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. */} {z.name} {isGeschoss && ( +{(z.okff ?? 0).toFixed(2)} )} {isGeschoss && } {isSchnitt && ( )} {isGeschoss ? ( ) : ( )}
) } 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 => ( ))}
{/* 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. */}
{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({ 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 */}
Höhe (m) updateGeschossDialog({ hoehe: ev.target.value })} onKeyDown={(ev) => { if (ev.key === 'Enter') confirmGeschoss() else if (ev.key === 'Escape') setGeschossDialog(null) }} style={{ width: '100%', textAlign: 'right' }} />
Schnitt (m) updateGeschossDialog({ schnitthoehe: ev.target.value })} onKeyDown={(ev) => { if (ev.key === 'Enter') confirmGeschoss() else if (ev.key === 'Escape') setGeschossDialog(null) }} style={{ width: '100%', textAlign: 'right' }} />
) })()} ) }