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 (
{opts.map(o => ( ))}
) } // Pill-Button — kompakt, Icon + Label horizontal, abgerundet function PillButton({ icon, label, hint, onClick, onContextMenu, disabled, hasMenu, badge }) { return ( ) } // Vertikale Kategorie-Gruppe mit Label + Pills, die wrappen function PillGroup({ label, children }) { return (
{label}
{children}
) } // 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 (
{items.map((it, i) => ( ))}
) } 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 (
Alle Elemente {elements.length}
{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 (
{meta.label} · {arr.length}
{arr.map(el => ( ))}
) })}
) } 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)} m²` 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} m²` } if (el.kind === 'aussparung') return `U ${fmtNum(el.umfang)} m` return `UK ${fmtNum(el.uk)} · OK ${fmtNum(el.ok)}` })() return (
{el.geschossName || '—'} {secondary} {tertiary && ( {tertiary} )}
) } 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 (
Neues Element
{noGeschoss ? 'Kein Geschoss aktiv' : 'auf'} {!noGeschoss && ( {activeName} )}
createWall({ geschoss: '' })} /> createDecke({ geschoss: '' })} /> createDach({ geschoss: '' })} /> createFenster({})} /> createTuer({})} /> createAussparung({})} />
createTreppe({ treppeArt: 'gerade' })} onContextMenu={openTreppeMenu} /> {treppeMenuOpen && ( setTreppeMenuOpen(false)} /> )}
createStuetze({})} onContextMenu={openStuetzeMenu} /> {stuetzeMenuOpen && ( setStuetzeMenuOpen(false)} /> )}
createTraeger({})} onContextMenu={openTraegerMenu} /> {traegerMenuOpen && ( setTraegerMenuOpen(false)} /> )}
createRaum({})} />
) } 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 (
{/* Header */}
ELEMENTE {elements.length}
{/* Element-Toolbar: kategorisierte Pills */} {/* Properties */} {selected ? ( selected.kind === 'wand' ? ( updateElement(selected.id, p)} onDelete={() => { if (window.confirm('Wand löschen?')) deleteElement(selected.id) }} /> ) : selected.kind === 'decke' ? ( updateElement(selected.id, p)} onDelete={() => { if (window.confirm('Decke löschen?')) deleteElement(selected.id) }} /> ) : selected.kind === 'dach' ? ( updateElement(selected.id, p)} onDelete={() => { if (window.confirm('Dach löschen?')) deleteElement(selected.id) }} /> ) : selected.kind === 'treppe' ? ( updateElement(selected.id, p)} onDelete={() => { if (window.confirm('Treppe löschen?')) deleteElement(selected.id) }} /> ) : (selected.kind === 'stuetze' || selected.kind === 'traeger') ? ( 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' ? ( updateElement(selected.id, p)} onDelete={() => { if (window.confirm('Raum löschen?')) deleteElement(selected.id) }} /> ) : selected.kind === 'aussparung' ? ( { if (window.confirm('Aussparung löschen?')) deleteElement(selected.id) }} /> ) : ( updateElement(selected.id, p)} onDelete={() => { const label = selected.kind === 'fenster' ? 'Fenster' : 'Tür' if (window.confirm(`${label} löschen?`)) deleteElement(selected.id) }} /> ) ) : (
Kein Element selektiert.
Eine Wand-Achse oder Decken-Outline in Rhino auswählen.
)} {/* Liste aller Elemente */} {elements.length > 0 && ( )}
) } function NumberField({ label, value, onCommit, width, step }) { const [raw, setRaw] = useState(String(value)) useEffect(() => { setRaw(String(value)) }, [value]) return (
{label} 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' }} />
) } 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 (
{meta.label} · {el.geschossName}
Profil
{!isRound && ( onUpdate({ b: v })} /> )} {showH && ( onUpdate({ h: v })} /> )} {isRound && ( onUpdate({ d: v })} /> )} {showT && ( onUpdate({ t: v })} /> )} onUpdate({ angle: v })} />
{isStuetze ? 'Höhe' : 'OK über UK'} 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' }} />
UK {fmtNum(el.uk)} · OK {fmtNum(el.ok)} {!isStuetze && L {fmtNum(el.axisLen)} m}
) } 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 (
Raum · {raum.geschossName}
Geschoss
Nummer 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' }} />
Name 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 }} />
Typ
Füllung
Rundung
{RAUM_RUNDUNGEN.map(r => ( ))}
Ausrichtung
{RAUM_ALIGN.map(a => ( ))}
Texthöhe 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' }} />
Flaeche: {raum.areaFmt} m² Umfang: {fmtNum(raum.umfang)} m
) } function AussparungProperties({ aussp, onDelete }) { return (
Aussparung · {aussp.geschossName}
Fläche: {fmtNum(aussp.area)} m² Umfang: {fmtNum(aussp.umfang)} m
Outline in Rhino editieren (Punkte ziehen, _Reshape, …) — die Eltern-Decke wird automatisch nachgerechnet.
) } 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 (
Wand · {wall.geschossName}
Geschoss
Aufbau
{!wall.layered && (
Dicke 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' }} />
)} {wall.layered && ( onUpdate({ layers })} /> )}
Referenz onUpdate({ referenz: v })} />
onUpdate({ ukOverride: ukAuto ? wall.uk : '' })} onCommit={() => { if (ukAuto) return const v = parseFloat(ukOver) if (!Number.isNaN(v)) onUpdate({ ukOverride: v }) }} /> onUpdate({ okOverride: okAuto ? wall.ok : '' })} onCommit={() => { if (okAuto) return const v = parseFloat(okOver) if (!Number.isNaN(v)) onUpdate({ okOverride: v }) }} />
) } 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 (
Schichten (links → rechts) Σ {total.toFixed(3)} m
{(layers || []).map((ly, i) => ( updateAt(i, patch)} onRemove={() => removeAt(i)} /> ))}
) } 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 (
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, }} /> 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' }} /> 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" />
) } 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 (
Decke · {decke.geschossName}
Geschoss
Dicke 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' }} />
onUpdate({ ukOverride: ukAuto ? decke.uk : '' })} onCommit={() => { if (ukAuto) return const v = parseFloat(ukOver) if (!Number.isNaN(v)) onUpdate({ ukOverride: v }) }} /> onUpdate({ okOverride: okAuto ? decke.ok : '' })} onCommit={() => { if (okAuto) return const v = parseFloat(okOver) if (!Number.isNaN(v)) onUpdate({ okOverride: v }) }} />
) } 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 (
{dachTyp === 'sattel' ? 'Satteldach' : dachTyp === 'walm' ? 'Walmdach' : 'Pultdach'} · {dach.geschossName}
{/* Dach-Typ */}
Typ
{[ { code: 'pult', label: 'Pult' }, { code: 'sattel', label: 'Sattel' }, { code: 'walm', label: 'Walm' }, { code: 'mansarde', label: 'Mansarde' }, ].map(o => ( ))}
Geschoss
Dicke 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' }} />
Neigung 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' }} /> °
{dachTyp === 'pult' && (
Traufe 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' }} /> Kante
)} {/* Mansarde-spezifisch: Variante + untere Neigung + Knick-Hoehe */} {dachTyp === 'mansarde' && ( <>
Variante
{[ { 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 => ( ))}
)} onUpdate({ ukOverride: ukAuto ? dach.uk : '' })} onCommit={() => { if (ukAuto) return const v = parseFloat(ukOver) if (!Number.isNaN(v)) onUpdate({ ukOverride: v }) }} />
) } 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 ( <>
Steil 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' }} /> °
Knick H 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' }} /> m
) } 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 (
{label} {fmtNum(value)} {unit} {readOnly ? ( {fmtNum(lo)}–{fmtNum(hi)} ) : ( <> Soll 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)', }} /> 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)', }} /> )}
) } 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 (
Treppe · {treppe.geschossName} → {treppe.geschossEndName || '(auto)'}
Start
Ziel
Breite 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' }} /> m
Stufen 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' }} /> ×
Lage
{REF_OPTIONS.map(o => ( ))}
{/* Unterseite-Modus */}
Unten
{MODUS_OPTIONS.map(o => ( ))}
{/* Lauf-Plattendicke (nur fuer flach + plattenrand relevant) */} {modus !== 'massiv' && (
Platte 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' }} /> m
)} {/* Schrittmass-Tabelle: H (editierbar), S, A, 2S+A mit on/off + range */}
{/* H — editierbar; aendert Hoehe und kippt Ziel auf "eigene" */}
H 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)', }} /> m {hasHOver && ( )}
) } 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 (
{label} · {oeff.geschossName}
Breite 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' }} /> m
Höhe 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' }} /> m
{isFenster ? 'Brüst.' : 'Schw.'} 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' }} /> m
{/* Referenz-Lage: wo sitzt der Klick-Punkt in der Oeffnung */}
Ref
{OEFF_REFERENZ_OPTIONS.map(o => ( ))}
{/* Rahmen-Profil (Breite × Tiefe) — beide Felder gleich breit */}
Rahmen 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' }} /> × 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' }} /> m
{/* Rahmen-Lage im Wandquerschnitt */}
Lage
{RAHMEN_POS_OPTIONS.map(o => ( ))}
{/* Fluegel-Anzahl — nur fuer Fenster (Tueren haben ein einzelnes Tuerblatt) */} {isFenster && (
Flügel
{[1, 2, 3, 4].map(n => ( ))}
)} {/* Sims-Stile (aussen / innen) — nur fuer Fenster */} {isFenster && ( <>
Sims a.
Sims i.
)} {/* Glas-Toggle: bei Tueren ersetzt Glas das Tuerblatt (verglaste Tuer) */}
) } // Wiederverwendete UI fuer UK/OK-Felder mit Auto/Custom-Toggle function AutoOverrideField({ label, auto, autoValue, rawValue, onChangeRaw, onToggle, onCommit }) { return (
{label} 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 }} />
) }