Files
DOSSIER/src/ElementeApp.jsx
T
karim f208e7fc00 Raumstempel-Panel: Hoehe + Ausrichtung weg, kommt aus Oberleiste
UX-Konsolidierung: Texthoehe und Ausrichtung wurden bisher doppelt
gesetzt (Panel + Oberleiste-Text-Block). Jetzt nur noch via Oberleiste
wenn der Stempel selektiert ist — Panel zeigt nur noch den Skala-Modus
(fix/masstab) als kompakten Dropdown.

Backend: _sync_raum_stamps_to_source spiegelt jetzt auch
TextHorizontalAlignment vom Stempel zur Source (Left/Center/Right →
links/mid/rechts). Damit kommt die User-Wahl aus der Oberleiste sauber
in die UserStrings — und wird beim naechsten Regen + bei Stil-Speichern
korrekt uebernommen.

Frontend RaumProperties:
- Ausrichtung-Zeile (3 BarToggles) raus
- Hoehe-Zeile reduziert auf Skala-Modus-Dropdown:
  "fix · Hoehe = Oberleiste-Wert (m)" | "masstab · skaliert mit Plan-Massstab"
- Local-State txtH + setTxtH entfernt (unused)
- Stempel-Stil-Speichern liest weiter raum.txtH + raum.align (kommen
  jetzt vom Sync) → captured korrekt
2026-05-26 23:33:38 +02:00

2346 lines
97 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
{opts.map(o => (
<BarToggle
key={o.code}
label={o.label}
active={value === o.code}
onClick={() => onChange(o.code)}
title={o.hint}
/>
))}
</div>
)
}
// Pill-Button — kompakt, Icon + Label horizontal, abgerundet
function PillButton({ icon, label, hint, onClick, onContextMenu, disabled,
hasMenu, badge }) {
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
disabled={disabled}
title={hint}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '5px 10px 5px 8px',
background: 'var(--bg-input)',
border: '1px solid var(--border-light)',
borderRadius: 999,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.4 : 1,
transition: 'background 0.1s, border-color 0.1s',
fontSize: 11, fontWeight: 500,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
position: 'relative',
}}
onMouseEnter={(e) => { if (!disabled) {
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.borderColor = 'var(--accent)'
}}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.borderColor = 'var(--border-light)'
}}
>
<Icon name={icon} size={14} style={{ color: 'var(--accent)' }} />
<span>{label}</span>
{hasMenu && (
<Icon name="expand_more" size={12}
style={{ color: 'var(--text-muted)', marginLeft: -2 }} />
)}
{badge && (
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 8,
background: 'var(--bg-section)', color: 'var(--text-muted)',
marginLeft: 2,
}}>{badge}</span>
)}
</button>
)
}
// Vertikale Kategorie-Gruppe mit Label + Pills, die wrappen
function PillGroup({ label, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{
fontSize: 9, color: 'var(--text-muted)',
letterSpacing: '0.08em', textTransform: 'uppercase',
fontWeight: 600,
}}>
{label}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{children}
</div>
</div>
)
}
// Popup-Menue (relativ positioniert) fuer Untertypen wie Treppen-Art
function PopupMenu({ items, onClose }) {
useEffect(() => {
const onDocClick = () => onClose()
document.addEventListener('click', onDocClick)
return () => document.removeEventListener('click', onDocClick)
}, [onClose])
return (
<div style={{
position: 'absolute',
top: '100%', left: 0,
marginTop: 4,
background: 'var(--bg-section)',
border: '1px solid var(--border)',
borderRadius: 6,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
padding: 4,
zIndex: 100,
minWidth: 140,
}}>
{items.map((it, i) => (
<button key={i}
onClick={(e) => { e.stopPropagation(); it.onClick(); onClose() }}
disabled={it.disabled}
title={it.hint || ''}
style={{
display: 'flex', alignItems: 'center', gap: 6,
width: '100%', padding: '6px 8px',
background: 'transparent',
border: 'none', borderRadius: 4,
cursor: it.disabled ? 'not-allowed' : 'pointer',
opacity: it.disabled ? 0.4 : 1,
color: 'var(--text-primary)',
fontSize: 11, textAlign: 'left',
}}
onMouseEnter={(e) => { if (!it.disabled) {
e.currentTarget.style.background = 'var(--bg-item-hover)'
}}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}>
{it.icon && <Icon name={it.icon} size={12}
style={{ color: 'var(--accent)' }} />}
<span style={{ flex: 1 }}>{it.label}</span>
{it.badge && (
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>{it.badge}</span>
)}
</button>
))}
</div>
)
}
const KIND_META = {
wand: { icon: 'view_week', label: 'Wand', color: '#a8b8c8' },
decke: { icon: 'layers', label: 'Decke', color: '#b8a890' },
dach: { icon: 'roofing', label: 'Dach', color: '#c89878' },
fenster: { icon: 'window', label: 'Fenster', color: '#90b8d0' },
tuer: { icon: 'sensor_door', label: 'Tür', color: '#c8a878' },
treppe: { icon: 'stairs', label: 'Treppe', color: '#a0c0a0' },
stuetze: { icon: 'square_foot', label: 'Stütze', color: '#5fa896' },
traeger: { icon: 'horizontal_rule', label: 'Träger', color: '#7fc8a8' },
raum: { icon: 'crop_free', label: 'Raum', color: '#a0a8b0' },
aussparung: { icon: 'rectangle', label: 'Aussparung', color: '#9090a0' },
}
const RAUM_RUNDUNGEN = ['exakt', '0.01', '0.1', '0.5', '1']
const RAUM_ALIGN = [
{ code: 'links', label: 'Links', icon: 'format_align_left' },
{ code: 'mid', label: 'Mitte', icon: 'format_align_center' },
{ code: 'rechts', label: 'Rechts', icon: 'format_align_right' },
]
const RAUM_SIA_KINDS = [
{ code: '', label: '—', color: 'transparent', hint: '' },
{ code: 'hnf', label: 'HNF', color: '#e8a8a8', hint: 'Hauptnutzfläche' },
{ code: 'nnf', label: 'NNF', color: '#e8c498', hint: 'Nebennutzfläche' },
{ code: 'vf', label: 'VF', color: '#e8d878', hint: 'Verkehrsfläche' },
{ code: 'ff', label: 'FF', color: '#a8c8e0', hint: 'Funktionsfläche' },
]
const PROFIL_META = {
quadrat: { label: 'Quadrat', icon: 'square' },
rechteck: { label: 'Rechteck', icon: 'rectangle' },
rund: { label: 'Rund', icon: 'circle' },
i_profil: { label: 'I-Profil', icon: 'view_column' },
rohr: { label: 'Rohr', icon: 'radio_button_unchecked' },
}
function ElementList({ elements }) {
// Gruppiert nach kind, dann nach geschoss-Reihenfolge wie sie reinkommen
const grouped = {}
for (const el of elements) {
const k = el.kind || 'unknown'
if (!grouped[k]) grouped[k] = []
grouped[k].push(el)
}
const kindOrder = ['wand', 'decke', 'dach', 'fenster', 'tuer', 'aussparung',
'treppe', 'stuetze', 'traeger', 'raum']
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ ...labelXs, flex: 1 }}>Alle Elemente</span>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>{elements.length}</span>
</div>
{kindOrder.map(k => {
const arr = grouped[k]
if (!arr || arr.length === 0) return null
const meta = KIND_META[k] || { icon: 'help', label: k, color: '#888' }
return (
<div key={k} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 5,
fontSize: 9, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase',
fontWeight: 600,
paddingLeft: 2,
}}>
<Icon name={meta.icon} size={10}
style={{ color: meta.color }} />
<span>{meta.label}</span>
<span style={{ color: 'var(--text-muted)', opacity: 0.6 }}>·</span>
<span style={{ opacity: 0.8 }}>{arr.length}</span>
</div>
{arr.map(el => (
<ElementListRow key={el.id} el={el} meta={meta} />
))}
</div>
)
})}
</div>
)
}
function ElementListRow({ el, meta }) {
const secondary = (() => {
if (el.kind === 'fenster' || el.kind === 'tuer')
return `${fmtNum(el.breite)}×${fmtNum(el.hoehe)} m`
if (el.kind === 'treppe')
return `${el.nStufen} St · H ${fmtNum(el.ok - el.uk)} m`
if (el.kind === 'dach' && el.neigung != null)
return `d ${fmtNum(el.dicke)} · ${fmtNum(el.neigung)}°`
if (el.kind === 'stuetze' || el.kind === 'traeger') {
const pl = (PROFIL_META[el.profil] || {}).label || el.profil
const dim = (el.profil === 'rund' || el.profil === 'rohr')
? `Ø${fmtNum(el.d)}`
: `${fmtNum(el.b)}×${fmtNum(el.h)}`
return `${pl} ${dim}`
}
if (el.kind === 'raum') {
const label = el.nummer ? `${el.nummer} ${el.name}` : el.name
return label || 'Raum'
}
if (el.kind === 'aussparung')
return `${fmtNum(el.area)}`
return `d ${fmtNum(el.dicke)} m`
})()
const tertiary = (() => {
if (el.kind === 'fenster')
return `Br ${fmtNum(el.brueest)}`
if (el.kind === 'tuer' || el.kind === 'treppe')
return ''
if (el.kind === 'stuetze')
return `H ${fmtNum(el.ok - el.uk)} m`
if (el.kind === 'traeger')
return `L ${fmtNum(el.axisLen)} m`
if (el.kind === 'raum') {
const siaInfo = RAUM_SIA_KINDS.find(s => s.code === (el.sia || ''))
const tag = siaInfo && siaInfo.code ? `${siaInfo.label} · ` : ''
return `${tag}${el.areaFmt}`
}
if (el.kind === 'aussparung')
return `U ${fmtNum(el.umfang)} m`
return `UK ${fmtNum(el.uk)} · OK ${fmtNum(el.ok)}`
})()
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '5px 8px',
background: el.selected ? 'var(--bg-item-active)' : 'transparent',
border: '1px solid ' + (el.selected ? 'var(--accent)' : 'transparent'),
borderLeft: '3px solid ' + (el.selected ? 'var(--accent)' : meta.color),
borderRadius: 'var(--r)',
fontSize: 10,
}}>
<span style={{
fontFamily: 'DM Mono, monospace', fontWeight: 500,
color: 'var(--text-primary)',
minWidth: 40,
}}>
{el.geschossName || '—'}
</span>
<span style={{
flex: 1, fontFamily: 'DM Mono, monospace',
color: 'var(--text-primary)',
}}>
{secondary}
</span>
{tertiary && (
<span style={{
color: 'var(--text-muted)', fontFamily: 'DM Mono, monospace',
fontSize: 9,
}}>
{tertiary}
</span>
)}
</div>
)
}
function NeuesElementSection({ noGeschoss, activeName, 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 (
<div style={{
display: 'flex', flexDirection: 'column', gap: 10,
marginBottom: 8,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10 }}>
<BarToggle
icon="account_tree"
label={`Projektübersicht${elementsCount > 0 ? ' · ' + elementsCount : ''}`}
onClick={() => openElementeUebersicht()}
disabled={elementsCount === 0}
title={elementsCount > 0
? `Projektübersicht öffnen — ${elementsCount} Elemente`
: 'Noch keine Elemente vorhanden'} />
<div style={{ flex: 1 }} />
<span style={{ color: noGeschoss ? 'var(--danger)' : 'var(--text-muted)' }}>
{noGeschoss ? 'Kein Geschoss aktiv' : 'auf'}
</span>
{!noGeschoss && (
<span className="chip chip-accent" style={{
fontSize: 10, fontFamily: 'DM Mono, monospace', fontWeight: 600,
}}>
{activeName}
</span>
)}
</div>
<PillGroup label="Konstruktion">
<PillButton icon="view_week" label="Wand"
hint={baseHint('Wand zeichnen')} disabled={dis}
onClick={() => createWall({ geschoss: '' })} />
<PillButton icon="layers" label="Decke"
hint={baseHint('Decke zeichnen')} disabled={dis}
onClick={() => createDecke({ geschoss: '' })} />
<PillButton icon="roofing" label="Dach"
hint={baseHint('Pultdach zeichnen — Traufe = 1. Kante')} disabled={dis}
onClick={() => createDach({ geschoss: '' })} />
</PillGroup>
<PillGroup label="Öffnungen">
<PillButton icon="window" label="Fenster"
hint={dis ? baseHint('Fenster') :
'Erst Wand-Achse wählen, dann Punkt darauf'} disabled={dis}
onClick={() => createFenster({})} />
<PillButton icon="sensor_door" label="Tür"
hint={dis ? baseHint('Tür') :
'Erst Wand-Achse wählen, dann Punkt darauf'} disabled={dis}
onClick={() => createTuer({})} />
<PillButton icon="rectangle" label="Aussparung"
hint={dis ? baseHint('Aussparung') :
'Outline auf einer Decke zeichnen — wird automatisch ausgeschnitten'}
disabled={dis}
onClick={() => createAussparung({})} />
</PillGroup>
<PillGroup label="Erschliessung">
<div ref={treppeWrapperRef} style={{ position: 'relative' }}>
<PillButton icon="stairs" label="Treppe" hasMenu
hint={dis ? baseHint('Treppe') :
'Klick: gerade Treppe · Rechtsklick: Typ wählen'}
disabled={dis}
onClick={() => createTreppe({ treppeArt: 'gerade' })}
onContextMenu={openTreppeMenu} />
{treppeMenuOpen && (
<PopupMenu items={treppeItems}
onClose={() => setTreppeMenuOpen(false)} />
)}
</div>
</PillGroup>
<PillGroup label="Tragwerk">
<div style={{ position: 'relative' }}>
<PillButton icon="square_foot" label="Stütze" hasMenu
hint={dis ? baseHint('Stütze') :
'Klick: Quadrat-Stütze · Rechtsklick: Profil wählen'}
disabled={dis}
onClick={() => createStuetze({})}
onContextMenu={openStuetzeMenu} />
{stuetzeMenuOpen && (
<PopupMenu items={stuetzeItems}
onClose={() => setStuetzeMenuOpen(false)} />
)}
</div>
<div style={{ position: 'relative' }}>
<PillButton icon="horizontal_rule" label="Träger" hasMenu
hint={dis ? baseHint('Träger') :
'Klick: Rechteck-Träger · Rechtsklick: Profil wählen'}
disabled={dis}
onClick={() => createTraeger({})}
onContextMenu={openTraegerMenu} />
{traegerMenuOpen && (
<PopupMenu items={traegerItems}
onClose={() => setTraegerMenuOpen(false)} />
)}
</div>
</PillGroup>
<PillGroup label="Raeume">
<PillButton icon="crop_free" label="Raum"
hint={dis ? baseHint('Raum') :
'Outline zeichnen · Stempel zeigt Name + Fläche'}
disabled={dis}
onClick={() => createRaum({})} />
</PillGroup>
<PillGroup label="Library">
<PillButton icon="inventory_2" label="Symbol"
hint={dis ? baseHint('Symbol') :
'Library-Picker oeffnen · Item waehlen · im Viewport Punkt klicken zum Platzieren'}
disabled={dis}
onClick={() => listLibrary()} />
</PillGroup>
<PillGroup label="Importer">
<PillButton icon="map" label="Swisstopo"
hint="Vollautomatischer Import via swisstopo STAC-API: Adresse suchen, Radius wählen, Gebäude + Terrain + Luftbild holen"
onClick={() => openSwisstopoDialog()} />
<PillButton icon="public" label="OSM"
hint="OpenStreetMap-Daten via Overpass-API als 2D-Linien: Strassen, Gebäudeumrisse, Wasser, Grünflächen, Wege"
onClick={() => openOsmDialog()} />
</PillGroup>
</div>
)
}
// 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 <WallProperties wall={selected} geschosse={geschosse} materials={materials || []}
onUpdate={upd} onDelete={del('Wand')} />
if (selected.kind === 'decke')
return <DeckenProperties decke={selected} geschosse={geschosse}
onUpdate={upd} onDelete={del('Decke')} />
if (selected.kind === 'dach')
return <DachProperties dach={selected} geschosse={geschosse}
onUpdate={upd} onDelete={del('Dach')} />
if (selected.kind === 'treppe')
return <TreppeProperties treppe={selected} geschosse={geschosse}
onUpdate={upd} onDelete={del('Treppe')} />
if (selected.kind === 'stuetze' || selected.kind === 'traeger') {
const lbl = (KIND_META[selected.kind] || {}).label || 'Element'
return <TragwerkProperties el={selected} onUpdate={upd} onDelete={del(lbl)} />
}
if (selected.kind === 'raum')
return <RaumProperties raum={selected} geschosse={geschosse}
hatchPatterns={hatchPatterns} fonts={fonts || []}
raumStempelStile={raumStempelStile || []}
onUpdate={upd} onDelete={del('Raum')} />
if (selected.kind === 'aussparung')
return <AussparungProperties aussp={selected} onDelete={del('Aussparung')} />
// fenster/tuer
return <OeffnungProperties oeff={selected} onUpdate={upd}
oeffStyles={oeffStyles || []}
onDelete={del(selected.kind === 'fenster' ? 'Fenster' : 'Tür')} />
}
export default function ElementeApp() {
const [state, setState] = useState({
elements: [], geschosse: [], selection: null,
activeGeschoss: '', activeGeschossName: '',
})
// Defaults werden vom Backend (sticky) verwaltet — letzte Werte aus
// dem Rhino-Prompt bleiben fuer den naechsten Klick erhalten.
useEffect(() => {
onMessage('STATE', (s) => setState(prev => ({ ...prev, ...s })))
notifyReady()
}, [])
const elements = state.elements || []
const geschosse = state.geschosse || []
const selected = elements.find(el => el.id === state.selection)
const activeName = state.activeGeschossName || ''
const noGeschoss = !state.activeGeschoss
return (
<div style={{
display: 'flex', flexDirection: 'column',
height: '100vh', overflow: 'hidden',
background: 'var(--bg-base)', color: 'var(--text-primary)',
fontFamily: 'var(--font)', fontSize: 11,
}}>
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: 8 }}>
{/* Bei Selektion: Properties OBEN, NeuesElement darunter.
Ohne Selektion: nur NeuesElement. Die volle Element-Liste
kommt jetzt aus der Projekt-Uebersicht (account_tree-Button). */}
{selected && (
<div style={{ position: 'relative' }}>
<div style={{ position: 'absolute', top: 8, right: 38, zIndex: 1 }}>
<BarButton icon="open_in_new"
onClick={() => openElementeProperties()}
title="Eigenschaften in eigenem Fenster öffnen" />
</div>
<PropertiesView
selected={selected}
geschosse={geschosse}
materials={state.materials || []}
hatchPatterns={state.hatchPatterns}
fonts={state.fonts || []}
oeffStyles={state.oeffStyles || []}
raumStempelStile={state.raumStempelStile || []} />
</div>
)}
<NeuesElementSection
noGeschoss={noGeschoss}
activeName={activeName}
elementsCount={elements.length}
/>
</div>
</div>
)
}
function NumberField({ label, value, onCommit, width, step }) {
const [raw, setRaw] = useState(String(value))
useEffect(() => { setRaw(String(value)) }, [value])
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-muted)',
width: width || 60 }}>{label}</span>
<input type="text" value={raw}
onChange={(e) => setRaw(e.target.value)}
onBlur={() => {
const v = parseFloat(raw)
if (!Number.isNaN(v) && v !== value) onCommit(v)
else setRaw(String(value))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
</div>
)
}
function TragwerkProperties({ el, onUpdate, onDelete }) {
const meta = KIND_META[el.kind] || { icon: 'square_foot', label: 'Element' }
const profil = el.profil || 'quadrat'
const profilOpts = ['quadrat', 'rechteck', 'rund', 'i_profil', 'rohr']
const isRound = (profil === 'rund' || profil === 'rohr')
const showH = (profil === 'rechteck' || profil === 'i_profil')
const showT = (profil === 'rohr' || profil === 'i_profil')
const isStuetze = (el.kind === 'stuetze')
const [zRaw, setZRaw] = useState(el.zOver || '')
useEffect(() => { setZRaw(el.zOver || '') }, [el.id, el.zOver])
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name={meta.icon} size={13}
style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
{meta.label} · {el.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete}
title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>
Profil
</span>
<select value={profil}
onChange={(e) => onUpdate({ profil: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{profilOpts.map(p => (
<option key={p} value={p}>
{(PROFIL_META[p] || {}).label || p}
</option>
))}
</select>
</div>
{!isRound && (
<NumberField label="Breite" value={el.b}
onCommit={(v) => onUpdate({ b: v })} />
)}
{showH && (
<NumberField label="Höhe" value={el.h}
onCommit={(v) => onUpdate({ h: v })} />
)}
{isRound && (
<NumberField label="Durchm." value={el.d}
onCommit={(v) => onUpdate({ d: v })} />
)}
{showT && (
<NumberField label="Wanddicke" value={el.t}
onCommit={(v) => onUpdate({ t: v })} />
)}
<NumberField label="Drehung°" value={el.angle}
onCommit={(v) => onUpdate({ angle: v })} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>
{isStuetze ? 'Höhe' : 'OK über UK'}
</span>
<input type="text" value={zRaw}
placeholder={`auto (${fmtNum(el.ok - el.uk)} m)`}
onChange={(e) => setZRaw(e.target.value)}
onBlur={() => {
const v = zRaw.trim()
if (v === '') {
if ((el.zOver || '') !== '') onUpdate({ zOver: '' })
} else {
const n = parseFloat(v)
if (!Number.isNaN(n)) onUpdate({ zOver: n })
}
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11,
fontFamily: 'DM Mono, monospace' }} />
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--text-muted)',
fontFamily: 'DM Mono, monospace',
paddingTop: 4, borderTop: '1px dashed var(--border)',
}}>
<span>UK {fmtNum(el.uk)} · OK {fmtNum(el.ok)}</span>
{!isStuetze && <span>L {fmtNum(el.axisLen)} m</span>}
</div>
</div>
)
}
// 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 (
<div style={{
display: 'flex', flexDirection: 'column', gap: 6,
paddingTop: 6, borderTop: '1px dashed var(--border)',
}}>
<span style={{ ...labelXs, marginBottom: 0 }}>Stempel-Layout</span>
<div style={{ fontSize: 9, color: 'var(--text-muted)', marginBottom: 2 }}>
Drag Felder zwischen Zeilen eine Zeile = eine Textzeile im Stempel.
</div>
{/* Verfuegbare Felder (Drag-Quelle) */}
{availableFields.length > 0 && (
<div style={{
display: 'flex', flexWrap: 'wrap', gap: 4,
padding: '4px 0', marginBottom: 2,
}}>
<span style={{ fontSize: 9, color: 'var(--text-muted)',
alignSelf: 'center', marginRight: 4 }}>+</span>
{availableFields.map(f => (
<span key={f.id}
draggable
onDragStart={(e) => 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',
}}>
<Icon name={f.icon} size={11} />{f.label}
</span>
))}
</div>
)}
{/* Rows */}
{layout.map((row, ri) => (
<div key={ri}
onDragOver={handleDragOver}
onDrop={(e) => handleDropOnRow(e, ri)}
style={rowStyle}>
{row.map(fid => {
const meta = FIELD_META[fid] || { label: fid, icon: 'label' }
return (
<span key={fid}
draggable
onDragStart={(e) => handleDragStart(e, fid, ri)}
onDragEnd={handleDragEnd}
style={pillStyle(dragging && dragging.id === fid)}>
<Icon name={meta.icon} size={11} />
{meta.label}
<button onClick={() => handleRemove(fid)}
title="Entfernen"
style={{
background: 'transparent', border: 'none',
cursor: 'pointer', padding: 0, marginLeft: 2,
color: 'inherit', opacity: 0.6,
}}>
<Icon name="close" size={10} />
</button>
</span>
)
})}
<span style={{ flex: 1, fontSize: 9, color: 'var(--text-muted)',
textAlign: 'right' }}>Zeile {ri + 1}</span>
</div>
))}
{/* Neue-Row Drop-Zone (nur wenn was dragable ist) */}
<div
onDragOver={handleDragOver}
onDrop={handleDropOnNewRow}
style={{
fontSize: 9, color: 'var(--text-muted)', textAlign: 'center',
padding: '6px 0',
border: '1px dashed transparent',
borderColor: dragging ? 'var(--accent)' : 'var(--border)',
borderRadius: 'var(--r)',
background: dragging ? 'var(--bg-item-hover)' : 'transparent',
transition: 'all 0.15s',
}}>
{dragging ? '↓ Hier ablegen für neue Zeile' : 'Drop hier für neue Zeile'}
</div>
</div>
)
}
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 (raum.txtH) + Ausrichtung (raum.align) werden via Oberleiste
// gesetzt — kein Local-State noetig, Stil-Speichern liest direkt aus raum.
const txtModus = raum.txtModus || 'fix'
useEffect(() => {
setName(raum.name || 'Raum')
setNummer(raum.nummer || '')
setFunktion(raum.funktion || '')
}, [raum.id, raum.name, raum.nummer, raum.funktion])
// 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 (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="crop_free" size={13}
style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Raum · {raum.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete}
title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
{/* 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. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Stil</span>
<select value={activeStilId}
onChange={(e) => handleStilChange(e.target.value)}
style={{ flex: 1, fontSize: 11 }}
title="Gespeicherter Stempel-Stil — Klick wendet die Vorlage an">
<option value=""> Stil wählen </option>
{stilList.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
<option disabled></option>
<option value="__save__">+ Aktuelle Settings als Stil speichern</option>
{activeStilId && <option value="__delete__">🗑 Aktiven Stil löschen</option>}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Geschoss</span>
<select value={raum.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Nummer</span>
<input type="text" value={nummer}
onChange={(e) => setNummer(e.target.value)}
onBlur={() => {
if (nummer !== (raum.nummer || '')) onUpdate({ nummer })
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11,
fontFamily: 'DM Mono, monospace' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Name</span>
<input type="text" value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => {
const v = (name || 'Raum').trim()
if (v !== raum.name) onUpdate({ name: v })
else setName(v)
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11 }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Typ</span>
<select value={raum.sia || ''}
onChange={(e) => onUpdate({ sia: e.target.value })}
title="SIA 416 Flaechenklassifikation"
style={{ flex: 1, fontSize: 11 }}>
{RAUM_SIA_KINDS.map(s => (
<option key={s.code} value={s.code}>
{s.code === '' ? '—'
: `${s.label}${s.hint}`}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Füllung</span>
<select value={fuell}
onChange={(e) => onUpdate({ fuellung: e.target.value })}
title="Hatch-Pattern im Normalmodus. Bei aktivem SIA-Modus wird klassifizierten Raeumen automatisch Solid forciert."
style={{ flex: 1, fontSize: 11 }}>
<option value="">Keine</option>
<option value="ByLayer">Ebene (folgt Layer-Farbe)</option>
{patternList.length > 0 && <option disabled></option>}
{patternList.map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Rundung</span>
<select value={raum.rundung || ''}
onChange={(e) => onUpdate({ rundung: e.target.value })}
style={{ flex: 1, fontSize: 11 }}
title="Rundung der Flächenangabe. Leer = Default aus Mass-Style.">
<option value="">Aus Mass-Style</option>
<option value="exakt">Exakt (2 Nachkommastellen)</option>
<option value="0.01">auf 0.01 </option>
<option value="0.1">auf 0.1 </option>
<option value="0.5">auf 0.5 </option>
<option value="1">auf 1 </option>
</select>
</div>
{/* Skala-Modus: fix = Modellhoehe in m (Oberleiste setzt direkt);
masstab = Paper-mm @ Plan-Massstab (Oberleiste-Hoehe wird zur
Render-Zeit berechnet, nicht gespiegelt). Hoehe + Ausrichtung
werden via Oberleiste-Text-Block gesetzt wenn der Stempel
selektiert ist — _sync_raum_stamps_to_source spiegelt die
Werte automatisch auf die Source-UserStrings. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Skala</span>
<select value={txtModus}
onChange={(e) => onUpdate({ txtModus: e.target.value })}
title="fix: Hoehe in m via Oberleiste · masstab: Paper-mm via Massstab"
style={{ flex: 1, fontSize: 11 }}>
<option value="fix">fix · Hoehe = Oberleiste-Wert (m)</option>
<option value="masstab">masstab · skaliert mit Plan-Massstab</option>
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 60 }}>Funktion</span>
<input type="text" value={funktion}
onChange={(e) => 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 }} />
</div>
{/* 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. */}
<StempelLayoutBuilder
layout={layout}
availableFields={availableFields}
onChange={(newLayout) => 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. */}
<div style={{
fontSize: 9, color: 'var(--text-muted)',
paddingTop: 4, borderTop: '1px dashed var(--border)',
display: 'flex', alignItems: 'center', gap: 4,
}}>
<Icon name="info" size={11} style={{ color: 'var(--text-muted)' }} />
Typografie (Font/Stil/Höhe): Stempel im Viewport selektieren
Oberleiste.
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--text-muted)',
fontFamily: 'DM Mono, monospace',
paddingTop: 4, borderTop: '1px dashed var(--border)',
}}>
<span>Flaeche: <strong style={{ color: 'var(--text-primary)' }}>
{raum.areaFmt} </strong></span>
<span>Umfang: {fmtNum(raum.umfang)} m</span>
</div>
</div>
)
}
function AussparungProperties({ aussp, onDelete }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="rectangle" size={13}
style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Aussparung · {aussp.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete}
title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--text-muted)',
fontFamily: 'DM Mono, monospace',
paddingTop: 4, borderTop: '1px dashed var(--border)',
}}>
<span>Fläche: {fmtNum(aussp.area)} </span>
<span>Umfang: {fmtNum(aussp.umfang)} m</span>
</div>
<div style={{
fontSize: 9, color: 'var(--text-muted)', fontStyle: 'italic',
lineHeight: 1.4,
}}>
Outline in Rhino editieren (Punkte ziehen, _Reshape, ) die
Eltern-Decke wird automatisch nachgerechnet.
</div>
</div>
)
}
function WallProperties({ wall, geschosse, materials, onUpdate, onDelete }) {
const [dicke, setDicke] = useState(String(wall.dicke))
const [ukOver, setUkOver] = useState(wall.ukOverride)
const [okOver, setOkOver] = useState(wall.okOverride)
useEffect(() => {
setDicke(String(wall.dicke))
setUkOver(wall.ukOverride)
setOkOver(wall.okOverride)
}, [wall.id, wall.dicke, wall.ukOverride, wall.okOverride])
const ukAuto = ukOver === '' || ukOver == null
const okAuto = okOver === '' || okOver == null
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="view_week" size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Wand · {wall.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Geschoss</span>
<select value={wall.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Aufbau</span>
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
<BarToggle label="Solid" active={!wall.layered}
onClick={() => onUpdate({ layered: false })}
title="Eine homogene Wand-Schicht (Standard)" />
<BarToggle label="Mehrschichtig" active={wall.layered}
onClick={() => onUpdate({ layered: true })}
title="Mehrere Schichten mit individuellen Dicken und Farben" />
</div>
</div>
{!wall.layered && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Dicke</span>
<input type="text" value={dicke}
onChange={(e) => setDicke(e.target.value)}
onBlur={() => {
const v = parseFloat(dicke)
if (v > 0 && v !== wall.dicke) onUpdate({ dicke: v })
else setDicke(String(wall.dicke))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
</div>
)}
{wall.layered && (
<LayersEditor layers={wall.layers || []}
materials={materials}
onChange={(layers) => onUpdate({ layers })} />
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Referenz</span>
<ReferenzSelector value={wall.referenz || 'mid'}
onChange={(v) => onUpdate({ referenz: v })} />
</div>
<AutoOverrideField label="UK" auto={ukAuto} autoValue={wall.uk}
rawValue={ukOver} onChangeRaw={setUkOver}
onToggle={() => onUpdate({ ukOverride: ukAuto ? wall.uk : '' })}
onCommit={() => {
if (ukAuto) return
const v = parseFloat(ukOver)
if (!Number.isNaN(v)) onUpdate({ ukOverride: v })
}} />
<AutoOverrideField label="OK" auto={okAuto} autoValue={wall.ok}
rawValue={okOver} onChangeRaw={setOkOver}
onToggle={() => onUpdate({ okOverride: okAuto ? wall.ok : '' })}
onCommit={() => {
if (okAuto) return
const v = parseFloat(okOver)
if (!Number.isNaN(v)) onUpdate({ okOverride: v })
}} />
</div>
)
}
function LayersEditor({ layers, onChange, materials }) {
const total = (layers || []).reduce((s, l) => s + (parseFloat(l.dicke) || 0), 0)
const updateAt = (idx, patch) => {
const next = (layers || []).map((l, i) => (i === idx ? { ...l, ...patch } : l))
onChange(next)
}
const removeAt = (idx) => {
const next = (layers || []).filter((_, i) => i !== idx)
onChange(next.length ? next : [{ name: 'Schicht 1', dicke: 0.20, color: '#cccccc', material: '' }])
}
const addLayer = () => {
const idx = (layers || []).length + 1
onChange([ ...(layers || []),
{ name: `Schicht ${idx}`, dicke: 0.05, color: '#dddddd', material: '' } ])
}
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 4,
padding: '6px 8px',
background: 'var(--bg-input)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--r)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4,
fontSize: 9, color: 'var(--text-muted)',
letterSpacing: '0.06em', textTransform: 'uppercase',
fontWeight: 600 }}>
<span style={{ flex: 1 }}>Schichten (links rechts)</span>
<span style={{ fontFamily: 'DM Mono, monospace', fontSize: 10,
color: 'var(--text-primary)', textTransform: 'none' }}>
Σ {total.toFixed(3)} m
</span>
</div>
{(layers || []).map((ly, i) => (
<LayerRow key={i} layer={ly}
materials={materials}
onChange={(patch) => updateAt(i, patch)}
onRemove={() => removeAt(i)} />
))}
<div style={{ marginTop: 2 }}>
<BarToggle label="+ Schicht" onClick={addLayer}
title="Neue Schicht unten anfügen" />
</div>
</div>
)
}
function LayerRow({ layer, materials, onChange, onRemove }) {
const [name, setName] = useState(layer.name || '')
const [dicke, setDicke] = useState(String(layer.dicke || 0))
useEffect(() => {
setName(layer.name || '')
setDicke(String(layer.dicke || 0))
}, [layer.name, layer.dicke])
// Wenn ein Material gewaehlt ist, kommt die Farbe von dort — der Color-
// Picker wird ausgegraut und zeigt die Material-Farbe an.
const matList = materials || []
const matName = layer.material || ''
const matDef = matList.find(m => m.name === matName)
const effectiveColor = matDef ? matDef.color : (layer.color || '#cccccc')
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="color"
value={effectiveColor}
onChange={(e) => onChange({ color: e.target.value })}
disabled={!!matDef}
title={matDef
? `Farbe aus Material "${matDef.name}"`
: 'Schicht-Farbe'}
style={{
width: 22, height: 22, padding: 0, border: 'none',
background: 'transparent',
cursor: matDef ? 'not-allowed' : 'pointer',
opacity: matDef ? 0.5 : 1,
}} />
<select value={matName}
onChange={(e) => {
const m = matList.find(x => x.name === e.target.value)
if (m) onChange({ material: m.name, color: m.color })
else onChange({ material: '' })
}}
title="Material aus Bibliothek (steuert Farbe + Section-Hatch)"
style={{ width: 92, fontSize: 10, padding: '2px 4px' }}>
<option value="">(eigene)</option>
{matList.map(m => (
<option key={m.name} value={m.name}>{m.name}</option>
))}
</select>
<input type="text" value={name}
placeholder="Name"
onChange={(e) => setName(e.target.value)}
onBlur={() => { if (name !== layer.name) onChange({ name }) }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 10, padding: '2px 4px' }} />
<input type="text" value={dicke}
onChange={(e) => setDicke(e.target.value)}
onBlur={() => {
const v = parseFloat(dicke)
if (v > 0 && v !== layer.dicke) onChange({ dicke: v })
else setDicke(String(layer.dicke))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ width: 56, fontSize: 10, padding: '2px 4px',
fontFamily: 'DM Mono, monospace' }}
title="Dicke in Metern" />
<button onClick={onRemove} className="btn-icon-sm btn-icon-danger"
title="Schicht entfernen" style={{ width: 18, height: 18 }}>
<Icon name="close" size={10} />
</button>
</div>
)
}
function DeckenProperties({ decke, geschosse, onUpdate, onDelete }) {
const [dicke, setDicke] = useState(String(decke.dicke))
const [ukOver, setUkOver] = useState(decke.ukOverride)
const [okOver, setOkOver] = useState(decke.okOverride)
useEffect(() => {
setDicke(String(decke.dicke))
setUkOver(decke.ukOverride)
setOkOver(decke.okOverride)
}, [decke.id, decke.dicke, decke.ukOverride, decke.okOverride])
const ukAuto = ukOver === '' || ukOver == null
const okAuto = okOver === '' || okOver == null
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="layers" size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Decke · {decke.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Geschoss</span>
<select value={decke.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Dicke</span>
<input type="text" value={dicke}
onChange={(e) => setDicke(e.target.value)}
onBlur={() => {
const v = parseFloat(dicke)
if (v > 0 && v !== decke.dicke) onUpdate({ dicke: v })
else setDicke(String(decke.dicke))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
</div>
<AutoOverrideField label="UK" auto={ukAuto} autoValue={decke.uk}
rawValue={ukOver} onChangeRaw={setUkOver}
onToggle={() => onUpdate({ ukOverride: ukAuto ? decke.uk : '' })}
onCommit={() => {
if (ukAuto) return
const v = parseFloat(ukOver)
if (!Number.isNaN(v)) onUpdate({ ukOverride: v })
}} />
<AutoOverrideField label="OK" auto={okAuto} autoValue={decke.ok}
rawValue={okOver} onChangeRaw={setOkOver}
onToggle={() => onUpdate({ okOverride: okAuto ? decke.ok : '' })}
onCommit={() => {
if (okAuto) return
const v = parseFloat(okOver)
if (!Number.isNaN(v)) onUpdate({ okOverride: v })
}} />
</div>
)
}
function DachProperties({ dach, geschosse, onUpdate, onDelete }) {
const [dicke, setDicke] = useState(String(dach.dicke))
const [neigung, setNeigung] = useState(String(dach.neigung ?? 30))
const [eaveIdx, setEaveIdx] = useState(String(dach.eaveIdx ?? 0))
const [ukOver, setUkOver] = useState(dach.ukOverride)
useEffect(() => {
setDicke(String(dach.dicke))
setNeigung(String(dach.neigung ?? 30))
setEaveIdx(String(dach.eaveIdx ?? 0))
setUkOver(dach.ukOverride)
}, [dach.id, dach.dicke, dach.neigung, dach.eaveIdx, dach.ukOverride])
const dachTyp = dach.dachTyp || 'pult'
const ukAuto = ukOver === '' || ukOver == null
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="roofing" size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
{dachTyp === 'sattel' ? 'Satteldach' : dachTyp === 'walm' ? 'Walmdach' : 'Pultdach'} · {dach.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
{/* Dach-Typ */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Typ</span>
<select value={dachTyp}
onChange={(e) => onUpdate({ dachTyp: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
<option value="pult">Pult Geneigte Fläche, Traufe = 1. Kante</option>
<option value="sattel">Sattel Rechteck-Outline (4 Ecken)</option>
<option value="walm">Walm Rechteck-Outline (4 Ecken)</option>
<option value="mansarde">Mansarde Rechteck-Outline (4 Ecken)</option>
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Geschoss</span>
<select value={dach.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Dicke</span>
<input type="text" value={dicke}
onChange={(e) => setDicke(e.target.value)}
onBlur={() => {
const v = parseFloat(dicke)
if (v > 0 && v !== dach.dicke) onUpdate({ dicke: v })
else setDicke(String(dach.dicke))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Neigung</span>
<input type="text" value={neigung}
onChange={(e) => setNeigung(e.target.value)}
onBlur={() => {
const v = parseFloat(neigung)
if (!Number.isNaN(v) && v >= 0 && v < 90) onUpdate({ neigung: v })
else setNeigung(String(dach.neigung ?? 30))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>°</span>
</div>
{dachTyp === 'pult' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Index der Traufkante (0 = Kante zwischen 1. und 2. Punkt)">
Traufe
</span>
<input type="text" value={eaveIdx}
onChange={(e) => setEaveIdx(e.target.value)}
onBlur={() => {
const v = parseInt(eaveIdx, 10)
if (!Number.isNaN(v) && v >= 0) onUpdate({ eaveIdx: v })
else setEaveIdx(String(dach.eaveIdx ?? 0))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Kante</span>
</div>
)}
{/* Mansarde-spezifisch: Variante + untere Neigung + Knick-Hoehe */}
{dachTyp === 'mansarde' && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Variante</span>
<select value={dach.dachVariante || 'walm'}
onChange={(e) => onUpdate({ dachVariante: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
<option value="walm">Walm Knick auf allen 4 Seiten (Pariser Stil)</option>
<option value="giebel">Giebel Knick nur an langen Seiten (DACH-Standard)</option>
<option value="walm_giebel">Walm + Giebel Unten Walm, oben Giebel</option>
</select>
</div>
<MansardeFields dach={dach} onUpdate={onUpdate} />
</>
)}
<AutoOverrideField label="Basis" auto={ukAuto} autoValue={dach.uk}
rawValue={ukOver} onChangeRaw={setUkOver}
onToggle={() => onUpdate({ ukOverride: ukAuto ? dach.uk : '' })}
onCommit={() => {
if (ukAuto) return
const v = parseFloat(ukOver)
if (!Number.isNaN(v)) onUpdate({ ukOverride: v })
}} />
</div>
)
}
function MansardeFields({ dach, onUpdate }) {
const [nu, setNu] = useState(String(dach.neigungUnten ?? 60))
const [kh, setKh] = useState(String(dach.knickH ?? 2.0))
useEffect(() => {
setNu(String(dach.neigungUnten ?? 60))
setKh(String(dach.knickH ?? 2.0))
}, [dach.id, dach.neigungUnten, dach.knickH])
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Untere (steile) Neigung — bis zum Knick">
Steil
</span>
<input type="text" value={nu}
onChange={(e) => setNu(e.target.value)}
onBlur={() => {
const v = parseFloat(nu)
if (!Number.isNaN(v) && v > 0 && v < 90) onUpdate({ neigungUnten: v })
else setNu(String(dach.neigungUnten ?? 60))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>°</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Höhe über Traufe wo der Knick sitzt">
Knick H
</span>
<input type="text" value={kh}
onChange={(e) => setKh(e.target.value)}
onBlur={() => {
const v = parseFloat(kh)
if (!Number.isNaN(v) && v > 0) onUpdate({ knickH: v })
else setKh(String(dach.knickH ?? 2.0))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
</>
)
}
const SIMS_OPTIONS = [
{ code: 'ohne', label: 'ohne' },
{ code: 'schmal', label: 'schmal' },
{ code: 'standard', label: 'standard' },
{ code: 'breit', label: 'breit' },
]
const RAHMEN_POS_OPTIONS = [
{ code: 'aussen', label: 'aussen', hint: 'Rahmen bündig mit Aussenfläche' },
{ code: 'mid', label: 'mittig', hint: 'Rahmen mittig im Wandquerschnitt' },
{ code: 'innen', label: 'innen', hint: 'Rahmen bündig mit Innenfläche' },
]
const OEFF_REFERENZ_OPTIONS = [
{ code: 'links', label: 'Links', hint: 'Klick-Punkt am linken Rand — Öffnung extendiert nach rechts (+tan der Wand-Achse)' },
{ code: 'mid', label: 'Mittig', hint: 'Klick-Punkt mittig in der Öffnung (Standard)' },
{ code: 'rechts', label: 'Rechts', hint: 'Klick-Punkt am rechten Rand — Öffnung extendiert nach links (-tan)' },
]
function SollRow({ label, value, unit, soll, sollKey, onUpdateSoll, readOnly }) {
// soll[sollKey] = [lo, hi, on]
const lo = soll?.[sollKey]?.[0] ?? 0
const hi = soll?.[sollKey]?.[1] ?? 0
const on = soll?.[sollKey]?.[2] ?? true
const inRange = value >= lo && value <= hi
const valueColor = !on ? 'var(--text-muted)'
: inRange ? 'var(--accent)'
: '#c87050'
const [loStr, setLoStr] = useState(String(lo))
const [hiStr, setHiStr] = useState(String(hi))
useEffect(() => {
setLoStr(String(lo))
setHiStr(String(hi))
}, [lo, hi])
const commit = (k, val, setBack, def) => {
const v = parseFloat(val)
if (!Number.isNaN(v) && v > 0) {
const next = [...soll[sollKey]]
next[k] = v
onUpdateSoll({ ...soll, [sollKey]: next })
} else setBack(String(def))
}
const toggle = () => {
const next = [...soll[sollKey]]
next[2] = !next[2]
onUpdateSoll({ ...soll, [sollKey]: next })
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10,
fontFamily: 'DM Mono, monospace' }}>
<input type="checkbox" checked={on} onChange={toggle}
title="Validierung ein/aus"
style={{ margin: 0, cursor: 'pointer' }} />
<span style={{ color: 'var(--text-muted)', width: 30 }}>{label}</span>
<span style={{ color: valueColor, width: 56 }}>
{fmtNum(value)} {unit}
</span>
{readOnly ? (
<span style={{ color: 'var(--text-muted)', marginLeft: 'auto', fontSize: 9 }}>
{fmtNum(lo)}{fmtNum(hi)}
</span>
) : (
<>
<span style={{ color: 'var(--text-muted)', marginLeft: 'auto', fontSize: 9 }}>Soll</span>
<input type="text" value={loStr}
onChange={(e) => setLoStr(e.target.value)}
onBlur={() => commit(0, loStr, setLoStr, lo)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
disabled={!on}
style={{
width: 38, fontSize: 9, fontFamily: 'DM Mono, monospace',
padding: '1px 3px', background: 'transparent',
border: '1px solid var(--border-light)',
color: on ? 'var(--text-primary)' : 'var(--text-muted)',
}} />
<span style={{ color: 'var(--text-muted)', fontSize: 9 }}></span>
<input type="text" value={hiStr}
onChange={(e) => setHiStr(e.target.value)}
onBlur={() => commit(1, hiStr, setHiStr, hi)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
disabled={!on}
style={{
width: 38, fontSize: 9, fontFamily: 'DM Mono, monospace',
padding: '1px 3px', background: 'transparent',
border: '1px solid var(--border-light)',
color: on ? 'var(--text-primary)' : 'var(--text-muted)',
}} />
</>
)}
</div>
)
}
const DEFAULT_TREPPE_SOLL = {
s: [0.15, 0.20, true],
a: [0.21, 0.35, true],
sa: [0.60, 0.65, true],
}
function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
const [breite, setBreite] = useState(String(treppe.breite ?? 1.0))
const [nStufen, setNStufen] = useState(String(treppe.nStufen ?? 15))
const [laufD, setLaufD] = useState(String(treppe.laufD ?? 0.18))
const [hStr, setHStr] = useState('')
useEffect(() => {
setBreite(String(treppe.breite ?? 1.0))
setNStufen(String(treppe.nStufen ?? 15))
setLaufD(String(treppe.laufD ?? 0.18))
}, [treppe.id, treppe.breite, treppe.nStufen, treppe.laufD])
const H = (treppe.ok ?? 0) - (treppe.uk ?? 0)
const N = treppe.nStufen ?? 15
const L = treppe.laufLen ?? 0
const S = N > 0 ? H / N : 0
const A = N > 0 ? L / N : 0
const sa = 2 * S + A
const soll = treppe.soll || DEFAULT_TREPPE_SOLL
const hasHOver = treppe.hOver != null && treppe.hOver !== ''
useEffect(() => {
setHStr(hasHOver ? String(treppe.hOver) : fmtNum(H))
}, [treppe.id, treppe.hOver, H, hasHOver])
const allOK = (
(!soll.s[2] || (S >= soll.s[0] && S <= soll.s[1])) &&
(!soll.a[2] || (A >= soll.a[0] && A <= soll.a[1])) &&
(!soll.sa[2] || (sa >= soll.sa[0] && sa <= soll.sa[1]))
)
const onUpdateSoll = (newSoll) => {
onUpdate({ soll: newSoll })
}
const onCommitH = () => {
const v = parseFloat(hStr)
if (!Number.isNaN(v) && v > 0 && Math.abs(v - H) > 1e-5) {
// User hat H ueberschrieben → Ziel auf "eigene"
onUpdate({ hOver: v, geschossEnd: '' })
}
}
const onClearHOver = () => {
onUpdate({ hOver: '' })
}
const ref = treppe.treppeReferenz ?? 'mid'
const REF_OPTIONS = [
{ code: 'links', label: 'Links' },
{ code: 'mid', label: 'Mittig' },
{ code: 'rechts', label: 'Rechts' },
]
const modus = treppe.treppeModus ?? 'flach'
const MODUS_OPTIONS = [
{ code: 'massiv', label: 'massiv', hint: 'Block bis zum Boden — wie eine Mauer unter der Treppe' },
{ code: 'flach', label: 'flach', hint: 'Schräge Plattenunterseite parallel zum Treppenlauf (realistisch)' },
{ code: 'plattenrand', label: 'gestuft', hint: 'Plattenunterseite folgt den Stufen, vertikal versetzt' },
]
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name="stairs" size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
Treppe · {treppe.geschossName} {treppe.geschossEndName || '(auto)'}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Start</span>
<select value={treppe.geschoss}
onChange={(e) => onUpdate({ geschoss: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
{geschosse.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Ziel</span>
<select
value={hasHOver ? '__custom__' : (treppe.geschossEnd || '')}
onChange={(e) => {
const v = e.target.value
if (v === '__custom__') {
// Eigene Hoehe — falls noch nicht gesetzt, mit aktuellem H starten
onUpdate({ hOver: H, geschossEnd: '' })
} else {
onUpdate({ geschossEnd: v, hOver: '' })
}
}}
style={{ flex: 1, fontSize: 11 }}>
<option value="">(auto: Start + Höhe)</option>
{geschosse.filter(g => g.id !== treppe.geschoss)
.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
<option value="__custom__">eigene Höhe</option>
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Breite</span>
<input type="text" value={breite}
onChange={(e) => setBreite(e.target.value)}
onBlur={() => {
const v = parseFloat(breite)
if (!Number.isNaN(v) && v >= 0.3) onUpdate({ breite: v })
else setBreite(String(treppe.breite))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Stufen</span>
<input type="text" value={nStufen}
onChange={(e) => setNStufen(e.target.value)}
onBlur={() => {
const v = parseInt(nStufen, 10)
if (Number.isFinite(v) && v >= 2 && v <= 40) onUpdate({ nStufen: v })
else setNStufen(String(treppe.nStufen))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>×</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Lage</span>
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
{REF_OPTIONS.map(o => (
<BarToggle key={o.code}
label={o.label}
active={ref === o.code}
onClick={() => onUpdate({ treppeReferenz: o.code })} />
))}
</div>
</div>
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
{/* Unterseite-Modus */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Form der Treppen-Unterseite">
Unten
</span>
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
{MODUS_OPTIONS.map(o => (
<BarToggle key={o.code}
label={o.label}
active={modus === o.code}
onClick={() => onUpdate({ treppeModus: o.code })}
title={o.hint} />
))}
</div>
</div>
{/* Lauf-Plattendicke (nur fuer flach + plattenrand relevant) */}
{modus !== 'massiv' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Dicke der Lauf-Platte (Materialdicke unter den Stufen)">
Platte
</span>
<input type="text" value={laufD}
onChange={(e) => setLaufD(e.target.value)}
onBlur={() => {
const v = parseFloat(laufD)
if (!Number.isNaN(v) && v > 0) onUpdate({ laufD: v })
else setLaufD(String(treppe.laufD))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
)}
{/* Schrittmass-Tabelle: H (editierbar), S, A, 2S+A mit on/off + range */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 3,
padding: '6px 8px', borderRadius: 4,
background: allOK ? 'rgba(95,168,150,0.10)' : 'rgba(200,112,80,0.08)',
border: '1px solid ' + (allOK ? 'rgba(95,168,150,0.4)' : 'rgba(200,112,80,0.35)'),
fontSize: 10, fontFamily: 'DM Mono, monospace',
}}>
{/* H — editierbar; aendert Hoehe und kippt Ziel auf "eigene" */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 16 }} />
<span style={{ color: 'var(--text-muted)', width: 30 }}>H</span>
<input type="text" value={hStr}
onChange={(e) => setHStr(e.target.value)}
onBlur={onCommitH}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{
width: 56, fontSize: 10, fontFamily: 'DM Mono, monospace',
padding: '1px 3px', background: 'transparent',
border: '1px solid ' + (hasHOver ? 'var(--accent)' : 'var(--border-light)'),
color: 'var(--text-primary)',
}} />
<span style={{ color: 'var(--text-muted)' }}>m</span>
{hasHOver && (
<button onClick={onClearHOver}
title="Zurück zu Geschoss-Höhe"
style={{
marginLeft: 'auto', fontSize: 9,
background: 'transparent', border: 'none',
color: 'var(--text-muted)', cursor: 'pointer',
textDecoration: 'underline',
}}>auto</button>
)}
</div>
<SollRow label="S" value={S} unit="m" soll={soll} sollKey="s"
onUpdateSoll={onUpdateSoll} />
<SollRow label="A" value={A} unit="m" soll={soll} sollKey="a"
onUpdateSoll={onUpdateSoll} />
<SollRow label="2S+A" value={sa} unit="m" soll={soll} sollKey="sa"
onUpdateSoll={onUpdateSoll} />
</div>
</div>
)
}
function OeffnungProperties({ oeff, onUpdate, onDelete, 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 (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
padding: 10, marginBottom: 8,
background: 'var(--bg-section)',
borderRadius: 'var(--r-lg)',
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Icon name={icon} size={13} style={{ color: 'var(--accent)', marginRight: 6 }} />
<span style={{ ...labelXs, flex: 1, color: 'var(--accent)' }}>
{label} · {oeff.geschossName}
</span>
<button className="btn-icon-sm btn-icon-danger" onClick={onDelete} title="Löschen">
<Icon name="delete" size={12} />
</button>
</div>
{/* Stil-Picker — Liste passender Styles (gefiltert nach typ) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Stil — gespeicherte Properties-Sets fuer Fenster/Tueren">
Stil
</span>
<div style={{ flex: 1, display: 'flex' }}>
<BarCombo
value={oeff.styleId || ''}
onChange={(v) => {
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">
<option value=""> Eigene Werte </option>
{oeffStyles
.filter(s => s.typ === (isFenster ? 'fenster' : 'tuer'))
.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
<option disabled></option>
<option value="__save__">+ Aktuelle als Stil speichern</option>
{oeff.styleId && <option value="__delete__">🗑 Aktiven Stil loeschen</option>}
</BarCombo>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="SIA-400 Detaillierungsgrad. Einfach=1:100, Standard=1:50, Detail=1:20">
Darstell.
</span>
<div style={{ flex: 1, display: 'flex' }}>
<BarCombo
value={oeff.darstellung || 'auto'}
onChange={(v) => onUpdate({ darstellung: v })}
title="Detaillierungsgrad — Auto folgt der Modelldarstellung in der Topbar">
<option value="auto">Nach Modelldarstellung</option>
<option value="einfach">Einfach (1:100)</option>
<option value="standard">Standard (1:50)</option>
<option value="detail">Detail (1:20)</option>
</BarCombo>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Orientierung — welche Seite der Wand ist aussen. Beim Setzen aus der Click-Richtung erkannt, hier umkehren falls falsch.">
Orient.
</span>
<div style={{ flex: 1, display: 'flex' }}>
<BarToggle label="Umkehren"
onClick={() => onUpdate({ aussenseite:
(oeff.aussenseite || 'rechts') === 'rechts' ? 'links' : 'rechts' })}
title="Aussenseite auf die andere Wandseite umkehren" />
</div>
</div>
{!isFenster && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Tueren-Typ. Wandoeffnung = nur Cutout ohne Schwung-Blatt">
Typ
</span>
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
<BarToggle label="Normal"
active={(oeff.tuerTyp || 'normal') === 'normal'}
onClick={() => onUpdate({ tuerTyp: 'normal' })}
title="Tuere mit Tuerblatt + Schwung-Bogen" />
<BarToggle label="Wandöffnung"
active={(oeff.tuerTyp || 'normal') === 'wandoeffnung'}
onClick={() => onUpdate({ tuerTyp: 'wandoeffnung' })}
title="Nur Wand-Cutout ohne Schwung" />
</div>
</div>
)}
{!isFenster && (oeff.tuerTyp || 'normal') === 'normal' && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Bandseite — welche Tuerflueg-Seite. Im Plan = Scharnier-Position">
Band
</span>
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
<BarToggle label="Links"
active={(oeff.hingeSide || 'links') === 'links'}
onClick={() => onUpdate({ hingeSide: 'links' })} />
<BarToggle label="Rechts"
active={(oeff.hingeSide || 'links') === 'rechts'}
onClick={() => onUpdate({ hingeSide: 'rechts' })} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Oeffnungswinkel im Plan (Grad, 0-180)">
Öffn.
</span>
<input type="text"
value={String(oeff.openAngle ?? 90)}
onChange={(e) => {
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' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>°</span>
<BarToggle label="Umkehren"
onClick={() => onUpdate({ swingInvert: !oeff.swingInvert })}
title="Schwung-Richtung umkehren (nach aussen statt innen)" />
</div>
</>
)}
{!isFenster && (oeff.tuerTyp || 'normal') === 'normal' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Tueren-Rahmen-Typ. Zarge sitzt in der Oeffnung, Blockrahmen sitzt aussen herum">
Rahmen
</span>
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
<BarToggle label="Zarge"
active={(oeff.tuerRahmen || 'zarge') === 'zarge'}
onClick={() => onUpdate({ tuerRahmen: 'zarge' })} />
<BarToggle label="Block"
active={(oeff.tuerRahmen || 'zarge') === 'block'}
onClick={() => onUpdate({ tuerRahmen: 'block' })} />
</div>
</div>
)}
{!isFenster && (oeff.tuerTyp || 'normal') === 'normal' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Sturzlinien-Anzeige bei 1:100 (gestrichelt). Aussen = Linie an Wand-Aussenseite, Innen = Wand-Innenseite, Beide = beide Linien">
Sturz
</span>
<select
value={oeff.sturz || 'beide'}
onChange={(e) => onUpdate({ sturz: e.target.value })}
style={{ flex: 1, fontSize: 11 }}>
<option value="keine">Keine</option>
<option value="innen">Innen</option>
<option value="aussen">Aussen</option>
<option value="beide">Beide</option>
</select>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Breite</span>
<input type="text" value={breite}
onChange={(e) => setBreite(e.target.value)}
onBlur={() => commit('breite', breite, setBreite, oeff.breite)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Höhe</span>
<input type="text" value={hoehe}
onChange={(e) => setHoehe(e.target.value)}
onBlur={() => commit('hoehe', hoehe, setHoehe, oeff.hoehe)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title={isFenster
? 'Brüstungshöhe über UK der Wand'
: 'Türschwelle / Höhe über UK der Wand'}>
{isFenster ? 'Brüst.' : 'Schw.'}
</span>
<input type="text" value={brueest}
onChange={(e) => setBrueest(e.target.value)}
onBlur={() => {
const v = parseFloat(brueest)
if (!Number.isNaN(v) && v >= 0) onUpdate({ brueest: v })
else setBrueest(String(oeff.brueest))
}}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
{/* Referenz-Lage: wo sitzt der Klick-Punkt in der Oeffnung */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Lage des Klick-Punkts in der Öffnung">
Ref
</span>
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
{OEFF_REFERENZ_OPTIONS.map(o => (
<BarToggle key={o.code}
label={o.label}
active={(oeff.oeffReferenz || 'mid') === o.code}
onClick={() => onUpdate({ oeffReferenz: o.code })}
title={o.hint} />
))}
</div>
</div>
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
{/* Rahmen-Profil (Breite × Tiefe) — beide Felder gleich breit */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Rahmen-Profil: Breite (in Wandflaeche) × Tiefe (entlang Wandnormale)">
Rahmen
</span>
<input type="text" value={rahmenB}
onChange={(e) => setRahmenB(e.target.value)}
onBlur={() => commit('rahmenB', rahmenB, setRahmenB, oeff.rahmenB)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
title="Profil-Breite (sichtbar in der Fassade)"
style={{ flex: '1 1 0', minWidth: 0, width: 0, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>×</span>
<input type="text" value={rahmenTiefe}
onChange={(e) => setRahmenTiefe(e.target.value)}
onBlur={() => commit('rahmenTiefe', rahmenTiefe, setRahmenTiefe, oeff.rahmenTiefe)}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
title="Rahmen-Tiefe (Lage in der Wand)"
style={{ flex: '1 1 0', minWidth: 0, width: 0, fontSize: 11, fontFamily: 'DM Mono, monospace' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m</span>
</div>
{/* Rahmen-Lage: Abstand der Rahmen-Innenkante von der Wand-Innenseite */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Abstand der Rahmen-Innenkante von der Wand-Innenseite (Aussenseite-Flag oben bestimmt welche Seite innen ist)">
Lage
</span>
<input type="text"
value={String(oeff.rahmenOffset ?? 0.05)}
onChange={(e) => {
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' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>m v. innen</span>
</div>
{/* Fluegel-Anzahl — nur fuer Fenster (Tueren haben ein einzelnes Tuerblatt) */}
{isFenster && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Anzahl Flügel (vertikale Unterteilung)">
Flügel
</span>
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
{[1, 2, 3, 4].map(n => (
<BarToggle key={n}
label={String(n)}
active={fluegel === n}
onClick={() => onUpdate({ fluegel: n })} />
))}
</div>
</div>
)}
{/* Sims — nur aussen. Innen gibt's bewusst nicht. Dient zugleich
als visueller Indikator fuer die Aussenseite-Einstellung. */}
{isFenster && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}
title="Aussensims — Platte unter Öffnung, ragt aussen heraus">
Sims
</span>
<div style={{ flex: 1, display: 'flex' }}>
<BarCombo
value={simsAus}
onChange={(v) => onUpdate({ simsAus: v })}
title="Sims-Stil">
{SIMS_OPTIONS.map(o =>
<option key={o.code} value={o.code}>{o.label}</option>)}
</BarCombo>
</div>
</div>
)}
{/* Glas-Toggle: bei Tueren ersetzt Glas das Tuerblatt (verglaste Tuer) */}
<div style={{ display: 'flex', gap: 4 }}>
<BarToggle
label={(isFenster ? 'Glas' : 'Verglast') + (oeff.glas ? ' ✓' : '')}
active={!!oeff.glas}
onClick={() => onUpdate({ glas: !oeff.glas })}
title={isFenster ? 'Glasscheibe sichtbar' : 'Verglaste Tür (statt Türblatt)'} />
</div>
</div>
)
}
// Wiederverwendete UI fuer UK/OK-Felder mit Auto/Custom-Toggle
function AutoOverrideField({ label, auto, autoValue, rawValue, onChangeRaw, onToggle, onCommit }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>{label}</span>
<BarToggle label={auto ? 'Auto' : 'Custom'} active={auto}
onClick={onToggle}
title={auto ? 'Folgt Geschoss' : 'Eigener Wert'} />
<input type="text"
value={auto ? fmtNum(autoValue) : rawValue}
disabled={auto}
onChange={(e) => onChangeRaw(e.target.value)}
onBlur={onCommit}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
style={{ flex: 1, fontSize: 11, fontFamily: 'DM Mono, monospace',
opacity: auto ? 0.6 : 1 }} />
</div>
)
}