Swisstopo Iter 2 + hierarchische Ebenen + 0-Kote m.ü.M

Swisstopo
- swissBUILDINGS3D 3.0 + Variant-Toggle (separated/solid) im Dialog
- Auto-Fallback auf 2.0 wenn 3.0-Tiles ueber 200 MB sind (Stadt-Fall)
- Defensiver Variant-Filter auf 3 Ebenen (Item, Asset, ZIP-Extract) — keine
  Doppelimporte mehr
- Auto-Skala korrigiert jetzt die importierten Objekte (×1000) statt die
  User-bbox zu schrumpfen — Buildings bleiben in m-Doc-Skala
- merge_grids: XYZ-Tiles werden vor dem Mesh-Bau vereint, kein 1m-Streifen
  zwischen Tiles mehr
- Layer-Konsolidierung: Build_*/Roof_*/Wall_*/Floor_* DWG-Source-Layer
  werden auf Sub-Sub-Layer unter 81_Swissbuildings/{Build,Roof,Wall,Floor}
  gemappt; solid-Variante landet flach direkt auf dem Parent
- 0-Kote m.ü.M (Projekt-Nullpunkt) wird beim Import als Z-Offset angewandt

Hierarchische Ebenen
- dossier_ebenen unterstuetzt jetzt 'children'-Array (rekursiv)
- layer_builder.build_layers rekursiv (Parent + Children unter jedem Geschoss)
- apply_visibility/update_layer_style/set_ebene_visible/set_ebene_locked
  walken den Tree (Sub-Sub-Layer mit gleichem Code-Prefix werden mit-gepflegt)
- EbenenManager mit Chevron-Toggle + Indent pro Level + Context-Menue-Item
  'Sub-Ebene hinzufuegen'
- rhinoBridge.applyVisibility schickt Children-Tree (nicht nur Top-Level) —
  sonst kommen Sub-Toggles nicht beim Backend an
- Visibility-Key in App.jsx rekursiv durch Children — useEffect feuert jetzt
  auch bei Sub-Eye-Toggles

0-Kote m.ü.M
- Eingabefeld im Geschoss-Settings-Dialog (projektweit)
- Speicherung als dossier_project_zero_mum in doc.Strings
- Wird im Swisstopo-Import als Z-Offset (m + doc-units) angewandt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 23:21:45 +02:00
parent 4111f12f32
commit afb59b6626
10 changed files with 1103 additions and 326 deletions
+20 -4
View File
@@ -62,9 +62,18 @@ export default function App() {
// Sichtbarkeit live anwenden bei Layer-Aenderungen. Zeichnungsebenen-Slice
// bleibt leer — Backend mergt mit doc.Strings.
// Rekursiv durch Children — sonst feuert das useEffect nicht wenn nur die
// Visibility/Lock einer Sub-Ebene geaendert wurde.
const visKeyFor = (e) => {
const own = `${e.code}:${e.visible !== false ? 1 : 0}:${e.locked === true ? 1 : 0}`
const kids = Array.isArray(e.children) && e.children.length
? '(' + e.children.map(visKeyFor).join(',') + ')'
: ''
return own + kids
}
const visibilityKey = useMemo(() => (
activeCode + '|' + eMode + '|' +
ebenen.map(e => `${e.code}:${e.visible !== false ? 1 : 0}:${e.locked === true ? 1 : 0}`).join(',')
ebenen.map(visKeyFor).join(',')
), [activeCode, eMode, ebenen])
useEffect(() => {
@@ -73,17 +82,24 @@ export default function App() {
}, [visibilityKey])
// Auto-Apply bei strukturellen Aenderungen (name, fill) — wieder nur unsere
// Slice, Backend mergt.
// Slice, Backend mergt. Rekursiv durch Children.
const fillSig = (e) => {
const f = e.fill
if (!f || !f.pattern || f.pattern === 'None') return ''
return [f.pattern, f.source || 'layer', f.color || '', f.scale ?? 1, f.rotation ?? 0].join('|')
}
const structKeyFor = (e) => {
const own = `${e.code}:${e.name}:${fillSig(e)}`
const kids = Array.isArray(e.children) && e.children.length
? '(' + e.children.map(structKeyFor).join(',') + ')'
: ''
return own + kids
}
const structureKey = useMemo(() => (
ebenen.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',')
ebenen.map(structKeyFor).join(',')
), [ebenen])
const appliedStructureKey = useMemo(() => (
appliedE.map(e => `${e.code}:${e.name}:${fillSig(e)}`).join(',')
appliedE.map(structKeyFor).join(',')
), [appliedE])
useEffect(() => {
+16 -1
View File
@@ -57,6 +57,7 @@ export default function SwisstopoApp() {
// Optionen
const [radius, setRadius] = useState(100)
const [getBuild, setGetBuild] = useState(true)
const [buildVariant, setBuildVariant] = useState('separated')
const [getTerrain, setGetTerrain] = useState(false)
const [getOrtho, setGetOrtho] = useState(false)
const [shift, setShift] = useState(true)
@@ -141,6 +142,7 @@ export default function SwisstopoApp() {
replaceExisting,
clipToBbox,
terrainResolution: terrainRes,
buildVariant,
})
}
@@ -235,9 +237,22 @@ export default function SwisstopoApp() {
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={getBuild} onChange={(e) => setGetBuild(e.target.checked)} />
<Icon name="location_city" size={13} /> Bestand-Gebäude (swissBUILDINGS3D, DWG)
<Icon name="location_city" size={13} /> Bestand-Gebäude (swissBUILDINGS3D 3.0, DWG)
</label>
</Field>
{getBuild && (
<Field label="GEBÄUDE-VARIANTE"
hint="Solid: ein geschlossenes Solid pro Gebäude (klein, schnell). Separated: Dach/Fassade/Wand als separate Objekte (mehr Detail, ermoeglicht z.B. Dach auszublenden).">
<Radio
value={buildVariant}
options={[
{ value: 'separated', label: 'Separated (Dach/Fassade getrennt)' },
{ value: 'solid', label: 'Solid (ein Volumen pro Gebäude)' },
]}
onChange={setBuildVariant}
/>
</Field>
)}
<Field label="">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
+161 -42
View File
@@ -145,7 +145,69 @@ function EditableText({ value, onCommit, style, fontWeight, fontSize, autoEditTr
)
}
function EbeneRow({ e, active, mode, onClick, onContextMenu, onToggleVisible, onToggleLock, onColorChange, onLwChange, onNameChange, onCodeChange, onDelete, autoEditCode, autoEditName, rowRef }) {
// --- Tree-Helper -----------------------------------------------------------
// Rekursive Updates: code ist global eindeutig (Children duerfen keinen
// bestehenden Top-Level Code haben). Helper finden/aendern den passenden
// Eintrag irgendwo im Tree.
function _updateInTree(ebenen, code, patch) {
return ebenen.map(e => {
if (e.code === code) return { ...e, ...patch }
if (Array.isArray(e.children) && e.children.length) {
return { ...e, children: _updateInTree(e.children, code, patch) }
}
return e
})
}
function _removeFromTree(ebenen, code) {
const out = []
for (const e of ebenen) {
if (e.code === code) continue
if (Array.isArray(e.children) && e.children.length) {
out.push({ ...e, children: _removeFromTree(e.children, code) })
} else {
out.push(e)
}
}
return out
}
function _addChildInTree(ebenen, parentCode, child) {
return ebenen.map(e => {
if (e.code === parentCode) {
const kids = Array.isArray(e.children) ? e.children : []
return { ...e, children: [...kids, child] }
}
if (Array.isArray(e.children) && e.children.length) {
return { ...e, children: _addChildInTree(e.children, parentCode, child) }
}
return e
})
}
function _findInTree(ebenen, code) {
for (const e of ebenen) {
if (e.code === code) return e
if (Array.isArray(e.children) && e.children.length) {
const f = _findInTree(e.children, code)
if (f) return f
}
}
return null
}
function _allCodes(ebenen) {
const out = []
for (const e of ebenen) {
out.push(e.code)
if (Array.isArray(e.children) && e.children.length) {
out.push(..._allCodes(e.children))
}
}
return out
}
function EbeneRow({ e, depth, hasChildren, expanded, onToggleExpand, active, mode, onClick, onContextMenu, onToggleVisible, onToggleLock, onColorChange, onLwChange, onNameChange, onCodeChange, onDelete, autoEditCode, autoEditName, rowRef }) {
// Auge zeigt den Eye-State (User-Intention) — auch fuer die aktive Ebene.
// So sieht man auf einen Blick ob sie "normalerweise" sichtbar waere.
// Aktive Ebene rendert Rhino zwar immer sichtbar, das visible-Flag bleibt
@@ -160,6 +222,7 @@ function EbeneRow({ e, active, mode, onClick, onContextMenu, onToggleVisible, on
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '3px 12px',
paddingLeft: 12 + (depth || 0) * 14,
margin: active ? '1px 6px' : '0',
background: active ? 'var(--active-dim)'
: (e.visible !== false) ? 'var(--bg-item)'
@@ -174,6 +237,16 @@ function EbeneRow({ e, active, mode, onClick, onContextMenu, onToggleVisible, on
userSelect: 'none',
}}
>
{hasChildren ? (
<button
className="btn-icon-xs"
onClick={(ev) => { ev.stopPropagation(); onToggleExpand() }}
title={expanded ? 'Einklappen' : 'Aufklappen'}
style={{ width: 14, height: 14 }}
><Icon name={expanded ? 'expand_more' : 'chevron_right'} size={12} /></button>
) : (
<span style={{ width: 14, flexShrink: 0 }} />
)}
{eyeShown ? (
<button
className={`btn-icon-sm ${e.visible !== false ? 'is-on' : ''}`}
@@ -262,6 +335,7 @@ export default function EbenenManager({
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, code }
const [clipboard, setClipboard] = useState(null) // { color, lw }
const [autoEdit, setAutoEdit] = useState(null) // { code, field, token }
const [expanded, setExpanded] = useState({}) // { code: true }
// Settings-Dialog laeuft jetzt in einem echten Rhino-Fenster (Satellite-
// Window via Eto.Form + WebView). State hier nicht mehr noetig.
@@ -279,9 +353,9 @@ export default function EbenenManager({
else { setSortBy(key); setSortDir('asc') }
}
const sortedEbenen = useMemo(() => {
const arr = [...ebenen]
arr.sort((a, b) => {
const sortByCurrent = (arr) => {
const sorted = [...arr]
sorted.sort((a, b) => {
let cmp = 0
if (sortBy === 'code') {
const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10)
@@ -293,19 +367,24 @@ export default function EbenenManager({
}
return sortDir === 'desc' ? -cmp : cmp
})
return arr
}, [ebenen, sortBy, sortDir])
return sorted
}
// Sort wirkt innerhalb jeder Ebene des Baums — Children behalten ihre
// Beziehung zum Parent, werden aber unter sich sortiert.
const sortedEbenen = useMemo(() => sortByCurrent(ebenen),
[ebenen, sortBy, sortDir])
const updateByCode = (code, patch) => {
onChange(ebenen.map(e => e.code === code ? { ...e, ...patch } : e))
onChange(_updateInTree(ebenen, code, patch))
}
const handleToggleVisible = (code) => {
const cur = ebenen.find(e => e.code === code)
const cur = _findInTree(ebenen, code)
if (cur) updateByCode(code, { visible: !(cur.visible !== false) })
}
const handleToggleLock = (code) => {
const cur = ebenen.find(e => e.code === code)
const cur = _findInTree(ebenen, code)
if (cur) updateByCode(code, { locked: !cur.locked })
}
const handleColorChange = (code, color) => {
@@ -324,8 +403,9 @@ export default function EbenenManager({
}
}
const handleCodeChange = (oldCode, newCode) => {
if (ebenen.some(e => e.code === newCode && e.code !== oldCode)) return
onChange(ebenen.map(e => e.code === oldCode ? { ...e, code: newCode } : e))
// Code muss global eindeutig sein (sonst gibt es mehrdeutige Layer-Matches)
if (_allCodes(ebenen).some(c => c === newCode && c !== oldCode)) return
onChange(_updateInTree(ebenen, oldCode, { code: newCode }))
// Phase weiterschalten: Code -> Name
if (autoEdit && autoEdit.code === oldCode && autoEdit.field === 'code') {
setAutoEdit({ code: newCode, field: 'name', token: Date.now() })
@@ -339,25 +419,27 @@ export default function EbenenManager({
const confirmDelete = (moveToCode) => {
const code = deleteTarget
deleteEbene(code, moveToCode)
onChange(ebenen.filter(e => e.code !== code))
onChange(_removeFromTree(ebenen, code))
if (activeCode === code) {
const next = ebenen.find(e => e.code !== code)
const flat = ebenen.flatMap(e =>
[e, ...(Array.isArray(e.children) ? e.children : [])])
const next = flat.find(e => e.code !== code)
if (next) onActiveChange(next.code)
}
setDeleteTarget(null)
}
const nextFreeAfter = (afterCode) => {
// Naechste freie Nummer NACH afterCode (= activeCode). Wenn afterCode
// = "20", probiert "21", dann "22", etc. Fallback: max+1.
const existing = new Set(ebenen.map(e => e.code))
// Naechste freie Nummer NACH afterCode. Codes sind global eindeutig
// (auch ueber Children) — also alle Codes als Konfliktraum.
const existing = new Set(_allCodes(ebenen))
let n = parseInt(afterCode, 10)
if (isNaN(n)) n = 49
for (let i = 1; i < 100; i++) {
for (let i = 1; i < 1000; i++) {
const c = String(n + i).padStart(2, '0')
if (!existing.has(c)) return c
}
const codes = ebenen.map(e => parseInt(e.code, 10)).filter(x => !isNaN(x))
const codes = _allCodes(ebenen).map(c => parseInt(c, 10)).filter(x => !isNaN(x))
return String((codes.length ? Math.max(...codes) : 49) + 1).padStart(2, '0')
}
@@ -379,11 +461,28 @@ export default function EbenenManager({
}
const duplicateEbene = (code) => {
const src = ebenen.find(e => e.code === code)
const src = _findInTree(ebenen, code)
if (!src) return
onChange([...ebenen, {
...src, code: nextFreeCode(), name: src.name + ' KOPIE',
}])
const dupCode = nextFreeAfter(code)
const dup = { ...src, code: dupCode, name: src.name + ' KOPIE' }
// Top-Level Eintrag — wir haengen Duplikat einfach hinten an
onChange([...ebenen, dup])
}
const addChild = (parentCode) => {
const code = nextFreeAfter(parentCode)
const child = {
code, name: 'NEU',
color: '#888888', lw: 0.18, visible: true, locked: false,
}
onChange(_addChildInTree(ebenen, parentCode, child))
// Parent expanden damit der neue Eintrag sichtbar ist
setExpanded(s => ({ ...s, [parentCode]: true }))
setAutoEdit({ code, field: 'code', token: Date.now() })
}
const toggleExpand = (code) => {
setExpanded(s => ({ ...s, [code]: !s[code] }))
}
const copyProps = (code) => {
@@ -405,10 +504,11 @@ export default function EbenenManager({
const ctxItems = (code) => [
{ label: 'Ebeneneinstellungen…', icon: 'settings', onClick: () => {
const target = ebenen.find(e => e.code === code)
const target = _findInTree(ebenen, code)
if (target) openEbenenSettings(target, hatchPatterns)
} },
{ divider: true },
{ label: 'Sub-Ebene hinzufügen…', icon: 'add', onClick: () => addChild(code) },
{ label: 'Selektion hierher übertragen', icon: 'move_down', onClick: () => moveSelectionToEbene(code) },
{ divider: true },
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateEbene(code) },
@@ -490,25 +590,44 @@ export default function EbenenManager({
<div style={{ width: 18 }} />
</div>
{sortedEbenen.map(e => (
<EbeneRow
key={e.code}
e={e}
active={e.code === activeCode}
mode={mode}
onClick={() => onActiveChange(e.code)}
onContextMenu={(ev) => openContextMenu(ev, e.code)}
onToggleVisible={() => handleToggleVisible(e.code)}
onToggleLock={() => handleToggleLock(e.code)}
onColorChange={(c) => handleColorChange(e.code, c)}
onLwChange={(lw) => handleLwChange(e.code, lw)}
onNameChange={(n) => handleNameChange(e.code, n)}
onCodeChange={(c) => handleCodeChange(e.code, c)}
onDelete={() => handleDelete(e.code)}
autoEditCode={autoEdit && autoEdit.code === e.code && autoEdit.field === 'code' ? autoEdit.token : null}
autoEditName={autoEdit && autoEdit.code === e.code && autoEdit.field === 'name' ? autoEdit.token : null}
/>
))}
{(() => {
// Rekursives Rendern: jede Ebene + sortierte Children (falls expanded)
const renderRow = (e, depth) => {
const kids = Array.isArray(e.children) ? e.children : []
const hasChildren = kids.length > 0
const isExpanded = !!expanded[e.code]
const rows = [
<EbeneRow
key={e.code}
e={e}
depth={depth}
hasChildren={hasChildren}
expanded={isExpanded}
onToggleExpand={() => toggleExpand(e.code)}
active={e.code === activeCode}
mode={mode}
onClick={() => onActiveChange(e.code)}
onContextMenu={(ev) => openContextMenu(ev, e.code)}
onToggleVisible={() => handleToggleVisible(e.code)}
onToggleLock={() => handleToggleLock(e.code)}
onColorChange={(c) => handleColorChange(e.code, c)}
onLwChange={(lw) => handleLwChange(e.code, lw)}
onNameChange={(n) => handleNameChange(e.code, n)}
onCodeChange={(c) => handleCodeChange(e.code, c)}
onDelete={() => handleDelete(e.code)}
autoEditCode={autoEdit && autoEdit.code === e.code && autoEdit.field === 'code' ? autoEdit.token : null}
autoEditName={autoEdit && autoEdit.code === e.code && autoEdit.field === 'name' ? autoEdit.token : null}
/>
]
if (hasChildren && isExpanded) {
for (const child of sortByCurrent(kids)) {
rows.push(...renderRow(child, depth + 1))
}
}
return rows
}
return sortedEbenen.flatMap(e => renderRow(e, 0))
})()}
{ctxMenu && (
<ContextMenu
+1
View File
@@ -245,6 +245,7 @@ export default function GeschossManager({
</span>
</div>
{/* Master-Row: Master-Eye links + Master-Lock rechts (analog
EbenenManager). */}
<div style={{
+14
View File
@@ -146,6 +146,20 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
/>
</>
)}
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
<Field
label="0-KOTE m.ü.M (PROJEKTWEIT)"
hint="Höhe ü. Meer am OKFF=0. Wird beim Swisstopo-Import als Z-Offset benutzt — alle Real-Welt-Höhen werden um diesen Wert runtergeschoben. Gilt projektweit (nicht nur dieses Geschoss).">
<input
type="number" step="0.01"
value={draft.projectZeroMum ?? 0}
onChange={(ev) => set({ projectZeroMum: parseFloat(ev.target.value) || 0 })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0,
fontFamily: 'var(--font-mono)' }}
/>
</Field>
</div>
{/* Footer */}
+14 -3
View File
@@ -318,9 +318,20 @@ export function applyVisibility(activeZ, zeichnungsebenen, activeCode, ebenen, z
visible: z.visible !== false,
locked: z.locked === true,
}))
const slimE = eList.map(e => ({
code: e.code, visible: e.visible !== false, locked: e.locked === true,
}))
// Rekursiv durch Children — sonst landen Sub-Ebenen-Toggles nicht beim
// Backend.
const slimEbene = (e) => {
const out = {
code: e.code,
visible: e.visible !== false,
locked: e.locked === true,
}
if (Array.isArray(e.children) && e.children.length) {
out.children = e.children.map(slimEbene)
}
return out
}
const slimE = eList.map(slimEbene)
send('SET_VISIBILITY', {
activeZ: a.activeZ ? { id: a.activeZ.id } : null,
activeCode: a.activeCode,