// SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Karim Gabriele Varano import { useEffect, useRef, useState } from 'react' import Icon from './components/Icon' import { BarToggle, BarButton, BarCombo } from './components/BarControls' import { onMessage, notifyReady, createWall, createDecke, createDach, createFenster, createTuer, createAussparung, createTreppe, createStuetze, createTraeger, createRaum, openSwisstopo, openSwisstopoDialog, openOsmDialog, updateElement, deleteElement, openElementeUebersicht, openElementeProperties, saveOeffStyle, deleteOeffStyle, saveRaumStil, deleteRaumStil, applyRaumStil, listLibrary, } 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 => ( onChange(o.code)} title={o.hint} /> ))}
) } // 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, elementsCount }) { 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 (
0 ? ' · ' + elementsCount : ''}`} onClick={() => openElementeUebersicht()} disabled={elementsCount === 0} title={elementsCount > 0 ? `Projektübersicht öffnen — ${elementsCount} Elemente` : 'Noch keine Elemente vorhanden'} />
{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({})} /> listLibrary()} /> openSwisstopoDialog()} /> openOsmDialog()} />
) } // PropertiesView: gemeinsame Komponente, rendert die passende Property- // Form je nach Element-Typ. Wiederverwendbar in Inline + Satellite-Window. export function PropertiesView({ selected, geschosse, materials, hatchPatterns, oeffStyles, fonts, raumStempelStile }) { if (!selected) return null const upd = (p) => updateElement(selected.id, p) const del = (label) => () => { if (window.confirm(`${label} löschen?`)) deleteElement(selected.id) } if (selected.kind === 'wand') return if (selected.kind === 'decke') return if (selected.kind === 'dach') return if (selected.kind === 'treppe') return if (selected.kind === 'stuetze' || selected.kind === 'traeger') { const lbl = (KIND_META[selected.kind] || {}).label || 'Element' return } if (selected.kind === 'raum') return if (selected.kind === 'aussparung') return // fenster/tuer return } 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 (
{/* Bei Selektion: Properties OBEN, NeuesElement darunter. Ohne Selektion: nur NeuesElement. Die volle Element-Liste kommt jetzt aus der Projekt-Uebersicht (account_tree-Button). */} {selected && (
openElementeProperties()} title="Eigenschaften in eigenem Fenster öffnen" />
)}
) } 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}
) } // Field-Definitionen fuer den Stempel-Layout-Builder. Symmetrisch zu // _RAUM_FIELD_IDS im Backend (elemente.py). const RAUM_LAYOUT_FIELDS = [ { id: 'nummer', label: 'Nummer', icon: 'tag' }, { id: 'name', label: 'Name', icon: 'label' }, { id: 'funktion', label: 'Funktion', icon: 'category' }, { id: 'area', label: 'Fläche', icon: 'square_foot' }, { id: 'sia', label: 'SIA-Tag', icon: 'class' }, ] // Layout-Builder mit Drag-and-Drop. Rows = Textzeilen, Felder in einer // Row stehen nebeneinander. Drag-Quelle ist ein "active field" Pill oder // ein "verfuegbares" Pill. Drop-Ziel ist eine Row (= an die Row anhaengen) // oder die neue-Row-Drop-Zone unten. function StempelLayoutBuilder({ layout, availableFields, onChange }) { const [dragging, setDragging] = useState(null) // { id, fromRow|null } const FIELD_META = Object.fromEntries(RAUM_LAYOUT_FIELDS.map(f => [f.id, f])) const removeFromLayout = (fid) => { const next = layout.map(row => row.filter(f => f !== fid)) .filter(row => row.length > 0) return next } const handleDragStart = (e, fid, fromRow) => { setDragging({ id: fid, fromRow }) try { e.dataTransfer.effectAllowed = 'move' } catch (_) {} try { e.dataTransfer.setData('text/plain', fid) } catch (_) {} } const handleDragEnd = () => setDragging(null) const handleDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } const handleDropOnRow = (e, rowIdx) => { e.preventDefault() if (!dragging) return const next = removeFromLayout(dragging.id) // Wenn die ge-droppte Source und das Ziel dieselbe Row ist → no-op // (Within-Row-Reorder waere komplexer; ignorieren wir vorerst) if (next[rowIdx]) next[rowIdx] = [...next[rowIdx], dragging.id] else next.push([dragging.id]) onChange(next) setDragging(null) } const handleDropOnNewRow = (e) => { e.preventDefault() if (!dragging) return const next = removeFromLayout(dragging.id) next.push([dragging.id]) onChange(next) setDragging(null) } const handleRemove = (fid) => { onChange(removeFromLayout(fid)) } const handleAddFromAvailable = (fid) => { onChange([...layout, [fid]]) } const pillStyle = (isDragging) => ({ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', fontSize: 10, background: isDragging ? 'var(--accent)' : 'var(--bg-input)', color: isDragging ? 'var(--bg-panel)' : 'var(--text-primary)', border: '1px solid var(--border)', borderRadius: 999, cursor: 'grab', userSelect: 'none', fontFamily: 'DM Mono, monospace', }) const rowStyle = { display: 'flex', flexWrap: 'wrap', gap: 4, padding: '6px 8px', background: 'var(--bg-panel)', border: '1px dashed var(--border)', borderRadius: 'var(--r)', minHeight: 28, alignItems: 'center', } return (
Stempel-Layout
Drag Felder zwischen Zeilen — eine Zeile = eine Textzeile im Stempel.
{/* Verfuegbare Felder (Drag-Quelle) */} {availableFields.length > 0 && (
+ {availableFields.map(f => ( handleDragStart(e, f.id, null)} onDragEnd={handleDragEnd} onClick={() => handleAddFromAvailable(f.id)} title={`${f.label} hinzufügen (klick) oder in Zeile ziehen`} style={{ ...pillStyle(dragging && dragging.id === f.id), opacity: 0.65, cursor: 'pointer', }}> {f.label} ))}
)} {/* Rows */} {layout.map((row, ri) => (
handleDropOnRow(e, ri)} style={rowStyle}> {row.map(fid => { const meta = FIELD_META[fid] || { label: fid, icon: 'label' } return ( handleDragStart(e, fid, ri)} onDragEnd={handleDragEnd} style={pillStyle(dragging && dragging.id === fid)}> {meta.label} ) })} Zeile {ri + 1}
))} {/* Neue-Row Drop-Zone (nur wenn was dragable ist) */}
{dragging ? '↓ Hier ablegen für neue Zeile' : 'Drop hier für neue Zeile'}
) } function RaumProperties({ raum, geschosse, onUpdate, onDelete, hatchPatterns, fonts, raumStempelStile }) { const stilList = raumStempelStile || [] // Match: aktueller Raum-Stempel-Aktiv-Stil-id wird per UserString // dossier_raum_stil_id gespeichert wenn ein Stil applied wurde. Fuer // jetzt: nicht-persistent — Match anhand visueller Settings (font + layout) // koennten wir tun, aber zu fragil. Default: kein Stil markiert. const activeStilId = raum.stilId || '' const handleStilChange = (val) => { if (val === '__save__') { const n = (window.prompt('Name für neuen Stempel-Stil:', 'Stil') || '').trim() if (!n) return saveRaumStil('', n, { font: raum.font || '', bold: !!raum.bold, italic: !!raum.italic, txtH: raum.txtH, txtModus: raum.txtModus || 'fix', align: raum.align || 'mid', rundung: raum.rundung || '', fuellung: raum.fuellung || '', showSia: !!raum.showSia, layout: Array.isArray(raum.layout) ? raum.layout : [], }, [raum.id]) // direkt aktuellen Raum mit der neuen Stil-ID verknuepfen return } if (val === '__delete__') { if (activeStilId && window.confirm(`Stil "${stilList.find(s => s.id === activeStilId)?.name}" löschen?`)) deleteRaumStil(activeStilId) return } if (val) applyRaumStil(val, [raum.id]) } const [name, setName] = useState(raum.name || 'Raum') const [nummer, setNummer] = useState(raum.nummer || '') const [funktion, setFunktion] = useState(raum.funktion || '') // Texthoehe lokal puffern — Modus bestimmt Einheit (m bei fix, mm bei masstab) const txtModus = raum.txtModus || 'fix' const txtHDisplay = (() => { const v = parseFloat(raum.txtH) if (Number.isNaN(v)) return '' return String(v) })() const [txtH, setTxtH] = useState(txtHDisplay) useEffect(() => { setName(raum.name || 'Raum') setNummer(raum.nummer || '') setFunktion(raum.funktion || '') setTxtH(txtHDisplay) }, [raum.id, raum.name, raum.nummer, raum.funktion, raum.txtH, raum.txtModus]) // Aktueller Wert von raum_fuellung: "" | "Solid" | "Hatch1" | … | "ByLayer" const fuell = raum.fuellung || '' const patternList = hatchPatterns || [] // Layout aus State, mit Fallback auf show_*-Flags (backwards-compat // fuer Raeume ohne explizites Layout). Backend faellt ebenfalls auf // dieselbe Konvention zurueck. const rawLayout = Array.isArray(raum.layout) ? raum.layout : [] const layout = rawLayout.length > 0 ? rawLayout : (() => { const head = [] if (raum.showNummer !== false) head.push('nummer') if (raum.showName !== false) head.push('name') const rows = [] if (head.length) rows.push(head) if (raum.showFunktion !== false) rows.push(['funktion']) const tail = [] if (raum.showArea !== false) tail.push('area') if (raum.showSia) tail.push('sia') if (tail.length) rows.push(tail) return rows.length ? rows : [['name']] })() const usedFields = new Set(layout.flat()) const availableFields = RAUM_LAYOUT_FIELDS.filter(f => !usedFields.has(f.id)) return (
Raum · {raum.geschossName}
{/* Stempel-Stil Preset-Picker — apply einer gespeicherten Visual-Vorlage auf den Raum. "+ Aktuell speichern" frischt die Liste mit den jetzigen Settings als neuen Stil. Stile sind per-Doc. */}
Stil
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
Ausrichtung
{RAUM_ALIGN.map(a => ( onUpdate({ align: a.code })} title={a.label} /> ))}
{/* Texthoehe + Modus: fix = m, masstab = mm-auf-Papier (skaliert mit aktivem Plan-Massstab). Bei masstab wirkt jede Massstabs- Aenderung der Oberleiste auch auf den Stempel. */}
Höhe
setTxtH(e.target.value)} onBlur={() => { const v = parseFloat(txtH) if (v > 0 && v !== raum.txtH) onUpdate({ txtH: v }) else setTxtH(txtHDisplay) }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} title={txtModus === 'masstab' ? 'Texthoehe in Paper-mm' : 'Texthoehe in m'} style={{ width: 56, fontSize: 11, textAlign: 'right', fontFamily: 'DM Mono, monospace' }} /> {txtModus === 'masstab' ? 'mm' : 'm'}
Funktion setFunktion(e.target.value)} onBlur={() => { const v = (funktion || '').trim() if (v !== (raum.funktion || '')) onUpdate({ funktion: v }) }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} placeholder="z.B. Wohnen, Bad, Büro …" style={{ flex: 1, fontSize: 11 }} />
{/* Stempel-Layout — Drag-and-Drop. Jede Row ist eine Textzeile, Felder innerhalb einer Row landen in derselben Zeile. Drag zwischen Rows um umzuordnen. Klick auf Field oben fügt es in eine eigene neue Row hinzu. */} onUpdate({ layout: newLayout })} /> {/* Hinweis: Typografie (Font/Stil/Höhe) wird in der OBERLEISTE gesetzt — den Stempel im Viewport anklicken. Aenderungen werden automatisch auf die Raum-Outline gespiegelt damit sie beim naechsten Regen erhalten bleiben. */}
Typografie (Font/Stil/Höhe): Stempel im Viewport selektieren → Oberleiste.
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
onUpdate({ layered: false })} title="Eine homogene Wand-Schicht (Standard)" /> onUpdate({ layered: true })} title="Mehrere Schichten mit individuellen Dicken und Farben" />
{!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
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
)} 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 => ( onUpdate({ treppeReferenz: o.code })} /> ))}
{/* Unterseite-Modus */}
Unten
{MODUS_OPTIONS.map(o => ( onUpdate({ treppeModus: o.code })} title={o.hint} /> ))}
{/* 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, oeffStyles = [] }) { 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 simsAus = oeff.simsAus ?? 'ohne' return (
{label} · {oeff.geschossName}
{/* Stil-Picker — Liste passender Styles (gefiltert nach typ) */}
Stil
{ if (v === '__save__') { const sugg = (oeffStyles.find(s => s.id === oeff.styleId) || {}).name || '' const n = (window.prompt('Name fuer neuen Stil:', sugg || (isFenster ? 'Mein Fenster' : 'Meine Tuer')) || '').trim() if (!n) return saveOeffStyle(n, { typ: isFenster ? 'fenster' : 'tuer', breite: oeff.breite, hoehe: oeff.hoehe, brueest: oeff.brueest, rahmenB: oeff.rahmenB, rahmenTiefe: oeff.rahmenTiefe, rahmenOffset: oeff.rahmenOffset, fluegel: oeff.fluegel, simsAus: oeff.simsAus, glas: oeff.glas, darstellung: oeff.darstellung, tuerRahmen: oeff.tuerRahmen, tuerTyp: oeff.tuerTyp, hingeSide: oeff.hingeSide, openAngle: oeff.openAngle, }) return } if (v === '__delete__') { if (oeff.styleId && window.confirm('Aktiven Stil loeschen?')) deleteOeffStyle(oeff.styleId) return } onUpdate({ styleId: v }) }} title="Stil anwenden — alle Properties werden gesetzt"> {oeffStyles .filter(s => s.typ === (isFenster ? 'fenster' : 'tuer')) .map(s => )} {oeff.styleId && }
Darstell.
onUpdate({ darstellung: v })} title="Detaillierungsgrad — Auto folgt der Modelldarstellung in der Topbar">
Orient.
onUpdate({ aussenseite: (oeff.aussenseite || 'rechts') === 'rechts' ? 'links' : 'rechts' })} title="Aussenseite auf die andere Wandseite umkehren" />
{!isFenster && (
Typ
onUpdate({ tuerTyp: 'normal' })} title="Tuere mit Tuerblatt + Schwung-Bogen" /> onUpdate({ tuerTyp: 'wandoeffnung' })} title="Nur Wand-Cutout ohne Schwung" />
)} {!isFenster && (oeff.tuerTyp || 'normal') === 'normal' && ( <>
Band
onUpdate({ hingeSide: 'links' })} /> onUpdate({ hingeSide: 'rechts' })} />
Öffn. { const v = parseFloat(e.target.value.replace(',', '.')) if (!Number.isNaN(v) && v >= 0 && v <= 180) onUpdate({ openAngle: v }) }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} /> ° onUpdate({ swingInvert: !oeff.swingInvert })} title="Schwung-Richtung umkehren (nach aussen statt innen)" />
)} {!isFenster && (oeff.tuerTyp || 'normal') === 'normal' && (
Rahmen
onUpdate({ tuerRahmen: 'zarge' })} /> onUpdate({ tuerRahmen: 'block' })} />
)} {!isFenster && (oeff.tuerTyp || 'normal') === 'normal' && (
Sturz
)}
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 => ( onUpdate({ oeffReferenz: o.code })} title={o.hint} /> ))}
{/* 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: Abstand der Rahmen-Innenkante von der Wand-Innenseite */}
Lage { const v = parseFloat(e.target.value.replace(',', '.')) if (!Number.isNaN(v) && v >= 0) onUpdate({ rahmenOffset: v }) }} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} /> m v. innen
{/* Fluegel-Anzahl — nur fuer Fenster (Tueren haben ein einzelnes Tuerblatt) */} {isFenster && (
Flügel
{[1, 2, 3, 4].map(n => ( onUpdate({ fluegel: n })} /> ))}
)} {/* Sims — nur aussen. Innen gibt's bewusst nicht. Dient zugleich als visueller Indikator fuer die Aussenseite-Einstellung. */} {isFenster && (
Sims
onUpdate({ simsAus: v })} title="Sims-Stil"> {SIMS_OPTIONS.map(o => )}
)} {/* Glas-Toggle: bei Tueren ersetzt Glas das Tuerblatt (verglaste Tuer) */}
onUpdate({ glas: !oeff.glas })} title={isFenster ? 'Glasscheibe sichtbar' : 'Verglaste Tür (statt Türblatt)'} />
) } // 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 }} />
) }