import { useState } from 'react'
import Icon from './Icon'
import ContextMenu from './ContextMenu'
import { BarCombo, BarButton } from './BarControls'
import { openGeschossSettings, openGeschossDialog, createSchnitt } from '../lib/rhinoBridge'
function GeschossBadge({ name }) {
return {name}
}
function ZeichnungsebeneRow({
z, active, mode, onClick, onContextMenu,
onToggleVisible, onToggleLock, onToggleClipping, onDelete,
}) {
const isGeschoss = !!z.isGeschoss
const isSchnitt = z.type === 'schnitt'
// Schnitt vs Ansicht: cutAtLine!=false = Schnitt (mit Front-Cut), sonst Ansicht
const schnittIcon = (z.cutAtLine === false) ? 'visibility' : 'content_cut'
const schnittLabel = (z.cutAtLine === false) ? 'Ansicht' : 'Schnitt'
// 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 (
{/* Spacer-Slot — spiegelt den Chevron-Slot bei Ebenen-Rows wider
damit die Eye-Icons beider Panels untereinander stehen. */}
{ ev.stopPropagation(); onToggleVisible() }}
title={eyeTitle}
style={{ opacity: eyeOpacity, width: 16, height: 16 }}
>
{z.name}
{isGeschoss && (
+{(z.okff ?? 0).toFixed(2)}
)}
{isGeschoss && }
{isSchnitt && (
)}
{isGeschoss ? (
{ ev.stopPropagation(); onToggleClipping() }}
title={z.hasClipping
? 'Clipping Plane ausschalten'
: 'Clipping Plane einschalten (Schnitt auf Schnitthöhe)'}
style={{ color: z.hasClipping ? 'var(--accent)' : undefined, width: 14, height: 14 }}
>
) : (
)}
{ ev.stopPropagation(); onToggleLock() }}
title={z.locked ? 'Entsperren' : 'Sperren'}
style={{ color: z.locked ? 'var(--warn)' : undefined, width: 14, height: 14 }}
>
{ ev.stopPropagation(); onDelete() }}
title="Löschen"
style={{ width: 14, height: 14 }}
>
)
}
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 [addMenu, setAddMenu] = useState(null) // { x, y } — Picker beim +
const [geschossDialog, setGeschossDialog] = useState(null) // { x, y, pos:'above'|'below', name, hoehe, schnitthoehe, anchorId }
const sorted = [...zeichnungsebenen].reverse()
const addZeichnung = () => {
const nonGeschossCount = zeichnungsebenen.filter(
z => !z.isGeschoss && z.type !== "schnitt").length
const newZ = {
id: `z_${Date.now()}`,
name: `Zeichnung ${nonGeschossCount + 1}`,
isGeschoss: false,
visible: true,
}
onChange([...zeichnungsebenen, newZ])
}
// Vorgeschlagener Geschossname relativ zum Anker. Logik:
// EG + above → 1OG, EG + below → UG
// OG + above → OG, OG + below → wenn N=1 dann EG, sonst OG
// UG + above → wenn N=1 dann EG, sonst UG; UG + below → UG
// sonst: 'Neu'
const suggestGeschossName = (anchor, pos) => {
const nm = ((anchor?.name) || '').trim().toUpperCase()
const ogMatch = nm.match(/^(\d*)OG$/)
const ugMatch = nm.match(/^(\d*)UG$/)
if (nm === 'EG') return pos === 'above' ? '1OG' : 'UG'
if (ogMatch) {
const n = parseInt(ogMatch[1] || '1', 10)
if (pos === 'above') return `${n + 1}OG`
return n <= 1 ? 'EG' : `${n - 1}OG`
}
if (ugMatch) {
const n = parseInt(ugMatch[1] || '1', 10)
if (pos === 'below') return `${n + 1}UG`
return n <= 1 ? 'EG' : `${n - 1}UG`
}
return 'Neu'
}
// Project-Default-Hoehe: erst aktives Geschoss, dann erstes Geschoss in
// der Liste, dann hartcodiert 3.0. So uebernimmt jeder neue Eintrag den
// "typischen" Wert des Projekts ohne dass der User irgendwo setzen muss.
const defaultGeschossHoehe = () => {
const act = zeichnungsebenen.find(z => z.id === activeId)
if (act?.isGeschoss && act.hoehe != null) return act.hoehe
const first = zeichnungsebenen.find(z => z.isGeschoss && z.hoehe != null)
return first?.hoehe ?? 3.0
}
const defaultSchnitthoehe = () => {
const act = zeichnungsebenen.find(z => z.id === activeId)
if (act?.isGeschoss && act.schnitthoehe != null) return act.schnitthoehe
const first = zeichnungsebenen.find(z => z.isGeschoss && z.schnitthoehe != null)
return first?.schnitthoehe ?? 1.0
}
const openGeschossPrompt = (ev) => {
// Anker = aktives Geschoss (oder erstes Geschoss in der Liste). Falls
// gar keins da: einfach default-Werte ohne Anker.
const anchor = zeichnungsebenen.find(z => z.id === activeId && z.isGeschoss)
|| zeichnungsebenen.find(z => z.isGeschoss)
|| null
const pos = 'above'
const rect = ev?.currentTarget?.getBoundingClientRect()
setGeschossDialog({
x: rect ? rect.right - 240 : 200,
y: rect ? rect.bottom + 4 : 100,
pos,
name: suggestGeschossName(anchor, pos),
hoehe: defaultGeschossHoehe().toFixed(2),
schnitthoehe: defaultSchnitthoehe().toFixed(2),
anchorId: anchor?.id || null,
})
}
const confirmGeschoss = () => {
if (!geschossDialog) return
const { pos, name, hoehe, schnitthoehe, anchorId } = geschossDialog
const h = parseFloat(String(hoehe).replace(',', '.'))
const sh = parseFloat(String(schnitthoehe).replace(',', '.'))
const newZ = {
id: `z_${Date.now()}`,
name: (name || 'Neu').trim(),
isGeschoss: true,
hoehe: isFinite(h) && h > 0 ? h : 3.0,
schnitthoehe: isFinite(sh) && sh > 0 ? sh : 1.0,
visible: true,
}
// Insertion-Index: anchor finden, dann +1 (above) oder vorne ein (below).
// Array-Reihenfolge = bottom-up (recalcOkff stacks von index 0 aufwaerts),
// also "above" = nach anchor, "below" = vor anchor.
let nextList
if (anchorId) {
const idx = zeichnungsebenen.findIndex(z => z.id === anchorId)
if (idx >= 0) {
const insertAt = pos === 'above' ? idx + 1 : idx
nextList = [
...zeichnungsebenen.slice(0, insertAt),
newZ,
...zeichnungsebenen.slice(insertAt),
]
} else {
nextList = [...zeichnungsebenen, newZ]
}
} else {
nextList = [...zeichnungsebenen, newZ]
}
onChange(nextList)
setGeschossDialog(null)
}
const updateGeschossDialog = (patch) => {
if (!geschossDialog) return
const next = { ...geschossDialog, ...patch }
// Wenn pos ODER anchorId sich aendert: Name + Hoehen neu vorschlagen,
// aber nur wenn der User die jeweiligen Felder nicht schon manuell
// ueberschrieben hat (heuristisch: aktueller Wert === alter Vorschlag).
const posChanged = patch.pos && patch.pos !== geschossDialog.pos
const anchorChanged = patch.anchorId && patch.anchorId !== geschossDialog.anchorId
if (posChanged || anchorChanged) {
const oldAnchor = zeichnungsebenen.find(z => z.id === geschossDialog.anchorId)
const newAnchor = zeichnungsebenen.find(z => z.id === (patch.anchorId || geschossDialog.anchorId))
const oldSuggestedName = suggestGeschossName(oldAnchor, geschossDialog.pos)
if (geschossDialog.name === oldSuggestedName) {
next.name = suggestGeschossName(newAnchor, next.pos)
}
// Bei Anchor-Wechsel: Hoehe/Schnitthoehe auch nachziehen wenn unveraendert
if (anchorChanged && newAnchor?.isGeschoss) {
const oldHoeheStr = (oldAnchor?.hoehe ?? 3.0).toFixed(2)
const oldShStr = (oldAnchor?.schnitthoehe ?? 1.0).toFixed(2)
if (geschossDialog.hoehe === oldHoeheStr && newAnchor.hoehe != null) {
next.hoehe = newAnchor.hoehe.toFixed(2)
}
if (geschossDialog.schnitthoehe === oldShStr && newAnchor.schnitthoehe != null) {
next.schnitthoehe = newAnchor.schnitthoehe.toFixed(2)
}
}
}
setGeschossDialog(next)
}
const addSchnitt = () => createSchnitt() // triggert interaktiven Pick + Auto-Activate
const openAddMenu = (ev) => {
if (!ev) { addZeichnung(); return } // Fallback ohne Event-Position
const rect = ev.currentTarget.getBoundingClientRect()
setAddMenu({ x: rect.right - 180, y: rect.bottom + 4 })
}
// ContextMenu Items — onClick kriegt KEIN Event. Daher fuer Geschoss
// einen kleinen Trick: ContextMenu schliesst, dann oeffnen wir den
// Dialog mit einer kuenstlichen Position (rechts vom Panel).
const addMenuItems = [
{ label: 'Geschoss', icon: 'layers',
onClick: () => openGeschossPrompt({
currentTarget: { getBoundingClientRect: () =>
({ right: addMenu?.x + 180, bottom: addMenu?.y + 20 }) } }) },
{ label: 'Schnitt / Ansicht', icon: 'content_cut', onClick: addSchnitt },
{ divider: true },
{ label: 'Zeichnung', icon: 'edit_note', onClick: addZeichnung },
]
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 (
<>
Sichtbarkeit
openGeschossDialog(zeichnungsebenen)}
gearIcon="settings"
gearTitle="Einstellungen"
onSecond={openAddMenu}
secondIcon="add"
secondTitle="Hinzufuegen: Geschoss / Schnitt / Zeichnung"
>
{MODES.map(m => (
{m.label}
))}
{/* Master-Row analog EbenenManager. Padding + Icon-Sizes identisch
damit beide Panels visuell kohaerent sind. Erste 12px-Spanne
spiegelt den Chevron-Slot der Ebenen-Daten-Rows wider. */}
{
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: 16, height: 16,
opacity: (mode === 'active' || mode === 'all_force') ? 0.5 : 1 }}
>
z.visible !== false) ? 'visibility' : 'visibility_off'}
size={11}
/>
{
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: 14, height: 14 }}
>
z.locked === true) ? 'lock' : 'lock_open'}
size={11}
/>
{sorted.map(z => (
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)}
/>
))}
{ctxMenu && (
setCtxMenu(null)}
/>
)}
{addMenu && (
setAddMenu(null)}
/>
)}
{geschossDialog && (() => {
// Geschoss-Liste (top-down sortiert wie im Panel) fuer das Dropdown
const geschossOptions = [...zeichnungsebenen]
.filter(z => z.isGeschoss)
.reverse()
return (
<>
{/* Backdrop — Klick schliesst */}
setGeschossDialog(null)}
style={{ position: 'fixed', inset: 0, zIndex: 999,
background: 'transparent' }} />
ev.stopPropagation()}>
Neues Geschoss
{/* Position: Anker-Dropdown + Über/Unter-Toggle */}
Position relativ zu
updateGeschossDialog({ anchorId: ev.target.value })}
disabled={geschossOptions.length === 0}
style={{ flex: 1, fontSize: 11, minWidth: 0 }}>
{geschossOptions.length === 0 && — keins — }
{geschossOptions.map(g => (
{g.name}
))}
updateGeschossDialog({ pos: 'above' })}
title="Über dem Anker einfügen" />
updateGeschossDialog({ pos: 'below' })}
title="Unter dem Anker einfügen" />
{/* Name */}
Name
updateGeschossDialog({ name: ev.target.value })}
autoFocus
onKeyDown={(ev) => {
if (ev.key === 'Enter') confirmGeschoss()
else if (ev.key === 'Escape') setGeschossDialog(null)
}}
style={{ width: '100%' }} />
{/* Hoehe + Schnitthoehe */}
setGeschossDialog(null)}>Abbrechen
Hinzufügen
>
)
})()}
>
)
}