Initial commit — Dossier Rhino 8 Plugin

OpenStudio-Suite Architektur-Plugin fuer Rhino 8 (Mac):
- Smart-Elemente: Wand, Decke, Dach (Pult/Sattel/Walm/Mansarde),
  Oeffnungen (Fenster/Tueren mit Rahmen + Sims + Glas + Fluegel),
  Treppen (gerade · L · Wendel mit Schrittmass-Validierung)
- Live-Previews mit Step-Lines + Soll-Range-Clamping
- Bidirektionale Selection-Sync zwischen Source-Linie und Volume
- Geschoss-/Ebenen-Verwaltung mit OKFF-Persistenz
- Layouts mit PDF-Export
- Ausschnitte / Massstab / Override-Regeln
- Petrol-Gruen Theme (Rapport-konform)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 04:27:41 +02:00
commit 9dc191be4f
145 changed files with 32629 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
import { useState, useMemo, useEffect } from 'react'
import Icon from './Icon'
// Erzeugt ein vollstaendiges Draft-Array fuer einen Preset.
// Layer die im Preset nicht enthalten sind werden auf Default (visible=true,
// locked=false) gesetzt — sonst gibt es Schmutz wenn man zwischen Presets
// hin- und herwechselt.
function draftFromPreset(allLayers, preset) {
if (!preset || !preset.layers) {
return allLayers.map(l => ({ ...l }))
}
const map = {}
;(preset.layers || []).forEach(l => { map[l.id] = l })
return allLayers.map(l => {
const ps = map[l.id]
if (ps) return { ...l, visible: !!ps.visible, locked: !!ps.locked }
return { ...l, visible: true, locked: false }
})
}
export default function AusschnittLayerDialog({
snapName, layers, presets,
onSave, onClose,
onSavePreset, onDeletePreset,
}) {
// Welche Kombination wird gerade angezeigt? null = aktueller Doc-State
const [selectedPreset, setSelectedPreset] = useState(null)
const [draft, setDraft] = useState(() => layers.map(l => ({ ...l })))
const [dirty, setDirty] = useState(false)
const [filter, setFilter] = useState('')
const [newName, setNewName] = useState('')
// Wenn die Layer-Liste (von Backend) sich aendert wegen Doc-Update,
// resetten wir den Draft — aber nur wenn nicht dirty.
useEffect(() => {
if (dirty) return
if (selectedPreset === null) {
setDraft(layers.map(l => ({ ...l })))
} else {
const p = presets.find(p => p.name === selectedPreset)
setDraft(draftFromPreset(layers, p))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layers])
const filtered = useMemo(() => {
const f = filter.trim().toLowerCase()
if (!f) return draft
return draft.filter(l => (l.fullPath || l.name || '').toLowerCase().includes(f))
}, [draft, filter])
const pickPreset = (name) => {
if (dirty && !window.confirm('Ungespeicherte Änderungen verwerfen?')) return
setSelectedPreset(name || null)
setDirty(false)
if (!name) {
setDraft(layers.map(l => ({ ...l })))
} else {
const p = presets.find(p => p.name === name)
setDraft(draftFromPreset(layers, p))
}
}
const toggle = (id, field) => {
setDraft(d => d.map(l => l.id === id ? { ...l, [field]: !l[field] } : l))
setDirty(true)
}
const setAll = (field, value) => {
setDraft(d => d.map(l => filtered.find(f => f.id === l.id) ? { ...l, [field]: value } : l))
setDirty(true)
}
const savePresetChanges = () => {
if (!selectedPreset) return
if (!window.confirm(`Änderungen an Kombination "${selectedPreset}" speichern?`)) return
onSavePreset(selectedPreset, draft.map(l => ({ id: l.id, visible: l.visible, locked: l.locked })))
setDirty(false)
}
const saveAsNew = () => {
const name = newName.trim()
if (!name) return
if (presets.some(p => p.name === name) &&
!window.confirm(`Kombination "${name}" überschreiben?`)) return
onSavePreset(name, draft.map(l => ({ id: l.id, visible: l.visible, locked: l.locked })))
setSelectedPreset(name)
setDirty(false)
setNewName('')
}
const deleteSelected = () => {
if (!selectedPreset) return
if (!window.confirm(`Kombination "${selectedPreset}" löschen?`)) return
onDeletePreset(selectedPreset)
setSelectedPreset(null)
setDirty(false)
setDraft(layers.map(l => ({ ...l })))
}
const applyToDoc = () => {
if (dirty && selectedPreset &&
!window.confirm('Änderungen an dieser Kombination sind nicht gespeichert. Trotzdem auf das Dokument anwenden?')) return
onSave(draft)
}
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 150,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: 30,
}}>
<div style={{
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
boxShadow: 'var(--shadow-3)',
width: 'calc(100% - 24px)', maxWidth: 480,
maxHeight: 'calc(100vh - 60px)',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="layers" size={16} style={{ color: 'var(--text-secondary)' }} />
<span style={{ flex: 1, fontWeight: 600, fontSize: 12 }}>
{snapName}
</span>
{dirty && (
<span style={{ fontSize: 10, color: 'var(--warn)',
padding: '2px 6px', borderRadius: 'var(--r)',
background: 'var(--warn-dim)' }}
title="Ungespeicherte Änderungen"></span>
)}
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px' }}>×</button>
</div>
{/* Preset-Auswahl */}
<div style={{
padding: '10px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border-light)',
display: 'flex', flexDirection: 'column', gap: 8,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="label-xs" style={{ flexShrink: 0 }}>Kombination</span>
<select
value={selectedPreset || ''}
onChange={(ev) => pickPreset(ev.target.value || null)}
style={{ flex: 1, fontSize: 11 }}
>
<option value=""> Aktueller Zustand </option>
{presets.length > 0 && <option disabled></option>}
{presets.map(p => (
<option key={p.name} value={p.name}>
{p.name} ({p.layers?.length || 0} Ebenen)
</option>
))}
</select>
{selectedPreset && (
<button
className="btn-icon-sm"
onClick={deleteSelected}
title="Diese Kombination löschen"
style={{ color: 'var(--danger)' }}
>
<Icon name="delete" size={13} />
</button>
)}
</div>
{selectedPreset && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
className="btn-contained"
onClick={savePresetChanges}
disabled={!dirty}
style={{ fontSize: 10, padding: '3px 10px',
opacity: dirty ? 1 : 0.5 }}
title={dirty ? `Änderungen in "${selectedPreset}" speichern` : 'Keine Änderungen'}
>
<Icon name="save" size={12} />
<span>Speichern</span>
</button>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
Änderungen werden NICHT automatisch gespeichert.
</span>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
value={newName}
onChange={(ev) => setNewName(ev.target.value)}
onKeyDown={(ev) => { if (ev.key === 'Enter') saveAsNew() }}
placeholder="Aktuelle Auswahl als neue Kombination speichern…"
style={{ flex: 1, fontSize: 10 }}
/>
<button
className="btn-outlined"
onClick={saveAsNew}
disabled={!newName.trim()}
title="Aktuelle Auswahl unter diesem Namen speichern"
style={{ fontSize: 10, padding: '3px 8px' }}
>
<Icon name="add" size={12} /> Neu
</button>
</div>
</div>
{/* Such- und Bulk-Zeile */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 14px',
borderBottom: '1px solid var(--border-light)',
}}>
<input
value={filter}
onChange={(ev) => setFilter(ev.target.value)}
placeholder="Filter..."
style={{ flex: 1, fontSize: 10, padding: '3px 6px' }}
/>
<button className="btn-icon-xs" onClick={() => setAll('visible', true)} title="Alle (gefiltert) sichtbar">
<Icon name="visibility" size={12} />
</button>
<button className="btn-icon-xs" onClick={() => setAll('visible', false)} title="Alle (gefiltert) ausblenden">
<Icon name="visibility_off" size={12} />
</button>
<button className="btn-icon-xs" onClick={() => setAll('locked', false)} title="Alle (gefiltert) entsperren">
<Icon name="lock_open" size={12} />
</button>
</div>
{/* Layer-Liste */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 200, maxHeight: '50vh' }}>
{filtered.length === 0 ? (
<div style={{ padding: '30px 14px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>
Keine Ebenen gefunden.
</div>
) : (
filtered.map(l => (
<div
key={l.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 14px',
borderBottom: '1px solid var(--border-light)',
background: 'var(--bg-item)',
opacity: l.visible ? 1 : 0.5,
}}
>
<span style={{
width: 10, height: 10, borderRadius: 2,
background: l.color || '#888',
flexShrink: 0,
border: '1px solid var(--border-light)',
}} />
<span style={{
flex: 1, fontSize: 11,
color: 'var(--text-label)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontFamily: 'var(--font-mono)',
}} title={l.fullPath}>{l.fullPath || l.name}</span>
<button
className={`btn-icon-xs ${l.visible ? 'is-on' : ''}`}
onClick={() => toggle(l.id, 'visible')}
title={l.visible ? 'Ausblenden' : 'Einblenden'}
><Icon name={l.visible ? 'visibility' : 'visibility_off'} size={12} /></button>
<button
className={`btn-icon-xs ${l.locked ? 'is-on' : ''}`}
onClick={() => toggle(l.id, 'locked')}
title={l.locked ? 'Entsperren' : 'Sperren'}
><Icon name={l.locked ? 'lock' : 'lock_open'} size={12} /></button>
</div>
))
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
}}>
<div style={{ flex: 1, fontSize: 10, color: 'var(--text-muted)' }}>
{draft.filter(l => l.visible).length} / {draft.length} sichtbar
</div>
<button className="btn-text" onClick={onClose}>Schliessen</button>
<button className="btn-contained" onClick={applyToDoc}
title="Aktuelle Auswahl auf das Dokument anwenden">
<Icon name="check" size={12} />
<span>Auf Doc anwenden</span>
</button>
</div>
</div>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
export default function BottomBar({ onApply, dirty }) {
return (
<div style={{
flexShrink: 0,
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
padding: '10px 14px',
}}>
<button
onClick={onApply}
disabled={!dirty}
style={{
width: '100%',
padding: '8px 0',
borderRadius: 'var(--r)',
fontWeight: 600,
fontSize: 11,
letterSpacing: '0.07em',
textTransform: 'uppercase',
background: dirty ? 'var(--accent)' : 'var(--bg-item)',
color: dirty ? '#fff' : 'var(--text-muted)',
border: dirty ? 'none' : '1px solid var(--border)',
cursor: dirty ? 'pointer' : 'default',
transition: 'background 0.18s, color 0.18s',
}}
onMouseEnter={e => { if (dirty) e.currentTarget.style.background = 'var(--accent-light)' }}
onMouseLeave={e => { if (dirty) e.currentTarget.style.background = dirty ? 'var(--accent)' : 'var(--bg-item)' }}
>
{dirty ? 'Auf Rhino anwenden' : 'Keine Änderungen'}
</button>
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { useState } from 'react'
import Icon from './Icon'
export default function ConfirmDeleteEbene({ ebene, otherEbenen, onConfirm, onCancel }) {
const [target, setTarget] = useState(otherEbenen[0]?.code ?? '_delete')
const isDelete = target === '_delete'
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 16,
}}>
<div style={{
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
boxShadow: 'var(--shadow-3)',
width: 320, maxWidth: '100%',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
<div style={{ padding: '16px 18px 6px', display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: '50%',
background: 'var(--warn-dim)', color: 'var(--warn)',
flexShrink: 0,
}}>
<Icon name="warning" size={18} />
</span>
<div>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>
Ebene löschen
</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
<b>{ebene.code}_{ebene.name}</b> wird in allen Zeichnungsebenen entfernt.
</div>
</div>
</div>
<div style={{ padding: '10px 18px 14px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<span className="label-xs">Inhalte auf der Ebene</span>
<select value={target} onChange={ev => setTarget(ev.target.value)}>
{otherEbenen.map(e => (
<option key={e.code} value={e.code}> Verschieben nach {e.code}_{e.name}</option>
))}
<option value="_delete"> Inhalte ebenfalls löschen</option>
</select>
</div>
<div style={{
display: 'flex', gap: 4, padding: '10px 14px',
justifyContent: 'flex-end',
borderTop: '1px solid var(--border-light)',
background: 'var(--bg-section)',
}}>
<button className="btn-text" onClick={onCancel}>Abbrechen</button>
<button
className="btn-contained"
style={isDelete ? { background: 'var(--danger)' } : undefined}
onClick={() => onConfirm(isDelete ? null : target)}
>
Löschen
</button>
</div>
</div>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react'
import Icon from './Icon'
export default function ContextMenu({ x, y, items, onClose }) {
const ref = useRef(null)
const [pos, setPos] = useState({ left: x, top: y })
// Falls Menue rechts/unten ueberlaufen wuerde, links/oben verschieben
useEffect(() => {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
let left = x, top = y
if (left + rect.width > vw - 4) left = vw - rect.width - 4
if (top + rect.height > vh - 4) top = vh - rect.height - 4
if (left < 4) left = 4
if (top < 4) top = 4
setPos({ left, top })
}, [x, y])
useEffect(() => {
const handleClick = (ev) => {
if (ref.current && !ref.current.contains(ev.target)) onClose()
}
const handleKey = (ev) => { if (ev.key === 'Escape') onClose() }
const handleContext = (ev) => { ev.preventDefault(); onClose() }
const t = setTimeout(() => {
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKey)
document.addEventListener('contextmenu', handleContext)
}, 0)
return () => {
clearTimeout(t)
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKey)
document.removeEventListener('contextmenu', handleContext)
}
}, [onClose])
return (
<div
ref={ref}
style={{
position: 'fixed',
top: pos.top, left: pos.left,
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r)',
boxShadow: 'var(--shadow-3)',
padding: '4px 0',
minWidth: 180,
zIndex: 300,
}}
>
{items.map((it, i) => (
it.divider ? (
<div key={i} style={{ height: 1, background: 'var(--border-light)', margin: '4px 0' }} />
) : (
<button
key={i}
disabled={it.disabled}
onClick={() => { if (!it.disabled) { it.onClick(); onClose() } }}
onMouseEnter={(ev) => { if (!it.disabled) ev.currentTarget.style.background = 'var(--overlay-hover)' }}
onMouseLeave={(ev) => { ev.currentTarget.style.background = 'transparent' }}
style={{
width: '100%', textAlign: 'left',
padding: '6px 14px', fontSize: 11,
color: it.disabled ? 'var(--text-muted)'
: it.danger ? 'var(--danger)'
: 'var(--text-primary)',
display: 'flex', alignItems: 'center', gap: 10,
borderRadius: 0,
cursor: it.disabled ? 'default' : 'pointer',
background: 'transparent',
}}
>
{it.icon
? <Icon name={it.icon} size={14} style={{ color: it.disabled ? 'var(--text-muted)' : it.danger ? 'var(--danger)' : 'var(--text-secondary)' }} />
: <span style={{ width: 14 }} />}
<span style={{ flex: 1 }}>{it.label}</span>
{it.shortcut && <span style={{ fontSize: 9, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>{it.shortcut}</span>}
</button>
)
))}
</div>
)
}
+587
View File
@@ -0,0 +1,587 @@
import { useState, useRef, useMemo, useEffect } from 'react'
import Section from './Section'
import Icon from './Icon'
import ConfirmDeleteEbene from './ConfirmDeleteEbene'
import ContextMenu from './ContextMenu'
import EbenenSettingsDialog from './EbenenSettingsDialog'
import { setLayerStyle, deleteEbene, moveSelectionToEbene } from '../lib/rhinoBridge'
const MODES = [
{ value: 'all', label: 'Alle anzeigen' },
{ value: 'active', label: 'Nur aktive' },
{ value: 'grey', label: 'Andere grau' },
{ value: 'grey_locked', label: 'Andere grau & gesperrt' },
]
const LW_PRESETS = [0.02, 0.10, 0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00]
function ColorPicker({ color, onChange }) {
const ref = useRef(null)
return (
<>
<div
onClick={(ev) => { ev.stopPropagation(); ref.current?.click() }}
title="Farbe ändern"
style={{
width: 12, height: 12, borderRadius: 2,
background: color, flexShrink: 0,
border: '1px solid var(--border)',
cursor: 'pointer',
}}
/>
<input
ref={ref}
type="color"
value={color}
onChange={(ev) => onChange(ev.target.value)}
onClick={(ev) => ev.stopPropagation()}
style={{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }}
/>
</>
)
}
function LwCell({ lw, onChange }) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(lw.toFixed(2))
const commit = () => {
const v = parseFloat(value)
if (!isNaN(v) && v > 0) onChange(v)
else setValue(lw.toFixed(2))
setEditing(false)
}
const step = (dir) => {
let next
if (dir > 0) next = LW_PRESETS.find(p => p > lw + 0.001)
else next = [...LW_PRESETS].reverse().find(p => p < lw - 0.001)
if (next !== undefined) onChange(next)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 2, width: 42, justifyContent: 'flex-end', flexShrink: 0 }}>
{editing ? (
<input
type="number" step="0.01" min="0.01" max="2.0"
value={value}
autoFocus
onChange={(ev) => setValue(ev.target.value)}
onBlur={commit}
onKeyDown={(ev) => { if (ev.key === 'Enter') commit(); if (ev.key === 'Escape') { setValue(lw.toFixed(2)); setEditing(false) } }}
onClick={(ev) => ev.stopPropagation()}
style={{ width: 28, fontSize: 9, padding: '1px 2px', textAlign: 'right' }}
/>
) : (
<span
onDoubleClick={(ev) => { ev.stopPropagation(); setEditing(true); setValue(lw.toFixed(2)) }}
title="Doppelklick zum Bearbeiten"
style={{ fontSize: 9, fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', cursor: 'text', minWidth: 22, textAlign: 'right' }}
>{lw.toFixed(2)}</span>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(+1) }} style={{ width: 10, height: 7 }}>
<Icon name="arrow_drop_up" size={10} />
</button>
<button className="btn-step" onClick={(ev) => { ev.stopPropagation(); step(-1) }} style={{ width: 10, height: 7 }}>
<Icon name="arrow_drop_down" size={10} />
</button>
</div>
</div>
)
}
function EditableText({ value, onCommit, style, fontWeight, fontSize, autoEditTrigger }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(value)
const lastTrigger = useRef(null)
const inputRef = useRef(null)
// External trigger -> Edit-Modus aktivieren
useEffect(() => {
if (autoEditTrigger && autoEditTrigger !== lastTrigger.current) {
lastTrigger.current = autoEditTrigger
setVal(value)
setEditing(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoEditTrigger])
// Beim Edit-Start: Inhalt selektieren
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus()
try { inputRef.current.select() } catch (e) {}
}
}, [editing])
const commit = () => {
const trimmed = (val ?? '').trim()
if (trimmed && trimmed !== value) onCommit(trimmed)
else setVal(value)
setEditing(false)
}
if (editing) {
return (
<input
ref={inputRef}
value={val}
onChange={(ev) => setVal(ev.target.value)}
onBlur={commit}
onKeyDown={(ev) => {
if (ev.key === 'Enter') commit()
if (ev.key === 'Escape') { setVal(value); setEditing(false) }
}}
onClick={(ev) => ev.stopPropagation()}
style={{ ...style, fontWeight, fontSize, padding: '1px 4px' }}
/>
)
}
return (
<span
onDoubleClick={(ev) => { ev.stopPropagation(); setVal(value); setEditing(true) }}
title="Doppelklick zum Umbenennen"
style={{ ...style, fontWeight, fontSize, cursor: 'text' }}
>{value}</span>
)
}
function EbeneRow({ e, 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
// aber die gespeicherte Intention bis sie wieder de-aktiviert wird.
const eyeShown = mode !== 'active'
return (
<div
ref={rowRef}
data-ebene-code={e.code}
onClick={onClick}
onContextMenu={onContextMenu}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '3px 12px',
margin: active ? '1px 6px' : '0',
background: active ? 'var(--active-dim)'
: (e.visible !== false) ? 'var(--bg-item)'
: 'var(--bg-panel)',
// Pill-Form fuer die aktive Ebene, sonst Standard-Zeile mit Bottom-Border
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',
opacity: (!active && e.visible === false && mode !== 'all') ? 0.45 : 1,
cursor: 'pointer',
userSelect: 'none',
}}
>
{eyeShown ? (
<button
className={`btn-icon-sm ${e.visible !== false ? 'is-on' : ''}`}
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
title={
active
? (e.visible !== false
? 'Normalerweise sichtbar (aktive Ebene wird trotzdem gezeigt)'
: 'Normalerweise ausgeblendet — wird nur sichtbar weil aktiv')
: (e.visible !== false ? 'Ausblenden' : 'Einblenden')
}
><Icon name={e.visible !== false ? 'visibility' : 'visibility_off'} size={14} /></button>
) : (
<span style={{ width: 18, flexShrink: 0 }} />
)}
<EditableText
value={e.code}
onCommit={onCodeChange}
autoEditTrigger={autoEditCode}
fontSize={9}
style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-muted)', width: 24, textAlign: 'left', flexShrink: 0 }}
/>
<ColorPicker color={e.color} onChange={onColorChange} />
<div style={{ flex: 1, minWidth: 0 }}>
<EditableText
value={e.name}
onCommit={onNameChange}
autoEditTrigger={autoEditName}
fontWeight={active ? 700 : 400}
fontSize={11}
style={{
color: active ? 'var(--active-light)'
: (e.visible !== false) ? 'var(--text-label)'
: 'var(--text-muted)',
display: 'inline-block', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
/>
</div>
<LwCell lw={e.lw} onChange={onLwChange} />
<button
className="btn-icon-xs"
onClick={(ev) => { ev.stopPropagation(); onToggleLock() }}
title={e.locked ? 'Entsperren' : 'Sperren'}
style={{ color: e.locked ? 'var(--warn)' : undefined }}
><Icon name={e.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>
)
}
function SortHeader({ label, sortKey, sortBy, sortDir, onSort, style }) {
const active = sortBy === sortKey
return (
<span
className="label-xs"
onClick={() => onSort(sortKey)}
style={{
cursor: 'pointer', userSelect: 'none',
display: 'inline-flex', alignItems: 'center', gap: 2,
color: active ? 'var(--text-secondary)' : undefined, ...style,
}}
>
{label}
{active && <Icon name={sortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'} size={11} />}
</span>
)
}
export default function EbenenManager({
ebenen, activeCode, onActiveChange, onChange, mode, onModeChange, hatchPatterns,
combinations = [], activeCombName = null,
onPickCombination, onSaveCurrentCombination, onDeleteCombination,
onEditCombinations, onUserVisibilityChange,
}) {
const [sortBy, setSortBy] = useState('code')
const [sortDir, setSortDir] = useState('asc')
const [deleteTarget, setDeleteTarget] = useState(null)
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, code }
const [clipboard, setClipboard] = useState(null) // { color, lw }
const [autoEdit, setAutoEdit] = useState(null) // { code, field, token }
const [settingsCode, setSettingsCode] = useState(null) // code der Ebene deren Einstellungen offen sind
// Bei Auto-Edit: in View scrollen
useEffect(() => {
if (!autoEdit) return
const el = document.querySelector(`[data-ebene-code="${autoEdit.code}"]`)
if (el && el.scrollIntoView) {
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
}, [autoEdit])
const toggleSort = (key) => {
if (sortBy === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortBy(key); setSortDir('asc') }
}
const sortedEbenen = useMemo(() => {
const arr = [...ebenen]
arr.sort((a, b) => {
let cmp = 0
if (sortBy === 'code') {
const ca = parseInt(a.code, 10), cb = parseInt(b.code, 10)
cmp = (!isNaN(ca) && !isNaN(cb)) ? ca - cb : a.code.localeCompare(b.code)
} else if (sortBy === 'name') {
cmp = a.name.localeCompare(b.name)
} else if (sortBy === 'lw') {
cmp = a.lw - b.lw
}
return sortDir === 'desc' ? -cmp : cmp
})
return arr
}, [ebenen, sortBy, sortDir])
const updateByCode = (code, patch) => {
onChange(ebenen.map(e => e.code === code ? { ...e, ...patch } : e))
}
const handleToggleVisible = (code) => {
const cur = ebenen.find(e => e.code === code)
if (cur) updateByCode(code, { visible: !(cur.visible !== false) })
if (onUserVisibilityChange) onUserVisibilityChange()
}
const handleToggleLock = (code) => {
const cur = ebenen.find(e => e.code === code)
if (cur) updateByCode(code, { locked: !cur.locked })
if (onUserVisibilityChange) onUserVisibilityChange()
}
const handleColorChange = (code, color) => {
updateByCode(code, { color })
setLayerStyle(code, color, undefined)
}
const handleLwChange = (code, lw) => {
updateByCode(code, { lw })
setLayerStyle(code, undefined, lw)
}
const handleNameChange = (code, name) => {
updateByCode(code, { name })
// Wenn wir gerade neue Ebene anlegen (Phase 'name') — fertig
if (autoEdit && autoEdit.code === code && autoEdit.field === 'name') {
setAutoEdit(null)
}
}
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))
// Phase weiterschalten: Code -> Name
if (autoEdit && autoEdit.code === oldCode && autoEdit.field === 'code') {
setAutoEdit({ code: newCode, field: 'name', token: Date.now() })
}
}
const handleDelete = (code) => {
if (ebenen.length <= 1) return
setDeleteTarget(code)
}
const confirmDelete = (moveToCode) => {
const code = deleteTarget
deleteEbene(code, moveToCode)
onChange(ebenen.filter(e => e.code !== code))
if (activeCode === code) {
const next = ebenen.find(e => e.code !== code)
if (next) onActiveChange(next.code)
}
setDeleteTarget(null)
}
const nextFreeCode = () => {
const codes = ebenen.map(e => parseInt(e.code, 10)).filter(n => !isNaN(n))
const next = codes.length ? Math.max(...codes) + 1 : 50
return String(next).padStart(2, '0')
}
const addNew = () => {
const code = nextFreeCode()
onChange([...ebenen, {
code, name: 'NEU',
color: '#888888', lw: 0.18, visible: true, locked: false,
}])
// Code-Feld der neuen Ebene fokussieren
setAutoEdit({ code, field: 'code', token: Date.now() })
}
const duplicateEbene = (code) => {
const src = ebenen.find(e => e.code === code)
if (!src) return
onChange([...ebenen, {
...src, code: nextFreeCode(), name: src.name + ' KOPIE',
}])
}
const copyProps = (code) => {
const e = ebenen.find(x => x.code === code)
if (e) setClipboard({ color: e.color, lw: e.lw })
}
const pasteProps = (code) => {
if (!clipboard) return
onChange(ebenen.map(e => e.code === code ? { ...e, ...clipboard } : e))
setLayerStyle(code, clipboard.color, clipboard.lw)
}
const openContextMenu = (ev, code) => {
ev.preventDefault()
ev.stopPropagation()
setCtxMenu({ x: ev.clientX, y: ev.clientY, code })
}
const ctxItems = (code) => [
{ label: 'Ebeneneinstellungen…', icon: 'settings', onClick: () => setSettingsCode(code) },
{ divider: true },
{ label: 'Selektion hierher übertragen', icon: 'move_down', onClick: () => moveSelectionToEbene(code) },
{ divider: true },
{ label: 'Duplizieren', icon: 'content_copy', onClick: () => duplicateEbene(code) },
{ label: 'Eigenschaften kopieren', icon: 'colorize', onClick: () => copyProps(code) },
{ label: 'Eigenschaften einfügen', icon: 'format_paint', onClick: () => pasteProps(code), disabled: !clipboard },
{ divider: true },
{ label: 'Löschen', icon: 'delete', onClick: () => handleDelete(code), danger: true,
disabled: ebenen.length <= 1 },
]
const actionBtns = (
<button className="btn-add" onClick={addNew} title="Ebene hinzufügen">
<Icon name="add" size={16} />
</button>
)
return (
<Section title="Ebenen" badge={ebenen.length} action={actionBtns}>
{/* Ebenenkombinationen — Label + Dropdown + Save-As-Plus */}
<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">Ebenenkombination</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<select
value={activeCombName || '__custom__'}
onChange={(ev) => {
const v = ev.target.value
if (v === '__custom__') return
if (v === '__delete__') {
if (activeCombName && onDeleteCombination) onDeleteCombination(activeCombName)
return
}
if (onPickCombination) onPickCombination(v)
}}
style={{ flex: 1, minWidth: 0 }}
title={activeCombName ? `Aktiv: ${activeCombName}` : 'Eigene Sichtbarkeit (keine Kombination)'}
>
<option value="__custom__">{activeCombName ? activeCombName : 'Eigene'}</option>
{combinations.length > 0 && <option disabled></option>}
{combinations.map(p => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
{activeCombName && combinations.some(p => p.name === activeCombName) && (
<>
<option disabled></option>
<option value="__delete__">🗑 Aktuelle löschen</option>
</>
)}
</select>
<button
className="btn-icon-sm"
onClick={() => onSaveCurrentCombination && onSaveCurrentCombination()}
title="Aktuelle Sichtbarkeit als neue Kombination speichern"
>
<Icon name="add" size={14} />
</button>
<button
className="btn-icon-sm"
onClick={() => onEditCombinations && onEditCombinations()}
title="Alle Kombinationen bearbeiten (Dialog)"
>
<Icon name="edit" size={13} />
</button>
</div>
</div>
<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>
<select value={mode} onChange={ev => onModeChange(ev.target.value)} style={{ width: '100%' }}>
{MODES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '2px 14px',
background: 'var(--bg-section)',
borderBottom: '1px solid var(--border)',
}}>
{/* Master-Eye: alle Ebenen sichtbar/unsichtbar */}
<button
className="btn-icon-xs"
onClick={() => {
const anyVisible = ebenen.some(e => e.visible !== false)
// Wenn irgendeine sichtbar -> alle aus. Wenn keine sichtbar -> alle an.
onChange(ebenen.map(e => ({ ...e, visible: !anyVisible })))
if (onUserVisibilityChange) onUserVisibilityChange()
}}
title={ebenen.every(e => e.visible !== false)
? 'Alle Ebenen ausblenden'
: 'Alle Ebenen einblenden'}
style={{ width: 18, height: 18 }}
>
<Icon
name={ebenen.every(e => e.visible !== false) ? 'visibility' : 'visibility_off'}
size={12}
/>
</button>
<SortHeader label="Cd" sortKey="code" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} style={{ width: 24 }} />
<div style={{ width: 12 }} />
<SortHeader label="Name" sortKey="name" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} style={{ flex: 1 }} />
<SortHeader label="Lw" sortKey="lw" sortBy={sortBy} sortDir={sortDir} onSort={toggleSort} style={{ width: 42, textAlign: 'right', display: 'block' }} />
{/* Master-Lock: alle Ebenen sperren/entsperren */}
<button
className="btn-icon-xs"
onClick={() => {
const anyLocked = ebenen.some(e => e.locked === true)
onChange(ebenen.map(e => ({ ...e, locked: !anyLocked })))
if (onUserVisibilityChange) onUserVisibilityChange()
}}
title={ebenen.every(e => e.locked === true)
? 'Alle Ebenen entsperren'
: 'Alle Ebenen sperren'}
style={{ width: 18, height: 18 }}
>
<Icon
name={ebenen.every(e => e.locked === true) ? 'lock' : 'lock_open'}
size={11}
/>
</button>
<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}
/>
))}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x} y={ctxMenu.y}
items={ctxItems(ctxMenu.code)}
onClose={() => setCtxMenu(null)}
/>
)}
{deleteTarget && (
<ConfirmDeleteEbene
ebene={ebenen.find(e => e.code === deleteTarget)}
otherEbenen={ebenen.filter(e => e.code !== deleteTarget)}
onConfirm={confirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
)}
{settingsCode && (() => {
const target = ebenen.find(e => e.code === settingsCode)
if (!target) { setSettingsCode(null); return null }
return (
<EbenenSettingsDialog
ebene={target}
hatchPatterns={hatchPatterns}
onSave={(updated) => {
// Code-Wechsel handhaben (eindeutiger key)
onChange(ebenen.map(e => e.code === settingsCode ? updated : e))
setSettingsCode(null)
}}
onClose={() => setSettingsCode(null)}
/>
)
})()}
</Section>
)
}
+252
View File
@@ -0,0 +1,252 @@
import { useState } from 'react'
import Icon from './Icon'
function Field({ label, hint, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '5px 0' }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', fontWeight: 500, letterSpacing: 0.2 }}>
{label}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
{hint && (
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>{hint}</span>
)}
</div>
)
}
function SectionLabel({ children }) {
return (
<div style={{
fontSize: 9, color: 'var(--text-muted)', fontWeight: 600,
letterSpacing: 0.5, textTransform: 'uppercase',
padding: '8px 0 2px',
borderTop: '1px solid var(--border-light)',
marginTop: 6,
}}>{children}</div>
)
}
export default function EbenenSettingsDialog({ ebene, hatchPatterns = ['Solid'], onSave, onClose }) {
const [draft, setDraft] = useState({
...ebene,
fill: {
pattern: 'None',
source: 'layer',
color: null,
scale: 1.0,
rotation: 0,
lw: null, // Stiftstaerke der Hatch in mm; null = wie Stift der Ebene
...(ebene.fill || {}),
},
})
const set = (patch) => setDraft({ ...draft, ...patch })
const setFill = (patch) => setDraft({ ...draft, fill: { ...draft.fill, ...patch } })
const fill = draft.fill
const isFilled = fill.pattern !== 'None'
const isPattern = isFilled && fill.pattern !== 'Solid'
const fillFromLayer = fill.source === 'layer'
const previewColor = (fillFromLayer || !fill.color) ? draft.color : fill.color
// Pattern-Optionen: None + Solid + Patterns
const patternOptions = [
'None', 'Solid',
...hatchPatterns.filter(p => p !== 'Solid' && p !== 'None'),
]
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 150,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: 30,
}}>
<div style={{
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
boxShadow: 'var(--shadow-3)',
width: 'calc(100% - 16px)', maxWidth: 300,
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
maxHeight: 'calc(100vh - 60px)',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="settings" size={14} style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
<span style={{
flex: 1, fontWeight: 600, fontSize: 11,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{ebene.code} {ebene.name}
</span>
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px', lineHeight: 1 }}>×</button>
</div>
{/* Body */}
<div style={{ padding: '6px 12px 4px', overflowY: 'auto' }}>
<Field label="CODE">
<input
value={draft.code}
onChange={(ev) => set({ code: ev.target.value })}
maxLength={4}
style={{ flex: 1, fontSize: 11, fontFamily: 'var(--font-mono)', fontWeight: 600, minWidth: 0 }}
/>
</Field>
<Field label="NAME">
<input
value={draft.name}
onChange={(ev) => set({ name: ev.target.value })}
style={{ flex: 1, fontSize: 11, fontWeight: 600, minWidth: 0 }}
/>
</Field>
<Field label="FARBE (Stift)">
<input
type="color"
value={draft.color}
onChange={(ev) => set({ color: ev.target.value })}
style={{
width: 32, height: 22, padding: 0,
border: '1px solid var(--border)', borderRadius: 'var(--r)',
cursor: 'pointer', background: 'transparent',
}}
/>
<input
value={draft.color}
onChange={(ev) => set({ color: ev.target.value })}
style={{ flex: 1, fontSize: 10, fontFamily: 'var(--font-mono)', minWidth: 0 }}
/>
</Field>
<Field label="STRICHSTÄRKE (mm)">
<input
type="number" step="0.01" min="0.01" max="2.0"
value={draft.lw}
onChange={(ev) => set({ lw: parseFloat(ev.target.value) || draft.lw })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<SectionLabel>Schraffur Nach Ebene"</SectionLabel>
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4, display: 'block', marginBottom: 4 }}>
Wird auf neue geschlossene Kurven angewandt die auf dieser Ebene gezeichnet werden.
</span>
<Field label="PATTERN">
<select
value={fill.pattern}
onChange={(ev) => setFill({ pattern: ev.target.value })}
style={{ flex: 1, fontSize: 11, minWidth: 0 }}
>
{patternOptions.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</Field>
{isFilled && (
<>
<Field label="FARBE-QUELLE">
<select
value={fill.source}
onChange={(ev) => setFill({ source: ev.target.value })}
style={{ flex: 1, fontSize: 11, minWidth: 0 }}
>
<option value="layer">Nach Stift (Layerfarbe)</option>
<option value="object">Eigene Farbe</option>
</select>
</Field>
<Field label="VORSCHAU / FARBE">
<input
type="color"
value={previewColor}
disabled={fillFromLayer}
onChange={(ev) => setFill({ color: ev.target.value, source: 'object' })}
style={{
width: 32, height: 22, padding: 0,
border: '1px solid var(--border)', borderRadius: 'var(--r)',
cursor: fillFromLayer ? 'default' : 'pointer',
background: 'transparent',
opacity: fillFromLayer ? 0.6 : 1,
}}
/>
<span style={{ fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text-muted)' }}>
{previewColor}
</span>
</Field>
{isPattern && (
<>
<Field label="SKALIERUNG">
<input
type="number" step="0.05" min="0.001"
value={fill.scale}
onChange={(ev) => setFill({ scale: parseFloat(ev.target.value) || 1.0 })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<Field label="DREHUNG (°)">
<input
type="number" step="5"
value={fill.rotation}
onChange={(ev) => setFill({ rotation: parseFloat(ev.target.value) || 0 })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
</>
)}
<Field
label="STIFTSTÄRKE (mm)"
hint="Leer = wie Stift der Ebene. Eigener Wert ueberschreibt die Strichstaerke der Hatch-Linien."
>
<input
type="number" step="0.01" min="0"
value={fill.lw == null ? '' : fill.lw}
placeholder=""
onChange={(ev) => {
const v = ev.target.value
if (v === '' || v === null) setFill({ lw: null })
else {
const f = parseFloat(v)
if (!isNaN(f) && f >= 0) setFill({ lw: f })
}
}}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
<button
className="btn-text"
style={{ fontSize: 10, padding: '2px 6px' }}
onClick={() => setFill({ lw: null })}
title="Auf 'wie Stift der Ebene' zuruecksetzen"
>Default</button>
</Field>
</>
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 12px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
}}>
<div style={{ flex: 1 }} />
<button className="btn-text" onClick={onClose}>Abbrechen</button>
<button className="btn-contained" onClick={() => onSave(draft)}>Übernehmen</button>
</div>
</div>
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
import { useState } from 'react'
import Icon from './Icon'
export default function GeschossDialog({ zeichnungsebenen, recalcOkff, onSave, onClose }) {
const [draft, setDraft] = useState(zeichnungsebenen.map(z => ({ ...z })))
const update = (i, field, val) => {
const next = draft.map((z, idx) => idx === i ? { ...z, [field]: val } : z)
setDraft(recalcOkff(next))
}
const toggleGeschoss = (i) => {
const next = draft.map((z, idx) => {
if (idx !== i) return z
if (z.isGeschoss) {
return { ...z, isGeschoss: false, hoehe: undefined, schnitthoehe: undefined, okff: undefined }
} else {
return { ...z, isGeschoss: true, hoehe: z.hoehe ?? 3.00, schnitthoehe: z.schnitthoehe ?? 1.00 }
}
})
setDraft(recalcOkff(next))
}
const add = (isGeschoss) => {
const n = draft.length
const newZ = isGeschoss
? { id: `z_${Date.now()}`, name: `${n}OG`, isGeschoss: true, hoehe: 3.00, schnitthoehe: 1.00, visible: true }
: { id: `z_${Date.now()}`, name: `Neu ${n + 1}`, isGeschoss: false, visible: true }
setDraft(recalcOkff([...draft, newZ]))
}
const remove = (i) => {
if (draft.length <= 1) return
setDraft(recalcOkff(draft.filter((_, idx) => idx !== i)))
}
const move = (i, dir) => {
const next = [...draft]
const t = i + dir
if (t < 0 || t >= next.length) return
;[next[i], next[t]] = [next[t], next[i]]
setDraft(recalcOkff(next))
}
const gesamthoehe = draft
.filter(z => z.isGeschoss)
.reduce((s, z) => s + (z.hoehe ?? 0), 0)
const col = {
move: { width: 28, flexShrink: 0 },
geschoss:{ width: 24, flexShrink: 0 },
name: { flex: 1, minWidth: 60 },
okff: { width: 50, flexShrink: 0 },
hoehe: { width: 64, flexShrink: 0 },
schnitt: { width: 64, flexShrink: 0 },
del: { width: 22, flexShrink: 0 },
}
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: 40,
}}>
<div style={{
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
width: 'calc(100% - 24px)',
boxShadow: 'var(--shadow)',
display: 'flex', flexDirection: 'column',
maxHeight: 'calc(100vh - 80px)',
overflow: 'hidden',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px', borderBottom: '1px solid var(--border)', flexShrink: 0,
}}>
<span style={{ flex: 1, fontWeight: 600, fontSize: 12, color: 'var(--text-primary)' }}>
Zeichnungsebenen
</span>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
Gebäude {gesamthoehe.toFixed(2)} m
</span>
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px' }}>×</button>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 14px', background: 'var(--bg-section)',
borderBottom: '1px solid var(--border)', flexShrink: 0,
}}>
<div style={col.move} />
<span className="label-xs" style={col.geschoss}>G</span>
<span className="label-xs" style={col.name}>Name</span>
<span className="label-xs" style={col.okff}>OKFF</span>
<span className="label-xs" style={col.hoehe}>Höhe</span>
<span className="label-xs" style={col.schnitt}>Schnitt h</span>
<div style={col.del} />
</div>
<div style={{ overflowY: 'auto', flex: 1 }}>
{draft.map((z, i) => (
<div key={z.id} style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '5px 14px',
borderBottom: '1px solid var(--border-light)',
background: i % 2 === 0 ? 'var(--bg-item)' : 'var(--bg-dialog)',
}}>
<div style={{ ...col.move, display: 'flex', flexDirection: 'column', gap: 0 }}>
<button className="btn-step" onClick={() => move(i, -1)} disabled={i === 0}>
<Icon name="arrow_drop_up" size={14} />
</button>
<button className="btn-step" onClick={() => move(i, 1)} disabled={i === draft.length - 1}>
<Icon name="arrow_drop_down" size={14} />
</button>
</div>
<div style={col.geschoss}>
<input
type="checkbox"
checked={!!z.isGeschoss}
onChange={() => toggleGeschoss(i)}
title="Ist ein Geschoss"
style={{ cursor: 'pointer' }}
/>
</div>
<input
value={z.name}
onChange={ev => update(i, 'name', ev.target.value)}
style={{ ...col.name, fontWeight: 600, fontSize: 11 }}
/>
<div style={{ ...col.okff, color: z.isGeschoss ? 'var(--text-muted)' : 'transparent', fontSize: 11, fontFamily: 'var(--font-mono)', textAlign: 'right' }}>
{z.isGeschoss ? `+${(z.okff ?? 0).toFixed(2)}` : '—'}
</div>
<div style={{ ...col.hoehe, display: 'flex', alignItems: 'center', gap: 2 }}>
{z.isGeschoss ? (
<>
<input type="number" step="0.05" min="0.5" max="20"
value={z.hoehe ?? 3.0}
onChange={ev => update(i, 'hoehe', parseFloat(ev.target.value) || z.hoehe || 3.0)}
style={{ width: 44, textAlign: 'right' }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>m</span>
</>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}></span>
)}
</div>
<div style={{ ...col.schnitt, display: 'flex', alignItems: 'center', gap: 2 }}>
{z.isGeschoss ? (
<>
<input type="number" step="0.05" min="0.1"
value={z.schnitthoehe ?? 1.0}
onChange={ev => update(i, 'schnitthoehe', parseFloat(ev.target.value) || 1.0)}
style={{ width: 44, textAlign: 'right' }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>m</span>
</>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}></span>
)}
</div>
<div style={col.del}>
<button className="btn-icon-sm" onClick={() => remove(i)} title="Löschen">
<Icon name="close" size={14} />
</button>
</div>
</div>
))}
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 14px', borderTop: '1px solid var(--border)',
background: 'var(--bg-section)', flexShrink: 0,
}}>
<button className="btn-outlined" onClick={() => add(true)} style={{
color: 'var(--accent-light)', borderColor: 'var(--accent-border)',
}}>+ Geschoss</button>
<button className="btn-outlined" onClick={() => add(false)}>+ Zeichnung</button>
<div style={{ flex: 1 }} />
<button className="btn-text" onClick={onClose}>Abbrechen</button>
<button className="btn-contained" onClick={() => onSave(draft)}>Übernehmen</button>
</div>
</div>
</div>
)
}
+187
View File
@@ -0,0 +1,187 @@
import { useState } from 'react'
import Section from './Section'
import Icon from './Icon'
import GeschossDialog from './GeschossDialog'
import GeschossSettingsDialog from './GeschossSettingsDialog'
function GeschossBadge({ name }) {
return <span className="chip chip-info">{name}</span>
}
function ZeichnungsebeneRow({ z, active, mode, onClick, onToggleVisible, onSettings }) {
// Eye-State auch fuer die aktive Zeichnungsebene anzeigen (User-Intention)
const eyeShown = mode !== 'active'
const isGeschoss = !!z.isGeschoss
return (
<div
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 12px',
margin: active ? '1px 6px' : '0',
background: active ? 'var(--active-dim)' : 'var(--bg-item)',
// Pill-Form fuer die aktive Zeichnungsebene
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',
opacity: (!active && z.visible === false && mode !== 'all') ? 0.45 : 1,
}}
>
<span style={{
fontWeight: active ? 700 : 500,
fontSize: 12,
color: active ? 'var(--active-light)' : 'var(--text-label)',
flex: 1,
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 && z.hasClipping && (
<Icon name="content_cut" size={12} style={{ color: 'var(--accent)', flexShrink: 0 }} title="Clipping Plane aktiv" />
)}
{eyeShown ? (
<button
className={`btn-icon-sm ${z.visible !== false ? 'is-on' : ''}`}
onClick={(ev) => { ev.stopPropagation(); onToggleVisible() }}
title={
active
? (z.visible !== false
? 'Normalerweise sichtbar (aktive Zeichnungsebene wird trotzdem gezeigt)'
: 'Normalerweise ausgeblendet — wird nur sichtbar weil aktiv')
: (z.visible !== false ? 'Ausblenden' : 'Einblenden')
}
><Icon name={z.visible !== false ? 'visibility' : 'visibility_off'} size={14} /></button>
) : (
<span style={{ width: 18, flexShrink: 0 }} />
)}
<button
className="btn-icon-xs"
onClick={(ev) => { ev.stopPropagation(); onSettings() }}
title="Einstellungen"
><Icon name="settings" size={12} /></button>
</div>
)
}
const MODES = [
{ value: 'all', label: 'Alle anzeigen' },
{ 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 [dialogOpen, setDialogOpen] = useState(false)
const [settingsFor, setSettingsFor] = useState(null) // Geschoss-Objekt oder null
const sorted = [...zeichnungsebenen].reverse()
const gesamthoehe = zeichnungsebenen
.filter(z => z.isGeschoss)
.reduce((s, z) => s + (z.hoehe ?? 0), 0)
const addQuick = () => {
const newZ = {
id: `z_${Date.now()}`,
name: `Neu ${zeichnungsebenen.length + 1}`,
isGeschoss: false,
visible: true,
}
onChange([...zeichnungsebenen, newZ])
}
const actions = (
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button className="btn-add" onClick={addQuick} title="Zeichnungsebene hinzufügen">
<Icon name="add" size={16} />
</button>
<button className="btn-icon-tonal" onClick={() => setDialogOpen(true)} title="Bearbeiten">
<Icon name="edit" size={14} />
</button>
</div>
)
const toggleVisible = (id) => {
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
}
return (
<>
<Section title="Zeichnungsebenen" badge={zeichnungsebenen.length} action={actions}>
<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>
<select value={mode} onChange={ev => onModeChange(ev.target.value)} style={{ width: '100%' }}>
{MODES.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</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>
<div>
{sorted.map(z => (
<ZeichnungsebeneRow
key={z.id}
z={z}
active={z.id === activeId}
mode={mode}
onClick={() => onActiveChange(z.id)}
onToggleVisible={() => toggleVisible(z.id)}
onSettings={() => setSettingsFor(z)}
/>
))}
</div>
</Section>
{dialogOpen && (
<GeschossDialog
zeichnungsebenen={zeichnungsebenen}
recalcOkff={recalcOkff}
onSave={(updated) => { onChange(updated); setDialogOpen(false) }}
onClose={() => setDialogOpen(false)}
/>
)}
{settingsFor && (
<GeschossSettingsDialog
geschoss={settingsFor}
onSave={(updated) => {
onChange(zeichnungsebenen.map(z => z.id === updated.id ? updated : z))
setSettingsFor(null)
}}
onClose={() => setSettingsFor(null)}
/>
)}
</>
)
}
+150
View File
@@ -0,0 +1,150 @@
import { useState } from 'react'
import Icon from './Icon'
/** Vertikales Feld-Layout: Label oben, Input darunter — passt in schmale Panels. */
function Field({ label, hint, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '5px 0' }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', fontWeight: 500, letterSpacing: 0.2 }}>
{label}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>{children}</div>
{hint && (
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4 }}>
{hint}
</span>
)}
</div>
)
}
/** Toggle-Reihe: Checkbox + Label inline, Hint darunter wenn vorhanden. */
function Toggle({ label, checked, onChange, hint }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: '5px 0' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={checked} onChange={(ev) => onChange(ev.target.checked)} />
<span style={{ fontSize: 11, color: 'var(--text-primary)' }}>{label}</span>
</label>
{hint && (
<span style={{ fontSize: 9, color: 'var(--text-muted)', lineHeight: 1.4, marginLeft: 22 }}>
{hint}
</span>
)}
</div>
)
}
export default function GeschossSettingsDialog({ geschoss, onSave, onClose }) {
const [draft, setDraft] = useState({ ...geschoss })
const set = (patch) => setDraft({ ...draft, ...patch })
const isG = !!draft.isGeschoss
const hoehe = draft.hoehe ?? 3.0
const schnitt = draft.schnitthoehe ?? 1.0
const hasClip = !!draft.hasClipping
const okff = draft.okff ?? 0
const clipZ = (okff + schnitt).toFixed(2)
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 150,
background: 'var(--bg-overlay)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: 30,
}}>
<div style={{
background: 'var(--bg-dialog)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
boxShadow: 'var(--shadow-3)',
width: 'calc(100% - 16px)', maxWidth: 280,
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
}}>
<Icon name="settings" size={14} style={{ color: 'var(--text-secondary)', flexShrink: 0 }} />
<span style={{
flex: 1, fontWeight: 600, fontSize: 11,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{geschoss.name}
</span>
<button onClick={onClose} style={{ color: 'var(--text-muted)', fontSize: 16, padding: '0 4px', lineHeight: 1 }}>×</button>
</div>
{/* Body */}
<div style={{ padding: '6px 12px 4px' }}>
<Field label="NAME">
<input
value={draft.name}
onChange={(ev) => set({ name: ev.target.value })}
style={{ flex: 1, fontSize: 11, fontWeight: 600, minWidth: 0 }}
/>
</Field>
<Toggle
label="Ist Geschoss"
checked={isG}
onChange={(v) => set({ isGeschoss: v })}
hint={isG ? 'Höhe & Clipping verfügbar' : 'reines Zeichenblatt'}
/>
{isG && (
<>
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
<Field label="HÖHE (m)">
<input
type="number" step="0.05" min="0.5" max="30"
value={hoehe}
onChange={(ev) => set({ hoehe: parseFloat(ev.target.value) || hoehe })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<Field label="SCHNITTHÖHE (m)" hint="über Geschossboden">
<input
type="number" step="0.05" min="0.1"
value={schnitt}
onChange={(ev) => set({ schnitthoehe: parseFloat(ev.target.value) || 1.0 })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
<Toggle
label="Clipping Plane"
checked={hasClip}
onChange={(v) => set({ hasClipping: v })}
hint={
hasClip
? `Horizontaler Schnitt bei +${clipZ}m (OKFF + Schnitthöhe). Sichtbar in der Top-Ansicht wenn dieses Geschoss aktiv ist.`
: 'aus'
}
/>
</>
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 12px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-section)',
}}>
<div style={{ flex: 1 }} />
<button className="btn-text" onClick={onClose}>Abbrechen</button>
<button className="btn-contained" onClick={() => onSave(draft)}>Übernehmen</button>
</div>
</div>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
export default function Icon({ name, size = 18, fill = 0, weight = 400, style }) {
return (
<span
className="material-symbols-outlined"
style={{
fontSize: size,
lineHeight: 1,
fontVariationSettings: `'FILL' ${fill}, 'wght' ${weight}, 'opsz' 20`,
...style,
}}
>{name}</span>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react'
import Icon from './Icon'
export default function Section({ title, badge, action, defaultOpen = true, children }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div>
<div
onClick={() => setOpen(o => !o)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px 4px',
cursor: 'pointer',
userSelect: 'none',
}}
>
<span style={{
transform: open ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
color: 'var(--text-muted)',
display: 'inline-flex',
marginLeft: -6,
}}>
<Icon name="arrow_drop_down" size={18} />
</span>
<span style={{
fontSize: 11, fontWeight: 500,
color: 'var(--text-primary)',
letterSpacing: '0.02em',
}}>{title}</span>
{badge != null && <span className="chip" style={{ fontSize: 8 }}>{badge}</span>}
<div style={{ flex: 1, height: 1, background: 'var(--border)', marginLeft: 4 }} />
{action && <div onClick={e => e.stopPropagation()}>{action}</div>}
</div>
{open && children}
</div>
)
}