afb59b6626
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>
322 lines
11 KiB
React
322 lines
11 KiB
React
import { useState } from 'react'
|
|
import Icon from './Icon'
|
|
import ContextMenu from './ContextMenu'
|
|
import { openGeschossSettings, openGeschossDialog } from '../lib/rhinoBridge'
|
|
|
|
function GeschossBadge({ name }) {
|
|
return <span className="chip chip-info">{name}</span>
|
|
}
|
|
|
|
function ZeichnungsebeneRow({
|
|
z, active, mode, onClick, onContextMenu,
|
|
onToggleVisible, onToggleLock, onToggleClipping, onDelete,
|
|
}) {
|
|
const isGeschoss = !!z.isGeschoss
|
|
// Eye-Logik: die aktive Z ist IMMER sichtbar (Backend forciert das), also
|
|
// zeigen wir ihr Auge immer als "an" — ohne Ruecksicht aufs visible-Flag.
|
|
// Nicht-aktive: in 'all_force' ist visible-Flag ueberschrieben (alle an),
|
|
// in 'active' ueberschrieben (alle aus) — Auge dimmt. Sonst (Ausgewaehlte/
|
|
// grey) reflektiert es das Flag direkt.
|
|
let eyeIcon, eyeOn, eyeOpacity, eyeTitle
|
|
if (active) {
|
|
eyeIcon = 'visibility'
|
|
eyeOn = true
|
|
eyeOpacity = 1
|
|
eyeTitle = z.visible !== false
|
|
? 'Sichtbar (aktive Zeichnungsebene)'
|
|
: 'Normalerweise ausgeblendet — wird gezeigt weil aktiv'
|
|
} else if (mode === 'all_force') {
|
|
eyeIcon = 'visibility'
|
|
eyeOn = true
|
|
eyeOpacity = 0.35
|
|
eyeTitle = 'Im „Alle anzeigen"-Mode immer sichtbar — Klick wechselt in „Ausgewählte"'
|
|
} else if (mode === 'active') {
|
|
eyeIcon = z.visible !== false ? 'visibility' : 'visibility_off'
|
|
eyeOn = false
|
|
eyeOpacity = 0.35
|
|
eyeTitle = 'Im „Nur aktive"-Mode ausgeblendet — Klick wechselt in „Ausgewählte"'
|
|
} else {
|
|
eyeIcon = z.visible !== false ? 'visibility' : 'visibility_off'
|
|
eyeOn = z.visible !== false
|
|
eyeOpacity = 1
|
|
eyeTitle = z.visible !== false ? 'Ausblenden' : 'Einblenden'
|
|
}
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
onContextMenu={onContextMenu}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: '4px 12px',
|
|
margin: active ? '1px 6px' : '0',
|
|
background: active ? 'var(--active-dim)' : 'var(--bg-item)',
|
|
borderRadius: active ? 999 : 0,
|
|
borderLeft: active ? 'none' : '3px solid transparent',
|
|
borderBottom: active ? 'none' : '1px solid var(--border-light)',
|
|
boxShadow: active ? 'inset 0 0 0 1px var(--active-light)' : 'none',
|
|
cursor: 'pointer',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
<button
|
|
className={`btn-icon-sm ${eyeOn ? 'is-on' : ''}`}
|
|
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
|
|
title={eyeTitle}
|
|
style={{ opacity: eyeOpacity }}
|
|
><Icon name={eyeIcon} size={14} /></button>
|
|
|
|
<span style={{
|
|
fontWeight: active ? 700 : 500,
|
|
fontSize: 12,
|
|
color: active ? 'var(--active-light)' : 'var(--text-label)',
|
|
flex: 1, minWidth: 0,
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>{z.name}</span>
|
|
|
|
{isGeschoss && (
|
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
|
|
+{(z.okff ?? 0).toFixed(2)}
|
|
</span>
|
|
)}
|
|
|
|
{isGeschoss && <GeschossBadge name={z.name} />}
|
|
|
|
{isGeschoss ? (
|
|
<button
|
|
className={`btn-icon-xs ${z.hasClipping ? 'is-on' : ''}`}
|
|
onClick={(ev) => { ev.stopPropagation(); onToggleClipping() }}
|
|
title={z.hasClipping
|
|
? 'Clipping Plane ausschalten'
|
|
: 'Clipping Plane einschalten (Schnitt auf Schnitthöhe)'}
|
|
style={{ color: z.hasClipping ? 'var(--accent)' : undefined }}
|
|
><Icon name="content_cut" size={12} /></button>
|
|
) : (
|
|
<span style={{ width: 18, flexShrink: 0 }} />
|
|
)}
|
|
|
|
<button
|
|
className="btn-icon-xs"
|
|
onClick={(ev) => { ev.stopPropagation(); onToggleLock() }}
|
|
title={z.locked ? 'Entsperren' : 'Sperren'}
|
|
style={{ color: z.locked ? 'var(--warn)' : undefined }}
|
|
><Icon name={z.locked ? 'lock' : 'lock_open'} size={12} /></button>
|
|
|
|
<button
|
|
className="btn-icon-xs"
|
|
onClick={(ev) => { ev.stopPropagation(); onDelete() }}
|
|
title="Löschen"
|
|
><Icon name="close" size={12} /></button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const MODES = [
|
|
{ value: 'all_force', label: 'Alle anzeigen' },
|
|
{ value: 'all', label: 'Ausgewählte' },
|
|
{ value: 'active', label: 'Nur aktive' },
|
|
{ value: 'grey', label: 'Andere grau' },
|
|
{ value: 'grey_locked', label: 'Andere grau & gesperrt' },
|
|
]
|
|
|
|
export default function GeschossManager({
|
|
zeichnungsebenen, activeId, onActiveChange, onChange, recalcOkff,
|
|
mode, onModeChange,
|
|
}) {
|
|
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, id }
|
|
|
|
const sorted = [...zeichnungsebenen].reverse()
|
|
const gesamthoehe = zeichnungsebenen
|
|
.filter(z => z.isGeschoss)
|
|
.reduce((s, z) => s + (z.hoehe ?? 0), 0)
|
|
|
|
const addQuick = () => {
|
|
// Standard: NICHT-Geschoss-Zeichnungsebene (z.B. Möblierung, Bemassung,
|
|
// Plangrafik etc.). User kann via Row-Kontextmenue auf Geschoss
|
|
// umschalten oder via Bearbeiten-Dialog (Pencil) ein Geschoss erstellen.
|
|
const nonGeschossCount = zeichnungsebenen.filter(z => !z.isGeschoss).length
|
|
const newZ = {
|
|
id: `z_${Date.now()}`,
|
|
name: `Zeichnung ${nonGeschossCount + 1}`,
|
|
isGeschoss: false,
|
|
visible: true,
|
|
}
|
|
onChange([...zeichnungsebenen, newZ])
|
|
}
|
|
|
|
const toggleVisible = (id) => {
|
|
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
|
|
// In "active" / "all_force" greift visible-Flag nicht — wer aufs Auge
|
|
// klickt will offensichtlich Sichtbarkeit kontrollieren, also direkt
|
|
// in den "Ausgewählte"-Mode wechseln damit die Aktion wirkt.
|
|
if (mode === 'active' || mode === 'all_force') onModeChange('all')
|
|
}
|
|
|
|
const toggleLock = (id) => {
|
|
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, locked: !z.locked } : z))
|
|
}
|
|
|
|
const toggleClipping = (id) => {
|
|
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, hasClipping: !z.hasClipping } : z))
|
|
}
|
|
|
|
const duplicate = (id) => {
|
|
const src = zeichnungsebenen.find(z => z.id === id)
|
|
if (!src) return
|
|
const clone = {
|
|
...src,
|
|
id: `z_${Date.now()}`,
|
|
name: `${src.name} Kopie`,
|
|
}
|
|
// Direkt nach dem Original einfuegen
|
|
const idx = zeichnungsebenen.findIndex(z => z.id === id)
|
|
const next = [...zeichnungsebenen]
|
|
next.splice(idx + 1, 0, clone)
|
|
onChange(next)
|
|
}
|
|
|
|
const remove = (id) => {
|
|
if (zeichnungsebenen.length <= 1) return
|
|
const target = zeichnungsebenen.find(z => z.id === id)
|
|
if (!target) return
|
|
if (!window.confirm(`"${target.name}" wirklich löschen?`)) return
|
|
onChange(zeichnungsebenen.filter(z => z.id !== id))
|
|
if (activeId === id) {
|
|
const next = zeichnungsebenen.find(z => z.id !== id)
|
|
if (next) onActiveChange(next.id)
|
|
}
|
|
}
|
|
|
|
const openContextMenu = (ev, id) => {
|
|
ev.preventDefault(); ev.stopPropagation()
|
|
setCtxMenu({ x: ev.clientX, y: ev.clientY, id })
|
|
}
|
|
|
|
const ctxItems = (id) => {
|
|
const z = zeichnungsebenen.find(x => x.id === id)
|
|
if (!z) return []
|
|
return [
|
|
{ label: 'Einstellungen…', icon: 'settings', onClick: () => openGeschossSettings(z) },
|
|
{ divider: true },
|
|
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicate(id) },
|
|
{ divider: true },
|
|
{ label: 'Löschen', icon: 'delete', danger: true,
|
|
disabled: zeichnungsebenen.length <= 1,
|
|
onClick: () => remove(id) },
|
|
]
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div style={{
|
|
display: 'flex', flexDirection: 'column', gap: 4,
|
|
padding: '6px 14px',
|
|
background: 'var(--bg-section)',
|
|
borderBottom: '1px solid var(--border-light)',
|
|
}}>
|
|
<span className="label-xs">Sichtbarkeit</span>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<select
|
|
value={mode}
|
|
onChange={ev => onModeChange(ev.target.value)}
|
|
style={{ flex: 1, minWidth: 0 }}
|
|
>
|
|
{MODES.map(m => (
|
|
<option key={m.value} value={m.value}>{m.label}</option>
|
|
))}
|
|
</select>
|
|
<button className="btn-icon-sm" onClick={addQuick} title="Zeichnungsebene hinzufügen">
|
|
<Icon name="add" size={14} />
|
|
</button>
|
|
<button className="btn-icon-sm" onClick={() => openGeschossDialog(zeichnungsebenen)} title="Bearbeiten">
|
|
<Icon name="edit" size={13} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
padding: '3px 14px',
|
|
background: 'var(--bg-section)',
|
|
borderBottom: '1px solid var(--border-light)',
|
|
}}>
|
|
<span className="label-xs">Gebäudehöhe</span>
|
|
<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-secondary)' }}>
|
|
{gesamthoehe.toFixed(2)} m
|
|
</span>
|
|
</div>
|
|
|
|
|
|
{/* Master-Row: Master-Eye links + Master-Lock rechts (analog
|
|
EbenenManager). */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 5,
|
|
padding: '2px 14px',
|
|
background: 'var(--bg-section)',
|
|
borderBottom: '1px solid var(--border)',
|
|
}}>
|
|
<button
|
|
className="btn-icon-xs"
|
|
onClick={() => {
|
|
const anyVisible = zeichnungsebenen.some(z => z.visible !== false)
|
|
onChange(zeichnungsebenen.map(z => ({ ...z, visible: !anyVisible })))
|
|
if (mode === 'active' || mode === 'all_force') onModeChange('all')
|
|
}}
|
|
title={zeichnungsebenen.every(z => z.visible !== false)
|
|
? 'Alle Zeichnungsebenen ausblenden'
|
|
: 'Alle Zeichnungsebenen einblenden'}
|
|
style={{ width: 18, height: 18,
|
|
opacity: (mode === 'active' || mode === 'all_force') ? 0.5 : 1 }}
|
|
>
|
|
<Icon
|
|
name={zeichnungsebenen.every(z => z.visible !== false) ? 'visibility' : 'visibility_off'}
|
|
size={12}
|
|
/>
|
|
</button>
|
|
<span style={{ flex: 1 }} />
|
|
<button
|
|
className="btn-icon-xs"
|
|
onClick={() => {
|
|
const anyLocked = zeichnungsebenen.some(z => z.locked === true)
|
|
onChange(zeichnungsebenen.map(z => ({ ...z, locked: !anyLocked })))
|
|
}}
|
|
title={zeichnungsebenen.every(z => z.locked === true)
|
|
? 'Alle Zeichnungsebenen entsperren'
|
|
: 'Alle Zeichnungsebenen sperren'}
|
|
style={{ width: 18, height: 18 }}
|
|
>
|
|
<Icon
|
|
name={zeichnungsebenen.every(z => z.locked === true) ? 'lock' : 'lock_open'}
|
|
size={11}
|
|
/>
|
|
</button>
|
|
<div style={{ width: 18 }} />
|
|
</div>
|
|
|
|
<div>
|
|
{sorted.map(z => (
|
|
<ZeichnungsebeneRow
|
|
key={z.id}
|
|
z={z}
|
|
active={z.id === activeId}
|
|
mode={mode}
|
|
onClick={() => onActiveChange(z.id)}
|
|
onContextMenu={(ev) => openContextMenu(ev, z.id)}
|
|
onToggleVisible={() => toggleVisible(z.id)}
|
|
onToggleLock={() => toggleLock(z.id)}
|
|
onToggleClipping={() => toggleClipping(z.id)}
|
|
onDelete={() => remove(z.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{ctxMenu && (
|
|
<ContextMenu
|
|
x={ctxMenu.x} y={ctxMenu.y}
|
|
items={ctxItems(ctxMenu.id)}
|
|
onClose={() => setCtxMenu(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|