Files
DOSSIER/src/ElementeApp.jsx
T
karim 961b3c0396 Snapshot: Wand/Öffnung Multi-Surface-Select + Z-Drag + Brüstungs-Mitnahme
Stable working state after a long iteration session. The plugin now supports:
- Multi-Surface-Select für alle Element-Typen (Türen/Fenster/Treppen/Tragwerk)
- Wand-Z-Drag → unbound mode (UK/OK-Override, Wand vom Geschoss entkoppelt)
- Wand-Z-Drag nimmt verknüpfte Öffnungen mit (Brüstung += delta_z via Idle-Pfad)
- Öffnungs-XY-Drag snapt direktional auf Wand-Tangente
- Öffnungs-Z-Drag passt Brüstung an (Fenster sofort sync, Tür deferred)
- Wand-Delete kaskadiert Öffnungen (deferred via Idle, robust gegen _Rotate/_Move)
- Source-Cascade beim Öffnungs-Delete (deferred analog Wand-Kaskade)
- Listener-Cleanup robust gegen _reset_panels.py Reload (Refs in
  _dossier_runtime_event_refs gespeichert, vor Re-Install deregistriert)
- _count_same_id_type filtert IsDeleted (verhindert Source-Duplikat-Bug bei Move)
- Frontend: Brüstungs-Slider für Tür ("Schwelle"), Flügel-Block nur bei Fenster

Plus aus früherer Phase dieser Session:
- Dossier-Launcher Auto-Load via Rhinos StartupCommands-XML
- Default-Pfad zeigt auf gebundeltes startup.py (out-of-the-box für neue User)
- Splash-Window beim Plugin-Load mit native macOS rounded corners
- Diverse Launcher-Verbesserungen (Brüstungs-Default, tauri.conf, capabilities)

Known issue: bei Multi-Select-Move mit vielen Sub-Volumen kann sporadisch
"Unable to transform" auftreten (Rhinos Move-Operation kollidiert mit Wand-
Regen). Tür-spezifischer Defer-Pfad mildert das, Fenster läuft sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:50:45 +02:00

1995 lines
81 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import Icon from './components/Icon'
import {
onMessage, notifyReady,
listElemente, createWall, createDecke, createDach,
createFenster, createTuer, createAussparung, createTreppe,
createStuetze, createTraeger, createRaum,
exportRaeume,
updateElement, deleteElement, regenerateAllElements,
} from './lib/rhinoBridge'
const labelXs = {
fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase',
}
function fmtNum(v) {
if (v == null || v === '') return ''
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return Number(n.toFixed(4)).toString()
}
function ReferenzSelector({ value, onChange }) {
const opts = [
{ code: 'left', label: 'Links', hint: 'Achse auf linker Aussenseite' },
{ code: 'mid', label: 'Mittig', hint: 'Achse zentriert (Standard)' },
{ code: 'right', label: 'Rechts', hint: 'Achse auf rechter Aussenseite' },
]
return (
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{opts.map(o => (
<button
key={o.code}
onClick={() => onChange(o.code)}
className={value === o.code ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
title={o.hint}
>
{o.label}
</button>
))}
</div>
)
}
// Pill-Button — kompakt, Icon + Label horizontal, abgerundet
function PillButton({ icon, label, hint, onClick, onContextMenu, disabled,
hasMenu, badge }) {
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
disabled={disabled}
title={hint}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '5px 10px 5px 8px',
background: 'var(--bg-input)',
border: '1px solid var(--border-light)',
borderRadius: 999,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.4 : 1,
transition: 'background 0.1s, border-color 0.1s',
fontSize: 11, fontWeight: 500,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
position: 'relative',
}}
onMouseEnter={(e) => { if (!disabled) {
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.borderColor = 'var(--accent)'
}}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.borderColor = 'var(--border-light)'
}}
>
<Icon name={icon} size={14} style={{ color: 'var(--accent)' }} />
<span>{label}</span>
{hasMenu && (
<Icon name="expand_more" size={12}
style={{ color: 'var(--text-muted)', marginLeft: -2 }} />
)}
{badge && (
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 8,
background: 'var(--bg-section)', color: 'var(--text-muted)',
marginLeft: 2,
}}>{badge}</span>
)}
</button>
)
}
// Vertikale Kategorie-Gruppe mit Label + Pills, die wrappen
function PillGroup({ label, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{
fontSize: 9, color: 'var(--text-muted)',
letterSpacing: '0.08em', textTransform: 'uppercase',
fontWeight: 600,
}}>
{label}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{children}
</div>
</div>
)
}
// Popup-Menue (relativ positioniert) fuer Untertypen wie Treppen-Art
function PopupMenu({ items, onClose }) {
useEffect(() => {
const onDocClick = () => onClose()
document.addEventListener('click', onDocClick)
return () => document.removeEventListener('click', onDocClick)
}, [onClose])
return (
<div style={{
position: 'absolute',
top: '100%', left: 0,
marginTop: 4,
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 6,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
padding: 4,
zIndex: 100,
minWidth: 140,
}}>
{items.map((it, i) => (
<button key={i}
onClick={(e) => { e.stopPropagation(); it.onClick(); onClose() }}
disabled={it.disabled}
title={it.hint || ''}
style={{
display: 'flex', alignItems: 'center', gap: 6,
width: '100%', padding: '6px 8px',
background: 'transparent',
border: 'none', borderRadius: 4,
cursor: it.disabled ? 'not-allowed' : 'pointer',
opacity: it.disabled ? 0.4 : 1,
color: 'var(--text-primary)',
fontSize: 11, textAlign: 'left',
}}
onMouseEnter={(e) => { if (!it.disabled) {
e.currentTarget.style.background = 'var(--bg-item-hover)'
}}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}>
{it.icon && <Icon name={it.icon} size={12}
style={{ color: 'var(--accent)' }} />}
<span style={{ flex: 1 }}>{it.label}</span>
{it.badge && (
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>{it.badge}</span>
)}
</button>
))}
</div>
)
}
const KIND_META = {
wand: { icon: 'view_week', label: 'Wand', color: '#a8b8c8' },
decke: { icon: 'layers', label: 'Decke', color: '#b8a890' },
dach: { icon: 'roofing', label: 'Dach', color: '#c89878' },
fenster: { icon: 'window', label: 'Fenster', color: '#90b8d0' },
tuer: { icon: 'sensor_door', label: 'Tür', color: '#c8a878' },
treppe: { icon: 'stairs', label: 'Treppe', color: '#a0c0a0' },
stuetze: { icon: 'square_foot', label: 'Stütze', color: '#5fa896' },
traeger: { icon: 'horizontal_rule', label: 'Träger', color: '#7fc8a8' },
raum: { icon: 'crop_free', label: 'Raum', color: '#a0a8b0' },
aussparung: { icon: 'rectangle', label: 'Aussparung', color: '#9090a0' },
}
const RAUM_RUNDUNGEN = ['exakt', '0.01', '0.1', '0.5', '1']
const RAUM_ALIGN = [
{ code: 'links', label: 'Links', icon: 'format_align_left' },
{ code: 'mid', label: 'Mitte', icon: 'format_align_center' },
{ code: 'rechts', label: 'Rechts', icon: 'format_align_right' },
]
const RAUM_SIA_KINDS = [
{ code: '', label: '—', color: 'transparent', hint: '' },
{ code: 'hnf', label: 'HNF', color: '#e8a8a8', hint: 'Hauptnutzfläche' },
{ code: 'nnf', label: 'NNF', color: '#e8c498', hint: 'Nebennutzfläche' },
{ code: 'vf', label: 'VF', color: '#e8d878', hint: 'Verkehrsfläche' },
{ code: 'ff', label: 'FF', color: '#a8c8e0', hint: 'Funktionsfläche' },
]
const PROFIL_META = {
quadrat: { label: 'Quadrat', icon: 'square' },
rechteck: { label: 'Rechteck', icon: 'rectangle' },
rund: { label: 'Rund', icon: 'circle' },
i_profil: { label: 'I-Profil', icon: 'view_column' },
rohr: { label: 'Rohr', icon: 'radio_button_unchecked' },
}
function ElementList({ elements }) {
// Gruppiert nach kind, dann nach geschoss-Reihenfolge wie sie reinkommen
const grouped = {}
for (const el of elements) {
const k = el.kind || 'unknown'
if (!grouped[k]) grouped[k] = []
grouped[k].push(el)
}
const kindOrder = ['wand', 'decke', 'dach', 'fenster', 'tuer', 'aussparung',
'treppe', 'stuetze', 'traeger', 'raum']
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10,
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ ...labelXs, flex: 1 }}>Alle Elemente</span>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>{elements.length}</span>
</div>
{kindOrder.map(k => {
const arr = grouped[k]
if (!arr || arr.length === 0) return null
const meta = KIND_META[k] || { icon: 'help', label: k, color: '#888' }
return (
<div key={k} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 5,
fontSize: 9, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase',
fontWeight: 600,
paddingLeft: 2,
}}>
<Icon name={meta.icon} size={10}
style={{ color: meta.color }} />
<span>{meta.label}</span>
<span style={{ color: 'var(--text-muted)', opacity: 0.6 }}>·</span>
<span style={{ opacity: 0.8 }}>{arr.length}</span>
</div>
{arr.map(el => (
<ElementListRow key={el.id} el={el} meta={meta} />
))}
</div>
)
})}
</div>
)
}
function ElementListRow({ el, meta }) {
const secondary = (() => {
if (el.kind === 'fenster' || el.kind === 'tuer')
return `${fmtNum(el.breite)}×${fmtNum(el.hoehe)} m`
if (el.kind === 'treppe')
return `${el.nStufen} St · H ${fmtNum(el.ok - el.uk)} m`
if (el.kind === 'dach' && el.neigung != null)
return `d ${fmtNum(el.dicke)} · ${fmtNum(el.neigung)}°`
if (el.kind === 'stuetze' || el.kind === 'traeger') {
const pl = (PROFIL_META[el.profil] || {}).label || el.profil
const dim = (el.profil === 'rund' || el.profil === 'rohr')
? `Ø${fmtNum(el.d)}`
: `${fmtNum(el.b)}×${fmtNum(el.h)}`
return `${pl} ${dim}`
}
if (el.kind === 'raum') {
const label = el.nummer ? `${el.nummer} ${el.name}` : el.name
return label || 'Raum'
}
if (el.kind === 'aussparung')
return `${fmtNum(el.area)}`
return `d ${fmtNum(el.dicke)} m`
})()
const tertiary = (() => {
if (el.kind === 'fenster')
return `Br ${fmtNum(el.brueest)}`
if (el.kind === 'tuer' || el.kind === 'treppe')
return ''
if (el.kind === 'stuetze')
return `H ${fmtNum(el.ok - el.uk)} m`
if (el.kind === 'traeger')
return `L ${fmtNum(el.axisLen)} m`
if (el.kind === 'raum') {
const siaInfo = RAUM_SIA_KINDS.find(s => s.code === (el.sia || ''))
const tag = siaInfo && siaInfo.code ? `${siaInfo.label} · ` : ''
return `${tag}${el.areaFmt}`
}
if (el.kind === 'aussparung')
return `U ${fmtNum(el.umfang)} m`
return `UK ${fmtNum(el.uk)} · OK ${fmtNum(el.ok)}`
})()
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '5px 8px',
background: el.selected ? 'var(--bg-item-active)' : 'transparent',
border: '1px solid ' + (el.selected ? 'var(--accent)' : 'transparent'),
borderLeft: '3px solid ' + (el.selected ? 'var(--accent)' : meta.color),
borderRadius: 'var(--r)',
fontSize: 10,
}}>
<span style={{
fontFamily: 'DM Mono, monospace', fontWeight: 500,
color: 'var(--text-primary)',
minWidth: 40,
}}>
{el.geschossName || '—'}
</span>
<span style={{
flex: 1, fontFamily: 'DM Mono, monospace',
color: 'var(--text-primary)',
}}>
{secondary}
</span>
{tertiary && (
<span style={{
color: 'var(--text-muted)', fontFamily: 'DM Mono, monospace',
fontSize: 9,
}}>
{tertiary}
</span>
)}
</div>
)
}
function NeuesElementSection({ noGeschoss, activeName }) {
const [treppeMenuOpen, setTreppeMenuOpen] = useState(false)
const [stuetzeMenuOpen, setStuetzeMenuOpen] = useState(false)
const [traegerMenuOpen, setTraegerMenuOpen] = useState(false)
const treppeWrapperRef = useRef(null)
const dis = noGeschoss
const baseHint = (label) =>
noGeschoss ? 'Erst im Ebenen-Manager ein Geschoss aktivieren'
: `${label} auf ${activeName}`
const openTreppeMenu = (e) => {
e.preventDefault()
setTreppeMenuOpen(true)
}
const openStuetzeMenu = (e) => { e.preventDefault(); setStuetzeMenuOpen(true) }
const openTraegerMenu = (e) => { e.preventDefault(); setTraegerMenuOpen(true) }
const treppeItems = [
{ icon: 'stairs', label: 'Gerade Treppe',
hint: 'Lauflinie mit 2 Punkten',
onClick: () => createTreppe({ treppeArt: 'gerade' }) },
{ icon: 'turn_right', label: 'L-Treppe',
hint: '3 Punkte: Start, Podest-Ecke, Ende',
onClick: () => createTreppe({ treppeArt: 'l' }) },
{ icon: 'rotate_right', label: 'Wendeltreppe',
hint: '3 Punkte: Mittelpunkt, Start-Lauflinie, End-Lauflinie',
onClick: () => createTreppe({ treppeArt: 'wendel' }) },
]
const profilItems = (factory) => [
{ icon: 'square', label: 'Quadrat',
hint: 'B × B', onClick: () => factory('quadrat') },
{ icon: 'rectangle', label: 'Rechteck',
hint: 'B × H', onClick: () => factory('rechteck') },
{ icon: 'circle', label: 'Rund',
hint: 'Durchmesser D', onClick: () => factory('rund') },
{ icon: 'view_column', label: 'I-Profil',
hint: 'Stahl HEB-Stil — Flansch B, Höhe H, Wand t',
onClick: () => factory('i_profil') },
{ icon: 'radio_button_unchecked', label: 'Rohr',
hint: 'Hohlzylinder — D Aussen, t Wanddicke',
onClick: () => factory('rohr') },
]
const stuetzeItems = profilItems((profil) =>
createStuetze({ profil }))
const traegerItems = profilItems((profil) =>
createTraeger({ profil }))
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 10,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10 }}>
<span style={labelXs}>Neues Element</span>
<div style={{ flex: 1 }} />
<span style={{ color: noGeschoss ? 'var(--danger)' : 'var(--text-muted)' }}>
{noGeschoss ? 'Kein Geschoss aktiv' : 'auf'}
</span>
{!noGeschoss && (
<span className="chip chip-accent" style={{
fontSize: 10, fontFamily: 'DM Mono, monospace', fontWeight: 600,
}}>
{activeName}
</span>
)}
</div>
<PillGroup label="Konstruktion">
<PillButton icon="view_week" label="Wand"
hint={baseHint('Wand zeichnen')} disabled={dis}
onClick={() => createWall({ geschoss: '' })} />
<PillButton icon="layers" label="Decke"
hint={baseHint('Decke zeichnen')} disabled={dis}
onClick={() => createDecke({ geschoss: '' })} />
<PillButton icon="roofing" label="Dach"
hint={baseHint('Pultdach zeichnen — Traufe = 1. Kante')} disabled={dis}
onClick={() => createDach({ geschoss: '' })} />
</PillGroup>
<PillGroup label="Öffnungen">
<PillButton icon="window" label="Fenster"
hint={dis ? baseHint('Fenster') :
'Erst Wand-Achse wählen, dann Punkt darauf'} disabled={dis}
onClick={() => createFenster({})} />
<PillButton icon="sensor_door" label="Tür"
hint={dis ? baseHint('Tür') :
'Erst Wand-Achse wählen, dann Punkt darauf'} disabled={dis}
onClick={() => createTuer({})} />
<PillButton icon="rectangle" label="Aussparung"
hint={dis ? baseHint('Aussparung') :
'Outline auf einer Decke zeichnen — wird automatisch ausgeschnitten'}
disabled={dis}
onClick={() => createAussparung({})} />
</PillGroup>
<PillGroup label="Erschliessung">
<div ref={treppeWrapperRef} style={{ position: 'relative' }}>
<PillButton icon="stairs" label="Treppe" hasMenu
hint={dis ? baseHint('Treppe') :
'Klick: gerade Treppe · Rechtsklick: Typ wählen'}
disabled={dis}
onClick={() => createTreppe({ treppeArt: 'gerade' })}
onContextMenu={openTreppeMenu} />
{treppeMenuOpen && (
<PopupMenu items={treppeItems}
onClose={() => setTreppeMenuOpen(false)} />
)}
</div>
</PillGroup>
<PillGroup label="Tragwerk">
<div style={{ position: 'relative' }}>
<PillButton icon="square_foot" label="Stütze" hasMenu
hint={dis ? baseHint('Stütze') :
'Klick: Quadrat-Stütze · Rechtsklick: Profil wählen'}
disabled={dis}
onClick={() => createStuetze({})}
onContextMenu={openStuetzeMenu} />
{stuetzeMenuOpen && (
<PopupMenu items={stuetzeItems}
onClose={() => setStuetzeMenuOpen(false)} />
)}
</div>
<div style={{ position: 'relative' }}>
<PillButton icon="horizontal_rule" label="Träger" hasMenu
hint={dis ? baseHint('Träger') :
'Klick: Rechteck-Träger · Rechtsklick: Profil wählen'}
disabled={dis}
onClick={() => createTraeger({})}
onContextMenu={openTraegerMenu} />
{traegerMenuOpen && (
<PopupMenu items={traegerItems}
onClose={() => setTraegerMenuOpen(false)} />
)}
</div>
</PillGroup>
<PillGroup label="Raeume">
<PillButton icon="crop_free" label="Raum"
hint={dis ? baseHint('Raum') :
'Outline zeichnen · Stempel zeigt Name + Fläche'}
disabled={dis}
onClick={() => createRaum({})} />
</PillGroup>
</div>
)
}
export default function ElementeApp() {
const [state, setState] = useState({
elements: [], geschosse: [], selection: null,
activeGeschoss: '', activeGeschossName: '',
})
// Defaults werden vom Backend (sticky) verwaltet — letzte Werte aus
// dem Rhino-Prompt bleiben fuer den naechsten Klick erhalten.
useEffect(() => {
onMessage('STATE', (s) => setState(prev => ({ ...prev, ...s })))
notifyReady()
}, [])
const elements = state.elements || []
const geschosse = state.geschosse || []
const selected = elements.find(el => el.id === state.selection)
const activeName = state.activeGeschossName || ''
const noGeschoss = !state.activeGeschoss
return (
<div style={{
display: 'flex', flexDirection: 'column',
height: '100vh', overflow: 'hidden',
background: 'var(--bg-base)', color: 'var(--text-primary)',
fontFamily: 'var(--font)', fontSize: 11,
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 10px', borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}>
<span style={{ flex: 1, fontWeight: 600, letterSpacing: '0.08em' }}>ELEMENTE</span>
<span className="chip" style={{ fontSize: 8 }}>{elements.length}</span>
<button
onClick={() => exportRaeume()}
className="btn-icon-tonal"
disabled={!elements.some(e => e.kind === 'raum')}
title="Raumliste als CSV exportieren"
>
<Icon name="download" size={14} />
</button>
<button
onClick={() => regenerateAllElements()}
className="btn-icon-tonal"
disabled={elements.length === 0}
title="Alle Elemente neu generieren (z.B. nach Geschoss-Änderung)"
>
<Icon name="sync" size={14} />
</button>
<button
onClick={() => listElemente()}
className="btn-icon-tonal"
title="Aktualisieren"
>
<Icon name="refresh" size={14} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: 8 }}>
{/* Element-Toolbar: kategorisierte Pills */}
<NeuesElementSection
noGeschoss={noGeschoss}
activeName={activeName}
/>
{/* Properties */}
{selected ? (
selected.kind === 'wand' ? (
<WallProperties wall={selected} geschosse={geschosse}
materials={state.materials || []}
onUpdate={(p) => updateElement(selected.id, p)}
onDelete={() => { if (window.confirm('Wand löschen?')) deleteElement(selected.id) }} />
) : selected.kind === 'decke' ? (
<DeckenProperties decke={selected} geschosse={geschosse}
onUpdate={(p) => updateElement(selected.id, p)}
onDelete={() => { if (window.confirm('Decke löschen?')) deleteElement(selected.id) }} />
) : selected.kind === 'dach' ? (
<DachProperties dach={selected} geschosse={geschosse}
onUpdate={(p) => updateElement(selected.id, p)}
onDelete={() => { if (window.confirm('Dach löschen?')) deleteElement(selected.id) }} />
) : selected.kind === 'treppe' ? (
<TreppeProperties treppe={selected} geschosse={geschosse}
onUpdate={(p) => updateElement(selected.id, p)}
onDelete={() => { if (window.confirm('Treppe löschen?')) deleteElement(selected.id) }} />
) : (selected.kind === 'stuetze' || selected.kind === 'traeger') ? (
<TragwerkProperties el={selected}
onUpdate={(p) => updateElement(selected.id, p)}
onDelete={() => {
const lbl = (KIND_META[selected.kind] || {}).label || 'Element'
if (window.confirm(`${lbl} löschen?`)) deleteElement(selected.id)
}} />
) : selected.kind === 'raum' ? (
<RaumProperties raum={selected} geschosse={geschosse}
hatchPatterns={state.hatchPatterns}
onUpdate={(p) => updateElement(selected.id, p)}
onDelete={() => { if (window.confirm('Raum löschen?')) deleteElement(selected.id) }} />
) : selected.kind === 'aussparung' ? (
<AussparungProperties aussp={selected}
onDelete={() => { if (window.confirm('Aussparung löschen?')) deleteElement(selected.id) }} />
) : (
<OeffnungProperties oeff={selected}
onUpdate={(p) => updateElement(selected.id, p)}
onDelete={() => {
const label = selected.kind === 'fenster' ? 'Fenster' : 'Tür'
if (window.confirm(`${label} löschen?`)) deleteElement(selected.id)
}} />
)
) : (
<div style={{
padding: '20px 16px', textAlign: 'center',
color: 'var(--text-muted)', fontSize: 11,
border: '1px dashed var(--border)',
borderRadius: 'var(--r-lg)',
background: 'var(--bg-section)',
marginBottom: 8,
}}>
<Icon name="touch_app" size={24} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
<div style={{ marginTop: 6 }}>Kein Element selektiert.</div>
<div style={{ marginTop: 4, fontSize: 10 }}>
Eine Wand-Achse oder Decken-Outline in Rhino auswählen.
</div>
</div>
)}
{/* Liste aller Elemente */}
{elements.length > 0 && (
<ElementList elements={elements} />
)}
</div>
</div>
)
}
function NumberField({ label, value, onCommit, width, step }) {
const [raw, setRaw] = useState(String(value))
useEffect(() => { setRaw(String(value)) }, [value])
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)',
width: width || 60 }}>{label}</span>
<input type="text" value={raw}
onChange={(e) => setRaw(e.target.value)}
onBlur={() => {
const v = parseFloat(raw)
if (!Number.isNaN(v) && v !== value) onCommit(v)
else setRaw(String(value))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
</div>
)
}
function TragwerkProperties({ el, onUpdate, onDelete }) {
const meta = KIND_META[el.kind] || { icon: 'square_foot', label: 'Element' }
const profil = el.profil || 'quadrat'
const profilOpts = ['quadrat', 'rechteck', 'rund', 'i_profil', 'rohr']
const isRound = (profil === 'rund' || profil === 'rohr')
const showH = (profil === 'rechteck' || profil === 'i_profil')
const showT = (profil === 'rohr' || profil === 'i_profil')
const isStuetze = (el.kind === 'stuetze')
const [zRaw, setZRaw] = useState(el.zOver || '')
useEffect(() => { setZRaw(el.zOver || '') }, [el.id, el.zOver])
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name={meta.icon} size={13}
style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
{meta.label} · {el.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete}
title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>
Profil
</span>
<select value={profil}
onChange={(e) => onUpdate({ profil: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{profilOpts.map(p => (
<option key={p} value={p}>
{(PROFIL_META[p] || {}).label || p}
</option>
))}
</select>
</div>
{!isRound && (
<NumberField label="Breite" value={el.b}
onCommit={(v) => onUpdate({ b: v })} />
)}
{showH && (
<NumberField label="Höhe" value={el.h}
onCommit={(v) => onUpdate({ h: v })} />
)}
{isRound && (
<NumberField label="Durchm." value={el.d}
onCommit={(v) => onUpdate({ d: v })} />
)}
{showT && (
<NumberField label="Wanddicke" value={el.t}
onCommit={(v) => onUpdate({ t: v })} />
)}
<NumberField label="Drehung°" value={el.angle}
onCommit={(v) => onUpdate({ angle: v })} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>
{isStuetze ? 'Höhe' : 'OK über UK'}
</span>
<input type="text" value={zRaw}
placeholder={`auto (${fmtNum(el.ok - el.uk)} m)`}
onChange={(e) => setZRaw(e.target.value)}
onBlur={() => {
const v = zRaw.trim()
if (v === '') {
if ((el.zOver || '') !== '') onUpdate({ zOver: '' })
} else {
const n = parseFloat(v)
if (!Number.isNaN(n)) onUpdate({ zOver: n })
}
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11,
fontFamily: 'DM Mono, monospace' }} />
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--text-muted)',
fontFamily: 'DM Mono, monospace',
paddingTop: 4, borderTop: '1px dashed var(--border)',
}}>
<span>UK {fmtNum(el.uk)} · OK {fmtNum(el.ok)}</span>
{!isStuetze && <span>L {fmtNum(el.axisLen)} m</span>}
</div>
</div>
)
}
function RaumProperties({ raum, geschosse, onUpdate, onDelete, hatchPatterns }) {
const [name, setName] = useState(raum.name || 'Raum')
const [nummer, setNummer] = useState(raum.nummer || '')
const [txtH, setTxtH] = useState(String(raum.txtH || 0.20))
useEffect(() => {
setName(raum.name || 'Raum')
setNummer(raum.nummer || '')
setTxtH(String(raum.txtH || 0.20))
}, [raum.id, raum.name, raum.nummer, raum.txtH])
// Aktueller Wert von raum_fuellung: "" | "Solid" | "Hatch1" | … | "ByLayer"
const fuell = raum.fuellung || ''
const patternList = hatchPatterns || []
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="crop_free" size={13}
style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Raum · {raum.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete}
title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Geschoss</span>
<select value={raum.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Nummer</span>
<input type="text" value={nummer}
onChange={(e) => setNummer(e.target.value)}
onBlur={() => {
if (nummer !== (raum.nummer || '')) onUpdate({ nummer })
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11,
fontFamily: 'DM Mono, monospace' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Name</span>
<input type="text" value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => {
const v = (name || 'Raum').trim()
if (v !== raum.name) onUpdate({ name: v })
else setName(v)
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11 }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Typ</span>
<select value={raum.sia || ''}
onChange={(e) => onUpdate({ sia: e.target.value })}
title="SIA 416 Flaechenklassifikation"
style={{ flex: 1, fontSize: 11 }}>
{RAUM_SIA_KINDS.map(s => (
<option key={s.code} value={s.code}>
{s.code === '' ? '—'
: `${s.label}${s.hint}`}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Füllung</span>
<select value={fuell}
onChange={(e) => onUpdate({ fuellung: e.target.value })}
title="Hatch-Pattern im Normalmodus. Bei aktivem SIA-Modus wird klassifizierten Raeumen automatisch Solid forciert."
style={{ flex: 1, fontSize: 11 }}>
<option value="">Keine</option>
<option value="ByLayer">Ebene (folgt Layer-Farbe)</option>
{patternList.length > 0 && <option disabled></option>}
{patternList.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Rundung</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{RAUM_RUNDUNGEN.map(r => (
<button key={r}
onClick={() => onUpdate({ rundung: r })}
className={raum.rundung === r ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 4px', fontSize: 10 }}
title={r === 'exakt' ? '2 Nachkommastellen, ohne Rundung'
: r === '0.01' ? 'auf 0.01 m²'
: r === '0.1' ? 'auf 0.1 m²'
: r === '0.5' ? 'auf 0.5 m²'
: 'auf ganze m²'}>
{r}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Ausrichtung</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{RAUM_ALIGN.map(a => (
<button key={a.code}
onClick={() => onUpdate({ align: a.code })}
className={(raum.align || 'mid') === a.code ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 4px', fontSize: 10,
display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 3 }}
title={a.label}>
<Icon name={a.icon} size={12} />
</button>
))}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Texthöhe</span>
<input type="text" value={txtH}
onChange={(e) => setTxtH(e.target.value)}
onBlur={() => {
const v = parseFloat(txtH)
if (v > 0 && v !== raum.txtH) onUpdate({ txtH: v })
else setTxtH(String(raum.txtH))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11,
fontFamily: 'DM Mono, monospace' }} />
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--text-muted)',
fontFamily: 'DM Mono, monospace',
paddingTop: 4, borderTop: '1px dashed var(--border)',
}}>
<span>Flaeche: <strong style={{ color: 'var(--text-primary)' }}>
{raum.areaFmt} </strong></span>
<span>Umfang: {fmtNum(raum.umfang)} m</span>
</div>
</div>
)
}
function AussparungProperties({ aussp, onDelete }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="rectangle" size={13}
style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Aussparung · {aussp.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete}
title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--text-muted)',
fontFamily: 'DM Mono, monospace',
paddingTop: 4, borderTop: '1px dashed var(--border)',
}}>
<span>Fläche: {fmtNum(aussp.area)} </span>
<span>Umfang: {fmtNum(aussp.umfang)} m</span>
</div>
<div style={{
fontSize: 9, color: 'var(--text-muted)', fontStyle: 'italic',
lineHeight: 1.4,
}}>
Outline in Rhino editieren (Punkte ziehen, _Reshape, ) die
Eltern-Decke wird automatisch nachgerechnet.
</div>
</div>
)
}
function WallProperties({ wall, geschosse, materials, onUpdate, onDelete }) {
const [dicke, setDicke] = useState(String(wall.dicke))
const [ukOver, setUkOver] = useState(wall.ukOverride)
const [okOver, setOkOver] = useState(wall.okOverride)
useEffect(() => {
setDicke(String(wall.dicke))
setUkOver(wall.ukOverride)
setOkOver(wall.okOverride)
}, [wall.id, wall.dicke, wall.ukOverride, wall.okOverride])
const ukAuto = ukOver === '' || ukOver == null
const okAuto = okOver === '' || okOver == null
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="view_week" size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Wand · {wall.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Geschoss</span>
<select value={wall.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Aufbau</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
<button
onClick={() => onUpdate({ layered: false })}
className={!wall.layered ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
title="Eine homogene Wand-Schicht (Standard)">Solid</button>
<button
onClick={() => onUpdate({ layered: true })}
className={wall.layered ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
title="Mehrere Schichten mit individuellen Dicken und Farben"
>Mehrschichtig</button>
</div>
</div>
{!wall.layered && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Dicke</span>
<input type="text" value={dicke}
onChange={(e) => setDicke(e.target.value)}
onBlur={() => {
const v = parseFloat(dicke)
if (v > 0 && v !== wall.dicke) onUpdate({ dicke: v })
else setDicke(String(wall.dicke))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
</div>
)}
{wall.layered && (
<LayersEditor layers={wall.layers || []}
materials={materials}
onChange={(layers) => onUpdate({ layers })} />
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Referenz</span>
<ReferenzSelector value={wall.referenz || 'mid'}
onChange={(v) => onUpdate({ referenz: v })} />
</div>
<AutoOverrideField label="UK" auto={ukAuto} autoValue={wall.uk}
rawValue={ukOver} onChangeRaw={setUkOver}
onToggle={() => onUpdate({ ukOverride: ukAuto ? wall.uk : '' })}
onCommit={() => {
if (ukAuto) return
const v = parseFloat(ukOver)
if (!Number.isNaN(v)) onUpdate({ ukOverride: v })
}} />
<AutoOverrideField label="OK" auto={okAuto} autoValue={wall.ok}
rawValue={okOver} onChangeRaw={setOkOver}
onToggle={() => onUpdate({ okOverride: okAuto ? wall.ok : '' })}
onCommit={() => {
if (okAuto) return
const v = parseFloat(okOver)
if (!Number.isNaN(v)) onUpdate({ okOverride: v })
}} />
</div>
)
}
function LayersEditor({ layers, onChange, materials }) {
const total = (layers || []).reduce((s, l) => s + (parseFloat(l.dicke) || 0), 0)
const updateAt = (idx, patch) => {
const next = (layers || []).map((l, i) => (i === idx ? { ...l, ...patch } : l))
onChange(next)
}
const removeAt = (idx) => {
const next = (layers || []).filter((_, i) => i !== idx)
onChange(next.length ? next : [{ name: 'Schicht 1', dicke: 0.20, color: '#cccccc', material: '' }])
}
const addLayer = () => {
const idx = (layers || []).length + 1
onChange([ ...(layers || []),
{ name: `Schicht ${idx}`, dicke: 0.05, color: '#dddddd', material: '' } ])
}
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 8px',
background: 'var(--bg-input)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--r)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4,
fontSize: 9, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase',
fontWeight: 600 }}>
<span style={{ flex: 1 }}>Schichten (links rechts)</span>
<span style={{ fontFamily: 'DM Mono, monospace', fontSize: 10,
color: 'var(--text-primary)', textTransform: 'none' }}>
Σ {total.toFixed(3)} m
</span>
</div>
{(layers || []).map((ly, i) => (
<LayerRow key={i} layer={ly}
materials={materials}
onChange={(patch) => updateAt(i, patch)}
onRemove={() => removeAt(i)} />
))}
<button onClick={addLayer} className="btn-outlined"
style={{ padding: '3px 6px', fontSize: 10, marginTop: 2 }}
title="Neue Schicht unten anfügen">
+ Schicht
</button>
</div>
)
}
function LayerRow({ layer, materials, onChange, onRemove }) {
const [name, setName] = useState(layer.name || '')
const [dicke, setDicke] = useState(String(layer.dicke || 0))
useEffect(() => {
setName(layer.name || '')
setDicke(String(layer.dicke || 0))
}, [layer.name, layer.dicke])
// Wenn ein Material gewaehlt ist, kommt die Farbe von dort — der Color-
// Picker wird ausgegraut und zeigt die Material-Farbe an.
const matList = materials || []
const matName = layer.material || ''
const matDef = matList.find(m => m.name === matName)
const effectiveColor = matDef ? matDef.color : (layer.color || '#cccccc')
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="color"
value={effectiveColor}
onChange={(e) => onChange({ color: e.target.value })}
disabled={!!matDef}
title={matDef
? `Farbe aus Material "${matDef.name}"`
: 'Schicht-Farbe'}
style={{
width: 22, height: 22, padding: 0, border: 'none',
background: 'transparent',
cursor: matDef ? 'not-allowed' : 'pointer',
opacity: matDef ? 0.5 : 1,
}} />
<select value={matName}
onChange={(e) => {
const m = matList.find(x => x.name === e.target.value)
if (m) onChange({ material: m.name, color: m.color })
else onChange({ material: '' })
}}
title="Material aus Bibliothek (steuert Farbe + Section-Hatch)"
style={{ width: 92, fontSize: 10, padding: '2px 4px' }}>
<option value="">(eigene)</option>
{matList.map(m => (
<option key={m.name} value={m.name}>{m.name}</option>
))}
</select>
<input type="text" value={name}
placeholder="Name"
onChange={(e) => setName(e.target.value)}
onBlur={() => { if (name !== layer.name) onChange({ name }) }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 10, padding: '2px 4px' }} />
<input type="text" value={dicke}
onChange={(e) => setDicke(e.target.value)}
onBlur={() => {
const v = parseFloat(dicke)
if (v > 0 && v !== layer.dicke) onChange({ dicke: v })
else setDicke(String(layer.dicke))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ width: 56, fontSize: 10, padding: '2px 4px',
fontFamily: 'DM Mono, monospace' }}
title="Dicke in Metern" />
<button onClick={onRemove} className="btn-icon-sm btn-icon-danger"
title="Schicht entfernen" style={{ width: 18, height: 18 }}>
<Icon name="close" size={10} />
</button>
</div>
)
}
function DeckenProperties({ decke, geschosse, onUpdate, onDelete }) {
const [dicke, setDicke] = useState(String(decke.dicke))
const [ukOver, setUkOver] = useState(decke.ukOverride)
const [okOver, setOkOver] = useState(decke.okOverride)
useEffect(() => {
setDicke(String(decke.dicke))
setUkOver(decke.ukOverride)
setOkOver(decke.okOverride)
}, [decke.id, decke.dicke, decke.ukOverride, decke.okOverride])
const ukAuto = ukOver === '' || ukOver == null
const okAuto = okOver === '' || okOver == null
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="layers" size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Decke · {decke.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Geschoss</span>
<select value={decke.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Dicke</span>
<input type="text" value={dicke}
onChange={(e) => setDicke(e.target.value)}
onBlur={() => {
const v = parseFloat(dicke)
if (v > 0 && v !== decke.dicke) onUpdate({ dicke: v })
else setDicke(String(decke.dicke))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
</div>
<AutoOverrideField label="UK" auto={ukAuto} autoValue={decke.uk}
rawValue={ukOver} onChangeRaw={setUkOver}
onToggle={() => onUpdate({ ukOverride: ukAuto ? decke.uk : '' })}
onCommit={() => {
if (ukAuto) return
const v = parseFloat(ukOver)
if (!Number.isNaN(v)) onUpdate({ ukOverride: v })
}} />
<AutoOverrideField label="OK" auto={okAuto} autoValue={decke.ok}
rawValue={okOver} onChangeRaw={setOkOver}
onToggle={() => onUpdate({ okOverride: okAuto ? decke.ok : '' })}
onCommit={() => {
if (okAuto) return
const v = parseFloat(okOver)
if (!Number.isNaN(v)) onUpdate({ okOverride: v })
}} />
</div>
)
}
function DachProperties({ dach, geschosse, onUpdate, onDelete }) {
const [dicke, setDicke] = useState(String(dach.dicke))
const [neigung, setNeigung] = useState(String(dach.neigung ?? 30))
const [eaveIdx, setEaveIdx] = useState(String(dach.eaveIdx ?? 0))
const [ukOver, setUkOver] = useState(dach.ukOverride)
useEffect(() => {
setDicke(String(dach.dicke))
setNeigung(String(dach.neigung ?? 30))
setEaveIdx(String(dach.eaveIdx ?? 0))
setUkOver(dach.ukOverride)
}, [dach.id, dach.dicke, dach.neigung, dach.eaveIdx, dach.ukOverride])
const dachTyp = dach.dachTyp || 'pult'
const ukAuto = ukOver === '' || ukOver == null
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="roofing" size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
{dachTyp === 'sattel' ? 'Satteldach' : dachTyp === 'walm' ? 'Walmdach' : 'Pultdach'} · {dach.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
{/* Dach-Typ */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Typ</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{[
{ code: 'pult', label: 'Pult' },
{ code: 'sattel', label: 'Sattel' },
{ code: 'walm', label: 'Walm' },
{ code: 'mansarde', label: 'Mansarde' },
].map(o => (
<button key={o.code}
onClick={() => onUpdate({ dachTyp: o.code })}
className={dachTyp === o.code ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 4px', fontSize: 9 }}
title={o.code === 'pult'
? 'Geneigte Fläche, Traufe = 1. Kante'
: 'Erfordert Rechteck-Outline (4 Ecken)'}>
{o.label}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Geschoss</span>
<select value={dach.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Dicke</span>
<input type="text" value={dicke}
onChange={(e) => setDicke(e.target.value)}
onBlur={() => {
const v = parseFloat(dicke)
if (v > 0 && v !== dach.dicke) onUpdate({ dicke: v })
else setDicke(String(dach.dicke))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Neigung</span>
<input type="text" value={neigung}
onChange={(e) => setNeigung(e.target.value)}
onBlur={() => {
const v = parseFloat(neigung)
if (!Number.isNaN(v) && v >= 0 && v < 90) onUpdate({ neigung: v })
else setNeigung(String(dach.neigung ?? 30))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>°</span>
</div>
{dachTyp === 'pult' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Index der Traufkante (0 = Kante zwischen 1. und 2. Punkt)">
Traufe
</span>
<input type="text" value={eaveIdx}
onChange={(e) => setEaveIdx(e.target.value)}
onBlur={() => {
const v = parseInt(eaveIdx, 10)
if (!Number.isNaN(v) && v >= 0) onUpdate({ eaveIdx: v })
else setEaveIdx(String(dach.eaveIdx ?? 0))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Kante</span>
</div>
)}
{/* Mansarde-spezifisch: Variante + untere Neigung + Knick-Hoehe */}
{dachTyp === 'mansarde' && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Variante</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{[
{ code: 'walm', label: 'Walm', hint: 'Knick auf allen 4 Seiten (Pariser Stil)' },
{ code: 'giebel', label: 'Giebel', hint: 'Knick nur an langen Seiten, Schmalseiten als Giebelwand (DACH-Standard)' },
{ code: 'walm_giebel', label: 'W-G', hint: 'Unten Walm (Knick rundum), oben Giebel mit First über voller Länge' },
].map(o => (
<button key={o.code}
onClick={() => onUpdate({ dachVariante: o.code })}
className={(dach.dachVariante || 'walm') === o.code ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
title={o.hint}>
{o.label}
</button>
))}
</div>
</div>
<MansardeFields dach={dach} onUpdate={onUpdate} />
</>
)}
<AutoOverrideField label="Basis" auto={ukAuto} autoValue={dach.uk}
rawValue={ukOver} onChangeRaw={setUkOver}
onToggle={() => onUpdate({ ukOverride: ukAuto ? dach.uk : '' })}
onCommit={() => {
if (ukAuto) return
const v = parseFloat(ukOver)
if (!Number.isNaN(v)) onUpdate({ ukOverride: v })
}} />
</div>
)
}
function MansardeFields({ dach, onUpdate }) {
const [nu, setNu] = useState(String(dach.neigungUnten ?? 60))
const [kh, setKh] = useState(String(dach.knickH ?? 2.0))
useEffect(() => {
setNu(String(dach.neigungUnten ?? 60))
setKh(String(dach.knickH ?? 2.0))
}, [dach.id, dach.neigungUnten, dach.knickH])
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Untere (steile) Neigung — bis zum Knick">
Steil
</span>
<input type="text" value={nu}
onChange={(e) => setNu(e.target.value)}
onBlur={() => {
const v = parseFloat(nu)
if (!Number.isNaN(v) && v > 0 && v < 90) onUpdate({ neigungUnten: v })
else setNu(String(dach.neigungUnten ?? 60))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>°</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Höhe über Traufe wo der Knick sitzt">
Knick H
</span>
<input type="text" value={kh}
onChange={(e) => setKh(e.target.value)}
onBlur={() => {
const v = parseFloat(kh)
if (!Number.isNaN(v) && v > 0) onUpdate({ knickH: v })
else setKh(String(dach.knickH ?? 2.0))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
</>
)
}
const SIMS_OPTIONS = [
{ code: 'ohne', label: 'ohne' },
{ code: 'schmal', label: 'schmal' },
{ code: 'standard', label: 'standard' },
{ code: 'breit', label: 'breit' },
]
const RAHMEN_POS_OPTIONS = [
{ code: 'aussen', label: 'aussen', hint: 'Rahmen bündig mit Aussenfläche' },
{ code: 'mid', label: 'mittig', hint: 'Rahmen mittig im Wandquerschnitt' },
{ code: 'innen', label: 'innen', hint: 'Rahmen bündig mit Innenfläche' },
]
const OEFF_REFERENZ_OPTIONS = [
{ code: 'links', label: 'Links', hint: 'Klick-Punkt am linken Rand — Öffnung extendiert nach rechts (+tan der Wand-Achse)' },
{ code: 'mid', label: 'Mittig', hint: 'Klick-Punkt mittig in der Öffnung (Standard)' },
{ code: 'rechts', label: 'Rechts', hint: 'Klick-Punkt am rechten Rand — Öffnung extendiert nach links (-tan)' },
]
function SollRow({ label, value, unit, soll, sollKey, onUpdateSoll, readOnly }) {
// soll[sollKey] = [lo, hi, on]
const lo = soll?.[sollKey]?.[0] ?? 0
const hi = soll?.[sollKey]?.[1] ?? 0
const on = soll?.[sollKey]?.[2] ?? true
const inRange = value >= lo && value <= hi
const valueColor = !on ? 'var(--text-muted)'
: inRange ? 'var(--accent)'
: '#c87050'
const [loStr, setLoStr] = useState(String(lo))
const [hiStr, setHiStr] = useState(String(hi))
useEffect(() => {
setLoStr(String(lo))
setHiStr(String(hi))
}, [lo, hi])
const commit = (k, val, setBack, def) => {
const v = parseFloat(val)
if (!Number.isNaN(v) && v > 0) {
const next = [...soll[sollKey]]
next[k] = v
onUpdateSoll({ ...soll, [sollKey]: next })
} else setBack(String(def))
}
const toggle = () => {
const next = [...soll[sollKey]]
next[2] = !next[2]
onUpdateSoll({ ...soll, [sollKey]: next })
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10,
fontFamily: 'DM Mono, monospace' }}>
<input type="checkbox" checked={on} onChange={toggle}
title="Validierung ein/aus"
style={{ margin: 0, cursor: 'pointer' }} />
<span style={{ color: 'var(--text-muted)', width: 30 }}>{label}</span>
<span style={{ color: valueColor, width: 56 }}>
{fmtNum(value)} {unit}
</span>
{readOnly ? (
<span style={{ color: 'var(--text-muted)', marginLeft: 'auto', fontSize: 9 }}>
{fmtNum(lo)}{fmtNum(hi)}
</span>
) : (
<>
<span style={{ color: 'var(--text-muted)', marginLeft: 'auto', fontSize: 9 }}>Soll</span>
<input type="text" value={loStr}
onChange={(e) => setLoStr(e.target.value)}
onBlur={() => commit(0, loStr, setLoStr, lo)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
disabled={!on}
style={{
width: 38, fontSize: 9, fontFamily: 'DM Mono, monospace',
padding: '1px 3px', background: 'transparent',
border: '1px solid var(--border-light)',
color: on ? 'var(--text-primary)' : 'var(--text-muted)',
}} />
<span style={{ color: 'var(--text-muted)', fontSize: 9 }}></span>
<input type="text" value={hiStr}
onChange={(e) => setHiStr(e.target.value)}
onBlur={() => commit(1, hiStr, setHiStr, hi)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
disabled={!on}
style={{
width: 38, fontSize: 9, fontFamily: 'DM Mono, monospace',
padding: '1px 3px', background: 'transparent',
border: '1px solid var(--border-light)',
color: on ? 'var(--text-primary)' : 'var(--text-muted)',
}} />
</>
)}
</div>
)
}
const DEFAULT_TREPPE_SOLL = {
s: [0.15, 0.20, true],
a: [0.21, 0.35, true],
sa: [0.60, 0.65, true],
}
function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
const [breite, setBreite] = useState(String(treppe.breite ?? 1.0))
const [nStufen, setNStufen] = useState(String(treppe.nStufen ?? 15))
const [laufD, setLaufD] = useState(String(treppe.laufD ?? 0.18))
const [hStr, setHStr] = useState('')
useEffect(() => {
setBreite(String(treppe.breite ?? 1.0))
setNStufen(String(treppe.nStufen ?? 15))
setLaufD(String(treppe.laufD ?? 0.18))
}, [treppe.id, treppe.breite, treppe.nStufen, treppe.laufD])
const H = (treppe.ok ?? 0) - (treppe.uk ?? 0)
const N = treppe.nStufen ?? 15
const L = treppe.laufLen ?? 0
const S = N > 0 ? H / N : 0
const A = N > 0 ? L / N : 0
const sa = 2 * S + A
const soll = treppe.soll || DEFAULT_TREPPE_SOLL
const hasHOver = treppe.hOver != null && treppe.hOver !== ''
useEffect(() => {
setHStr(hasHOver ? String(treppe.hOver) : fmtNum(H))
}, [treppe.id, treppe.hOver, H, hasHOver])
const allOK = (
(!soll.s[2] || (S >= soll.s[0] && S <= soll.s[1])) &&
(!soll.a[2] || (A >= soll.a[0] && A <= soll.a[1])) &&
(!soll.sa[2] || (sa >= soll.sa[0] && sa <= soll.sa[1]))
)
const onUpdateSoll = (newSoll) => {
onUpdate({ soll: newSoll })
}
const onCommitH = () => {
const v = parseFloat(hStr)
if (!Number.isNaN(v) && v > 0 && Math.abs(v - H) > 1e-5) {
// User hat H ueberschrieben → Ziel auf "eigene"
onUpdate({ hOver: v, geschossEnd: '' })
}
}
const onClearHOver = () => {
onUpdate({ hOver: '' })
}
const ref = treppe.treppeReferenz ?? 'mid'
const REF_OPTIONS = [
{ code: 'links', label: 'Links' },
{ code: 'mid', label: 'Mittig' },
{ code: 'rechts', label: 'Rechts' },
]
const modus = treppe.treppeModus ?? 'flach'
const MODUS_OPTIONS = [
{ code: 'massiv', label: 'massiv', hint: 'Block bis zum Boden — wie eine Mauer unter der Treppe' },
{ code: 'flach', label: 'flach', hint: 'Schräge Plattenunterseite parallel zum Treppenlauf (realistisch)' },
{ code: 'plattenrand', label: 'gestuft', hint: 'Plattenunterseite folgt den Stufen, vertikal versetzt' },
]
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="stairs" size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Treppe · {treppe.geschossName} {treppe.geschossEndName || '(auto)'}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Start</span>
<select value={treppe.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Ziel</span>
<select
value={hasHOver ? '__custom__' : (treppe.geschossEnd || '')}
onChange={(e) => {
const v = e.target.value
if (v === '__custom__') {
// Eigene Hoehe — falls noch nicht gesetzt, mit aktuellem H starten
onUpdate({ hOver: H, geschossEnd: '' })
} else {
onUpdate({ geschossEnd: v, hOver: '' })
}
}}
style={{ flex: 1, fontSize: 11 }}>
<option value="">(auto: Start + Höhe)</option>
{geschosse.filter(g => g.id !== treppe.geschoss)
.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
<option value="__custom__">eigene Höhe</option>
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Breite</span>
<input type="text" value={breite}
onChange={(e) => setBreite(e.target.value)}
onBlur={() => {
const v = parseFloat(breite)
if (!Number.isNaN(v) && v >= 0.3) onUpdate({ breite: v })
else setBreite(String(treppe.breite))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Stufen</span>
<input type="text" value={nStufen}
onChange={(e) => setNStufen(e.target.value)}
onBlur={() => {
const v = parseInt(nStufen, 10)
if (Number.isFinite(v) && v >= 2 && v <= 40) onUpdate({ nStufen: v })
else setNStufen(String(treppe.nStufen))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>×</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Lage</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{REF_OPTIONS.map(o => (
<button key={o.code}
onClick={() => onUpdate({ treppeReferenz: o.code })}
className={ref === o.code ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}>
{o.label}
</button>
))}
</div>
</div>
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
{/* Unterseite-Modus */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Form der Treppen-Unterseite">
Unten
</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{MODUS_OPTIONS.map(o => (
<button key={o.code}
onClick={() => onUpdate({ treppeModus: o.code })}
className={modus === o.code ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
title={o.hint}>
{o.label}
</button>
))}
</div>
</div>
{/* Lauf-Plattendicke (nur fuer flach + plattenrand relevant) */}
{modus !== 'massiv' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Dicke der Lauf-Platte (Materialdicke unter den Stufen)">
Platte
</span>
<input type="text" value={laufD}
onChange={(e) => setLaufD(e.target.value)}
onBlur={() => {
const v = parseFloat(laufD)
if (!Number.isNaN(v) && v > 0) onUpdate({ laufD: v })
else setLaufD(String(treppe.laufD))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
)}
{/* Schrittmass-Tabelle: H (editierbar), S, A, 2S+A mit on/off + range */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 3,
padding: '6px 8px', borderRadius: 4,
background: allOK ? 'rgba(95,168,150,0.10)' : 'rgba(200,112,80,0.08)',
border: '1px solid ' + (allOK ? 'rgba(95,168,150,0.4)' : 'rgba(200,112,80,0.35)'),
fontSize: 10, fontFamily: 'DM Mono, monospace',
}}>
{/* H — editierbar; aendert Hoehe und kippt Ziel auf "eigene" */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 16 }} />
<span style={{ color: 'var(--text-muted)', width: 30 }}>H</span>
<input type="text" value={hStr}
onChange={(e) => setHStr(e.target.value)}
onBlur={onCommitH}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{
width: 56, fontSize: 10, fontFamily: 'DM Mono, monospace',
padding: '1px 3px', background: 'transparent',
border: '1px solid ' + (hasHOver ? 'var(--accent)' : 'var(--border-light)'),
color: 'var(--text-primary)',
}} />
<span style={{ color: 'var(--text-muted)' }}>m</span>
{hasHOver && (
<button onClick={onClearHOver}
title="Zurück zu Geschoss-Höhe"
style={{
marginLeft: 'auto', fontSize: 9,
background: 'transparent', border: 'none',
color: 'var(--text-muted)', cursor: 'pointer',
textDecoration: 'underline',
}}>auto</button>
)}
</div>
<SollRow label="S" value={S} unit="m" soll={soll} sollKey="s"
onUpdateSoll={onUpdateSoll} />
<SollRow label="A" value={A} unit="m" soll={soll} sollKey="a"
onUpdateSoll={onUpdateSoll} />
<SollRow label="2S+A" value={sa} unit="m" soll={soll} sollKey="sa"
onUpdateSoll={onUpdateSoll} />
</div>
</div>
)
}
function OeffnungProperties({ oeff, onUpdate, onDelete }) {
const isFenster = oeff.kind === 'fenster'
const label = isFenster ? 'Fenster' : 'Tür'
const icon = isFenster ? 'window' : 'sensor_door'
const [breite, setBreite] = useState(String(oeff.breite ?? (isFenster ? 1.2 : 0.9)))
const [hoehe, setHoehe] = useState(String(oeff.hoehe ?? (isFenster ? 1.4 : 2.1)))
const [brueest, setBrueest] = useState(String(oeff.brueest ?? 0.9))
const [rahmenB, setRahmenB] = useState(String(oeff.rahmenB ?? 0.06))
const [rahmenTiefe, setRahmenTiefe] = useState(String(oeff.rahmenTiefe ?? 0.08))
useEffect(() => {
setBreite(String(oeff.breite ?? (isFenster ? 1.2 : 0.9)))
setHoehe(String(oeff.hoehe ?? (isFenster ? 1.4 : 2.1)))
setBrueest(String(oeff.brueest ?? 0.9))
setRahmenB(String(oeff.rahmenB ?? 0.06))
setRahmenTiefe(String(oeff.rahmenTiefe ?? 0.08))
}, [oeff.id, oeff.breite, oeff.hoehe, oeff.brueest, oeff.rahmenB,
oeff.rahmenTiefe, isFenster])
const commit = (key, val, setBack, def) => {
const v = parseFloat(val)
if (!Number.isNaN(v) && v > 0) onUpdate({ [key]: v })
else setBack(String(def))
}
const fluegel = oeff.fluegel ?? 1
const rahmenPos = oeff.rahmenPos ?? 'mid'
const simsAus = oeff.simsAus ?? 'ohne'
const simsIn = oeff.simsIn ?? 'ohne'
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
border: '1px solid var(--accent)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name={icon} size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
{label} · {oeff.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Breite</span>
<input type="text" value={breite}
onChange={(e) => setBreite(e.target.value)}
onBlur={() => commit('breite', breite, setBreite, oeff.breite)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>Höhe</span>
<input type="text" value={hoehe}
onChange={(e) => setHoehe(e.target.value)}
onBlur={() => commit('hoehe', hoehe, setHoehe, oeff.hoehe)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title={isFenster
? 'Brüstungshöhe über UK der Wand'
: 'Türschwelle / Höhe über UK der Wand'}>
{isFenster ? 'Brüst.' : 'Schw.'}
</span>
<input type="text" value={brueest}
onChange={(e) => setBrueest(e.target.value)}
onBlur={() => {
const v = parseFloat(brueest)
if (!Number.isNaN(v) && v >= 0) onUpdate({ brueest: v })
else setBrueest(String(oeff.brueest))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
{/* Referenz-Lage: wo sitzt der Klick-Punkt in der Oeffnung */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Lage des Klick-Punkts in der Öffnung">
Ref
</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{OEFF_REFERENZ_OPTIONS.map(o => (
<button key={o.code}
onClick={() => onUpdate({ oeffReferenz: o.code })}
className={(oeff.oeffReferenz || 'mid') === o.code ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
title={o.hint}>
{o.label}
</button>
))}
</div>
</div>
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
{/* Rahmen-Profil (Breite × Tiefe) — beide Felder gleich breit */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Rahmen-Profil: Breite (in Wandflaeche) × Tiefe (entlang Wandnormale)">
Rahmen
</span>
<input type="text" value={rahmenB}
onChange={(e) => setRahmenB(e.target.value)}
onBlur={() => commit('rahmenB', rahmenB, setRahmenB, oeff.rahmenB)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
title="Profil-Breite (sichtbar in der Fassade)"
style={{ flex: '1 1 0', minWidth: 0, width: 0, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>×</span>
<input type="text" value={rahmenTiefe}
onChange={(e) => setRahmenTiefe(e.target.value)}
onBlur={() => commit('rahmenTiefe', rahmenTiefe, setRahmenTiefe, oeff.rahmenTiefe)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
title="Rahmen-Tiefe (Lage in der Wand)"
style={{ flex: '1 1 0', minWidth: 0, width: 0, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
{/* Rahmen-Lage im Wandquerschnitt */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Lage des Rahmens im Wandquerschnitt">
Lage
</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{RAHMEN_POS_OPTIONS.map(o => (
<button key={o.code}
onClick={() => onUpdate({ rahmenPos: o.code })}
className={rahmenPos === o.code ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
title={o.hint}>
{o.label}
</button>
))}
</div>
</div>
{/* Fluegel-Anzahl — nur fuer Fenster (Tueren haben ein einzelnes Tuerblatt) */}
{isFenster && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Anzahl Flügel (vertikale Unterteilung)">
Flügel
</span>
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
{[1, 2, 3, 4].map(n => (
<button key={n}
onClick={() => onUpdate({ fluegel: n })}
className={fluegel === n ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}>
{n}
</button>
))}
</div>
</div>
)}
{/* Sims-Stile (aussen / innen) — nur fuer Fenster */}
{isFenster && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Aussensims — Platte unter Öffnung, ragt aussen heraus">
Sims a.
</span>
<select value={simsAus}
onChange={(e) => onUpdate({ simsAus: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{SIMS_OPTIONS.map(o =>
<option key={o.code} value={o.code}>{o.label}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
title="Innensims — Platte unter Öffnung, ragt innen heraus">
Sims i.
</span>
<select value={simsIn}
onChange={(e) => onUpdate({ simsIn: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{SIMS_OPTIONS.map(o =>
<option key={o.code} value={o.code}>{o.label}</option>)}
</select>
</div>
</>
)}
{/* Glas-Toggle: bei Tueren ersetzt Glas das Tuerblatt (verglaste Tuer) */}
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => onUpdate({ glas: !oeff.glas })}
className={oeff.glas ? 'btn-contained' : 'btn-outlined'}
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
title={isFenster ? 'Glasscheibe sichtbar' : 'Verglaste Tür (statt Türblatt)'}>
{isFenster ? 'Glas' : 'Verglast'} {oeff.glas ? '✓' : ''}
</button>
</div>
</div>
)
}
// Wiederverwendete UI fuer UK/OK-Felder mit Auto/Custom-Toggle
function AutoOverrideField({ label, auto, autoValue, rawValue, onChangeRaw, onToggle, onCommit }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}>{label}</span>
<button onClick={onToggle}
className={auto ? 'btn-contained' : 'btn-outlined'}
style={{ padding: '3px 8px', fontSize: 10 }}
title={auto ? 'Folgt Geschoss' : 'Eigener Wert'}>
{auto ? 'Auto' : 'Custom'}
</button>
<input type="text"
value={auto ? fmtNum(autoValue) : rawValue}
disabled={auto}
onChange={(e) => onChangeRaw(e.target.value)}
onBlur={onCommit}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace',
opacity: auto ? 0.6 : 1 }} />
</div>
)
}