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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user