// 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 (
{ if (!disabled) {
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.borderColor = 'var(--accent)'
}}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.borderColor = 'var(--border-light)'
}}
>
{label}
{hasMenu && (
)}
{badge && (
{badge}
)}
)
}
// 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) => (
{ e.stopPropagation(); it.onClick(); onClose() }}
disabled={it.disabled}
title={it.hint || ''}
style={{
display: 'flex', alignItems: 'center', gap: 6,
width: '100%', padding: '6px 8px',
background: 'transparent',
border: 'none', borderRadius: 4,
cursor: it.disabled ? 'not-allowed' : 'pointer',
opacity: it.disabled ? 0.4 : 1,
color: 'var(--text-primary)',
fontSize: 11, textAlign: 'left',
}}
onMouseEnter={(e) => { if (!it.disabled) {
e.currentTarget.style.background = 'var(--bg-item-hover)'
}}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}>
{it.icon && }
{it.label}
{it.badge && (
{it.badge}
)}
))}
)
}
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
onUpdate({ profil: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{profilOpts.map(p => (
{(PROFIL_META[p] || {}).label || p}
))}
{!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}
handleRemove(fid)}
title="Entfernen"
style={{
background: 'transparent', border: 'none',
cursor: 'pointer', padding: 0, marginLeft: 2,
color: 'inherit', opacity: 0.6,
}}>
)
})}
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
handleStilChange(e.target.value)}
style={{ flex: 1, fontSize: 11 }}
title="Gespeicherter Stempel-Stil — Klick wendet die Vorlage an">
— Stil wählen —
{stilList.map(s => (
{s.name}
))}
──────────
+ Aktuelle Settings als Stil speichern…
{activeStilId && 🗑 Aktiven Stil löschen }
Geschoss
onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => {g.name} )}
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
onUpdate({ sia: e.target.value })}
title="SIA 416 Flaechenklassifikation"
style={{ flex: 1, fontSize: 11 }}>
{RAUM_SIA_KINDS.map(s => (
{s.code === '' ? '—'
: `${s.label} — ${s.hint}`}
))}
Füllung
onUpdate({ fuellung: e.target.value })}
title="Hatch-Pattern im Normalmodus. Bei aktivem SIA-Modus wird klassifizierten Raeumen automatisch Solid forciert."
style={{ flex: 1, fontSize: 11 }}>
Keine
Ebene (folgt Layer-Farbe)
{patternList.length > 0 && ────── }
{patternList.map(p => (
{p}
))}
Rundung
onUpdate({ rundung: e.target.value })}
style={{ flex: 1, fontSize: 11 }}
title="Rundung der Flächenangabe. Leer = Default aus Mass-Style.">
Aus Mass-Style
Exakt (2 Nachkommastellen)
auf 0.01 m²
auf 0.1 m²
auf 0.5 m²
auf 1 m²
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. */}
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
onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => {g.name} )}
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,
}} />
{
const m = matList.find(x => x.name === e.target.value)
if (m) onChange({ material: m.name, color: m.color })
else onChange({ material: '' })
}}
title="Material aus Bibliothek (steuert Farbe + Section-Hatch)"
style={{ width: 92, fontSize: 10, padding: '2px 4px' }}>
(eigene)
{matList.map(m => (
{m.name}
))}
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
onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => {g.name} )}
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
onUpdate({ dachTyp: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
Pult — Geneigte Fläche, Traufe = 1. Kante
Sattel — Rechteck-Outline (4 Ecken)
Walm — Rechteck-Outline (4 Ecken)
Mansarde — Rechteck-Outline (4 Ecken)
Geschoss
onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => {g.name} )}
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({ dachVariante: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
Walm — Knick auf allen 4 Seiten (Pariser Stil)
Giebel — Knick nur an langen Seiten (DACH-Standard)
Walm + Giebel — Unten Walm, oben Giebel
>
)}
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
onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => {g.name} )}
Ziel
{
const v = e.target.value
if (v === '__custom__') {
// Eigene Hoehe — falls noch nicht gesetzt, mit aktuellem H starten
onUpdate({ hOver: H, geschossEnd: '' })
} else {
onUpdate({ geschossEnd: v, hOver: '' })
}
}}
style={{ flex: 1, fontSize: 11 }}>
(auto: Start + Höhe)
{geschosse.filter(g => g.id !== treppe.geschoss)
.map(g => {g.name} )}
eigene Höhe
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 */}
)
}
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">
— Eigene Werte —
{oeffStyles
.filter(s => s.typ === (isFenster ? 'fenster' : 'tuer'))
.map(s => {s.name} )}
──────────
+ Aktuelle als Stil speichern…
{oeff.styleId && 🗑 Aktiven Stil loeschen }
Darstell.
onUpdate({ darstellung: v })}
title="Detaillierungsgrad — Auto folgt der Modelldarstellung in der Topbar">
Nach Modelldarstellung
Einfach (1:100)
Standard (1:50)
Detail (1:20)
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
onUpdate({ sturz: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
Keine
Innen
Aussen
Beide
)}
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 =>
{o.label} )}
)}
{/* 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 }} />
)
}