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,
openSwisstopo, openSwisstopoDialog, openOsmDialog,
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 (
)
}
// 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({})} />
openSwisstopoDialog()} />
openOsmDialog()} />
openSwisstopo('both')} />
)
}
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
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
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 => (
))}
{/* 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 */}
)
}
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 }} />
)
}