961b3c0396
Stable working state after a long iteration session. The plugin now supports:
- Multi-Surface-Select für alle Element-Typen (Türen/Fenster/Treppen/Tragwerk)
- Wand-Z-Drag → unbound mode (UK/OK-Override, Wand vom Geschoss entkoppelt)
- Wand-Z-Drag nimmt verknüpfte Öffnungen mit (Brüstung += delta_z via Idle-Pfad)
- Öffnungs-XY-Drag snapt direktional auf Wand-Tangente
- Öffnungs-Z-Drag passt Brüstung an (Fenster sofort sync, Tür deferred)
- Wand-Delete kaskadiert Öffnungen (deferred via Idle, robust gegen _Rotate/_Move)
- Source-Cascade beim Öffnungs-Delete (deferred analog Wand-Kaskade)
- Listener-Cleanup robust gegen _reset_panels.py Reload (Refs in
_dossier_runtime_event_refs gespeichert, vor Re-Install deregistriert)
- _count_same_id_type filtert IsDeleted (verhindert Source-Duplikat-Bug bei Move)
- Frontend: Brüstungs-Slider für Tür ("Schwelle"), Flügel-Block nur bei Fenster
Plus aus früherer Phase dieser Session:
- Dossier-Launcher Auto-Load via Rhinos StartupCommands-XML
- Default-Pfad zeigt auf gebundeltes startup.py (out-of-the-box für neue User)
- Splash-Window beim Plugin-Load mit native macOS rounded corners
- Diverse Launcher-Verbesserungen (Brüstungs-Default, tauri.conf, capabilities)
Known issue: bei Multi-Select-Move mit vielen Sub-Volumen kann sporadisch
"Unable to transform" auftreten (Rhinos Move-Operation kollidiert mit Wand-
Regen). Tür-spezifischer Defer-Pfad mildert das, Fenster läuft sync.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1995 lines
81 KiB
React
1995 lines
81 KiB
React
import { useEffect, useRef, useState } from 'react'
|
||
import Icon from './components/Icon'
|
||
import {
|
||
onMessage, notifyReady,
|
||
listElemente, createWall, createDecke, createDach,
|
||
createFenster, createTuer, createAussparung, createTreppe,
|
||
createStuetze, createTraeger, createRaum,
|
||
exportRaeume,
|
||
updateElement, deleteElement, regenerateAllElements,
|
||
} from './lib/rhinoBridge'
|
||
|
||
const labelXs = {
|
||
fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||
letterSpacing: '0.06em', textTransform: 'uppercase',
|
||
}
|
||
|
||
function fmtNum(v) {
|
||
if (v == null || v === '') return ''
|
||
const n = Number(v)
|
||
if (Number.isNaN(n)) return String(v)
|
||
return Number(n.toFixed(4)).toString()
|
||
}
|
||
|
||
function ReferenzSelector({ value, onChange }) {
|
||
const opts = [
|
||
{ code: 'left', label: 'Links', hint: 'Achse auf linker Aussenseite' },
|
||
{ code: 'mid', label: 'Mittig', hint: 'Achse zentriert (Standard)' },
|
||
{ code: 'right', label: 'Rechts', hint: 'Achse auf rechter Aussenseite' },
|
||
]
|
||
return (
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{opts.map(o => (
|
||
<button
|
||
key={o.code}
|
||
onClick={() => onChange(o.code)}
|
||
className={value === o.code ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
|
||
title={o.hint}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</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,
|
||
padding: 10,
|
||
background: 'var(--bg-section)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 'var(--r-lg)',
|
||
}}>
|
||
<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)} 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 (
|
||
<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 }) {
|
||
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,
|
||
padding: 10, marginBottom: 8,
|
||
background: 'var(--bg-section)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 'var(--r-lg)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10 }}>
|
||
<span style={labelXs}>Neues Element</span>
|
||
<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>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
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,
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
padding: '8px 10px', borderBottom: '1px solid var(--border)',
|
||
flexShrink: 0,
|
||
}}>
|
||
<span style={{ flex: 1, fontWeight: 600, letterSpacing: '0.08em' }}>ELEMENTE</span>
|
||
<span className="chip" style={{ fontSize: 8 }}>{elements.length}</span>
|
||
<button
|
||
onClick={() => exportRaeume()}
|
||
className="btn-icon-tonal"
|
||
disabled={!elements.some(e => e.kind === 'raum')}
|
||
title="Raumliste als CSV exportieren"
|
||
>
|
||
<Icon name="download" size={14} />
|
||
</button>
|
||
<button
|
||
onClick={() => regenerateAllElements()}
|
||
className="btn-icon-tonal"
|
||
disabled={elements.length === 0}
|
||
title="Alle Elemente neu generieren (z.B. nach Geschoss-Änderung)"
|
||
>
|
||
<Icon name="sync" size={14} />
|
||
</button>
|
||
<button
|
||
onClick={() => listElemente()}
|
||
className="btn-icon-tonal"
|
||
title="Aktualisieren"
|
||
>
|
||
<Icon name="refresh" size={14} />
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: 8 }}>
|
||
{/* Element-Toolbar: kategorisierte Pills */}
|
||
<NeuesElementSection
|
||
noGeschoss={noGeschoss}
|
||
activeName={activeName}
|
||
/>
|
||
|
||
|
||
{/* Properties */}
|
||
{selected ? (
|
||
selected.kind === 'wand' ? (
|
||
<WallProperties wall={selected} geschosse={geschosse}
|
||
materials={state.materials || []}
|
||
onUpdate={(p) => updateElement(selected.id, p)}
|
||
onDelete={() => { if (window.confirm('Wand löschen?')) deleteElement(selected.id) }} />
|
||
) : selected.kind === 'decke' ? (
|
||
<DeckenProperties decke={selected} geschosse={geschosse}
|
||
onUpdate={(p) => updateElement(selected.id, p)}
|
||
onDelete={() => { if (window.confirm('Decke löschen?')) deleteElement(selected.id) }} />
|
||
) : selected.kind === 'dach' ? (
|
||
<DachProperties dach={selected} geschosse={geschosse}
|
||
onUpdate={(p) => updateElement(selected.id, p)}
|
||
onDelete={() => { if (window.confirm('Dach löschen?')) deleteElement(selected.id) }} />
|
||
) : selected.kind === 'treppe' ? (
|
||
<TreppeProperties treppe={selected} geschosse={geschosse}
|
||
onUpdate={(p) => updateElement(selected.id, p)}
|
||
onDelete={() => { if (window.confirm('Treppe löschen?')) deleteElement(selected.id) }} />
|
||
) : (selected.kind === 'stuetze' || selected.kind === 'traeger') ? (
|
||
<TragwerkProperties el={selected}
|
||
onUpdate={(p) => updateElement(selected.id, p)}
|
||
onDelete={() => {
|
||
const lbl = (KIND_META[selected.kind] || {}).label || 'Element'
|
||
if (window.confirm(`${lbl} löschen?`)) deleteElement(selected.id)
|
||
}} />
|
||
) : selected.kind === 'raum' ? (
|
||
<RaumProperties raum={selected} geschosse={geschosse}
|
||
hatchPatterns={state.hatchPatterns}
|
||
onUpdate={(p) => updateElement(selected.id, p)}
|
||
onDelete={() => { if (window.confirm('Raum löschen?')) deleteElement(selected.id) }} />
|
||
) : selected.kind === 'aussparung' ? (
|
||
<AussparungProperties aussp={selected}
|
||
onDelete={() => { if (window.confirm('Aussparung löschen?')) deleteElement(selected.id) }} />
|
||
) : (
|
||
<OeffnungProperties oeff={selected}
|
||
onUpdate={(p) => updateElement(selected.id, p)}
|
||
onDelete={() => {
|
||
const label = selected.kind === 'fenster' ? 'Fenster' : 'Tür'
|
||
if (window.confirm(`${label} löschen?`)) deleteElement(selected.id)
|
||
}} />
|
||
)
|
||
) : (
|
||
<div style={{
|
||
padding: '20px 16px', textAlign: 'center',
|
||
color: 'var(--text-muted)', fontSize: 11,
|
||
border: '1px dashed var(--border)',
|
||
borderRadius: 'var(--r-lg)',
|
||
background: 'var(--bg-section)',
|
||
marginBottom: 8,
|
||
}}>
|
||
<Icon name="touch_app" size={24} style={{ color: 'var(--text-muted)', opacity: 0.5 }} />
|
||
<div style={{ marginTop: 6 }}>Kein Element selektiert.</div>
|
||
<div style={{ marginTop: 4, fontSize: 10 }}>
|
||
Eine Wand-Achse oder Decken-Outline in Rhino auswählen.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Liste aller Elemente */}
|
||
{elements.length > 0 && (
|
||
<ElementList elements={elements} />
|
||
)}
|
||
</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)',
|
||
border: '1px solid var(--accent)',
|
||
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-muted)', 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-muted)', 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>
|
||
)
|
||
}
|
||
|
||
function RaumProperties({ raum, geschosse, onUpdate, onDelete, hatchPatterns }) {
|
||
const [name, setName] = useState(raum.name || 'Raum')
|
||
const [nummer, setNummer] = useState(raum.nummer || '')
|
||
const [txtH, setTxtH] = useState(String(raum.txtH || 0.20))
|
||
useEffect(() => {
|
||
setName(raum.name || 'Raum')
|
||
setNummer(raum.nummer || '')
|
||
setTxtH(String(raum.txtH || 0.20))
|
||
}, [raum.id, raum.name, raum.nummer, raum.txtH])
|
||
|
||
// Aktueller Wert von raum_fuellung: "" | "Solid" | "Hatch1" | … | "ByLayer"
|
||
const fuell = raum.fuellung || ''
|
||
const patternList = hatchPatterns || []
|
||
|
||
return (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column', gap: 8,
|
||
padding: 10, marginBottom: 8,
|
||
background: 'var(--bg-section)',
|
||
border: '1px solid var(--accent)',
|
||
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>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-muted)', 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-muted)', 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-muted)', 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-muted)', 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-muted)', 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-muted)', width: 60 }}>Rundung</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{RAUM_RUNDUNGEN.map(r => (
|
||
<button key={r}
|
||
onClick={() => onUpdate({ rundung: r })}
|
||
className={raum.rundung === r ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 4px', fontSize: 10 }}
|
||
title={r === 'exakt' ? '2 Nachkommastellen, ohne Rundung'
|
||
: r === '0.01' ? 'auf 0.01 m²'
|
||
: r === '0.1' ? 'auf 0.1 m²'
|
||
: r === '0.5' ? 'auf 0.5 m²'
|
||
: 'auf ganze m²'}>
|
||
{r}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Ausrichtung</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{RAUM_ALIGN.map(a => (
|
||
<button key={a.code}
|
||
onClick={() => onUpdate({ align: a.code })}
|
||
className={(raum.align || 'mid') === a.code ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 4px', fontSize: 10,
|
||
display: 'flex', alignItems: 'center',
|
||
justifyContent: 'center', gap: 3 }}
|
||
title={a.label}>
|
||
<Icon name={a.icon} size={12} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 60 }}>Texthöhe</span>
|
||
<input type="text" value={txtH}
|
||
onChange={(e) => setTxtH(e.target.value)}
|
||
onBlur={() => {
|
||
const v = parseFloat(txtH)
|
||
if (v > 0 && v !== raum.txtH) onUpdate({ txtH: v })
|
||
else setTxtH(String(raum.txtH))
|
||
}}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
|
||
style={{ flex: 1, fontSize: 11,
|
||
fontFamily: 'DM Mono, monospace' }} />
|
||
</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} m²</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)',
|
||
border: '1px solid var(--accent)',
|
||
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)} m²</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)',
|
||
border: '1px solid var(--accent)',
|
||
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-muted)', 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-muted)', width: 50 }}>Aufbau</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
<button
|
||
onClick={() => onUpdate({ layered: false })}
|
||
className={!wall.layered ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
|
||
title="Eine homogene Wand-Schicht (Standard)">Solid</button>
|
||
<button
|
||
onClick={() => onUpdate({ layered: true })}
|
||
className={wall.layered ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
|
||
title="Mehrere Schichten mit individuellen Dicken und Farben"
|
||
>Mehrschichtig</button>
|
||
</div>
|
||
</div>
|
||
|
||
{!wall.layered && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-muted)', 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-muted)', 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)} />
|
||
))}
|
||
<button onClick={addLayer} className="btn-outlined"
|
||
style={{ padding: '3px 6px', fontSize: 10, marginTop: 2 }}
|
||
title="Neue Schicht unten anfügen">
|
||
+ Schicht
|
||
</button>
|
||
</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)',
|
||
border: '1px solid var(--accent)',
|
||
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-muted)', 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-muted)', 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)',
|
||
border: '1px solid var(--accent)',
|
||
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-muted)', width: 50 }}>Typ</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{[
|
||
{ code: 'pult', label: 'Pult' },
|
||
{ code: 'sattel', label: 'Sattel' },
|
||
{ code: 'walm', label: 'Walm' },
|
||
{ code: 'mansarde', label: 'Mansarde' },
|
||
].map(o => (
|
||
<button key={o.code}
|
||
onClick={() => onUpdate({ dachTyp: o.code })}
|
||
className={dachTyp === o.code ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 4px', fontSize: 9 }}
|
||
title={o.code === 'pult'
|
||
? 'Geneigte Fläche, Traufe = 1. Kante'
|
||
: 'Erfordert Rechteck-Outline (4 Ecken)'}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-muted)', 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-muted)', 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-muted)', 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-muted)', 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-muted)', width: 50 }}>Variante</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{[
|
||
{ code: 'walm', label: 'Walm', hint: 'Knick auf allen 4 Seiten (Pariser Stil)' },
|
||
{ code: 'giebel', label: 'Giebel', hint: 'Knick nur an langen Seiten, Schmalseiten als Giebelwand (DACH-Standard)' },
|
||
{ code: 'walm_giebel', label: 'W-G', hint: 'Unten Walm (Knick rundum), oben Giebel mit First über voller Länge' },
|
||
].map(o => (
|
||
<button key={o.code}
|
||
onClick={() => onUpdate({ dachVariante: o.code })}
|
||
className={(dach.dachVariante || 'walm') === o.code ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
|
||
title={o.hint}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</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-muted)', 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-muted)', 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)',
|
||
border: '1px solid var(--accent)',
|
||
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-muted)', 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-muted)', 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-muted)', 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-muted)', 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-muted)', width: 50 }}>Lage</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{REF_OPTIONS.map(o => (
|
||
<button key={o.code}
|
||
onClick={() => onUpdate({ treppeReferenz: o.code })}
|
||
className={ref === o.code ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</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-muted)', width: 50 }}
|
||
title="Form der Treppen-Unterseite">
|
||
Unten
|
||
</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{MODUS_OPTIONS.map(o => (
|
||
<button key={o.code}
|
||
onClick={() => onUpdate({ treppeModus: o.code })}
|
||
className={modus === o.code ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
|
||
title={o.hint}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</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-muted)', 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 }) {
|
||
const isFenster = oeff.kind === 'fenster'
|
||
const label = isFenster ? 'Fenster' : 'Tür'
|
||
const icon = isFenster ? 'window' : 'sensor_door'
|
||
const [breite, setBreite] = useState(String(oeff.breite ?? (isFenster ? 1.2 : 0.9)))
|
||
const [hoehe, setHoehe] = useState(String(oeff.hoehe ?? (isFenster ? 1.4 : 2.1)))
|
||
const [brueest, setBrueest] = useState(String(oeff.brueest ?? 0.9))
|
||
const [rahmenB, setRahmenB] = useState(String(oeff.rahmenB ?? 0.06))
|
||
const [rahmenTiefe, setRahmenTiefe] = useState(String(oeff.rahmenTiefe ?? 0.08))
|
||
useEffect(() => {
|
||
setBreite(String(oeff.breite ?? (isFenster ? 1.2 : 0.9)))
|
||
setHoehe(String(oeff.hoehe ?? (isFenster ? 1.4 : 2.1)))
|
||
setBrueest(String(oeff.brueest ?? 0.9))
|
||
setRahmenB(String(oeff.rahmenB ?? 0.06))
|
||
setRahmenTiefe(String(oeff.rahmenTiefe ?? 0.08))
|
||
}, [oeff.id, oeff.breite, oeff.hoehe, oeff.brueest, oeff.rahmenB,
|
||
oeff.rahmenTiefe, isFenster])
|
||
|
||
const commit = (key, val, setBack, def) => {
|
||
const v = parseFloat(val)
|
||
if (!Number.isNaN(v) && v > 0) onUpdate({ [key]: v })
|
||
else setBack(String(def))
|
||
}
|
||
|
||
const fluegel = oeff.fluegel ?? 1
|
||
const rahmenPos = oeff.rahmenPos ?? 'mid'
|
||
const simsAus = oeff.simsAus ?? 'ohne'
|
||
const simsIn = oeff.simsIn ?? 'ohne'
|
||
|
||
return (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column', gap: 8,
|
||
padding: 10, marginBottom: 8,
|
||
background: 'var(--bg-section)',
|
||
border: '1px solid var(--accent)',
|
||
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>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-muted)', 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-muted)', 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-muted)', 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-muted)', width: 50 }}
|
||
title="Lage des Klick-Punkts in der Öffnung">
|
||
Ref
|
||
</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{OEFF_REFERENZ_OPTIONS.map(o => (
|
||
<button key={o.code}
|
||
onClick={() => onUpdate({ oeffReferenz: o.code })}
|
||
className={(oeff.oeffReferenz || 'mid') === o.code ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
|
||
title={o.hint}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</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-muted)', 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 im Wandquerschnitt */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
|
||
title="Lage des Rahmens im Wandquerschnitt">
|
||
Lage
|
||
</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{RAHMEN_POS_OPTIONS.map(o => (
|
||
<button key={o.code}
|
||
onClick={() => onUpdate({ rahmenPos: o.code })}
|
||
className={rahmenPos === o.code ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
|
||
title={o.hint}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</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-muted)', width: 50 }}
|
||
title="Anzahl Flügel (vertikale Unterteilung)">
|
||
Flügel
|
||
</span>
|
||
<div style={{ flex: 1, display: 'flex', gap: 2 }}>
|
||
{[1, 2, 3, 4].map(n => (
|
||
<button key={n}
|
||
onClick={() => onUpdate({ fluegel: n })}
|
||
className={fluegel === n ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}>
|
||
{n}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Sims-Stile (aussen / innen) — nur fuer Fenster */}
|
||
{isFenster && (
|
||
<>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
|
||
title="Aussensims — Platte unter Öffnung, ragt aussen heraus">
|
||
Sims a.
|
||
</span>
|
||
<select value={simsAus}
|
||
onChange={(e) => onUpdate({ simsAus: e.target.value })}
|
||
style={{ flex: 1, fontSize: 11 }}>
|
||
{SIMS_OPTIONS.map(o =>
|
||
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 10, color: 'var(--text-muted)', width: 50 }}
|
||
title="Innensims — Platte unter Öffnung, ragt innen heraus">
|
||
Sims i.
|
||
</span>
|
||
<select value={simsIn}
|
||
onChange={(e) => onUpdate({ simsIn: e.target.value })}
|
||
style={{ flex: 1, fontSize: 11 }}>
|
||
{SIMS_OPTIONS.map(o =>
|
||
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||
</select>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Glas-Toggle: bei Tueren ersetzt Glas das Tuerblatt (verglaste Tuer) */}
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<button
|
||
onClick={() => onUpdate({ glas: !oeff.glas })}
|
||
className={oeff.glas ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ flex: 1, padding: '3px 6px', fontSize: 10 }}
|
||
title={isFenster ? 'Glasscheibe sichtbar' : 'Verglaste Tür (statt Türblatt)'}>
|
||
{isFenster ? 'Glas' : 'Verglast'} {oeff.glas ? '✓' : ''}
|
||
</button>
|
||
</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-muted)', width: 50 }}>{label}</span>
|
||
<button onClick={onToggle}
|
||
className={auto ? 'btn-contained' : 'btn-outlined'}
|
||
style={{ padding: '3px 8px', fontSize: 10 }}
|
||
title={auto ? 'Folgt Geschoss' : 'Eigener Wert'}>
|
||
{auto ? 'Auto' : 'Custom'}
|
||
</button>
|
||
<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>
|
||
)
|
||
}
|