Treppen UX-Polish: Start-Z, Trittmass-Lock, Pfeil-Stile, Grips

Properties-Panel:
- Konsistentes 50px/1fr/14px Grid fuer alle Treppen-Rows
- Lage + Unten als Dropdown (lowercase Labels)
- Versatz: Dropdown (Geschoss-OKFF) oder eigenes Z mit Input + x-Button
- Ziel: gleich (Geschoss-Liste oder eigene Hoehe), Geschosse-Filter
  excludes das Start-Geschoss
- Start-Dropdown filtert auf okff < Ziel-Z (kein hoeheres Geschoss als
  Start waehlbar, beachtet auch eigene-Hoehe-Ziel)
- Stufen: Dropdown 2-40 (statt freie Eingabe), mit Lock nur S-konforme
  Werte
- Dropdowns nutzen System-Font (statt mono)
- Ausgrenzung 'Aussenlinie'-Toggle (Aussenlinie immer an)
- Pfeil-Style-Dropdown unter Lauflinie-Checkbox: klassisch / gefuellt
  (Solid-Hatch) / breit / voll (Spitzen bis Treppen-Aussenkanten)

Backend Treppe:
- Start-Z-Override via treppe_uk_over (m Offset relativ zu Geschoss-OKFF)
- 2D-Symbol bleibt auf OKFF (egal ob Versatz) — Symbol klebt am Boden
- Lauflinie-Schaft auf visuellen Treppen-Mittelpunkt versetzt
  (bei Lage=links/rechts), nicht mehr auf der Referenz-Achse
- Trittmass-Lock: treppe_lock_s + target_S/A. Beim Aktivieren werden
  S+A als Ziel gespeichert. Bei H-Change wird N=round(H/target_S)
  recomputed + Axis-Laenge auf N*target_A angepasst (gerade Treppen)
- Bruchsymbol-Toggle aus: ganze Treppe ungesplittet zeichnen
  (eff_cut_h=0 → kein Lower/Upper-Split)
- Treppen-Endpunkt-Marker (treppe_grips.py) — gruene Punkte an Start/
  Ende der Lauflinie, beachtet treppe_art (Wendel: poly[1]/poly[2])

Verdoppelungs-Fix:
- _find_target_volume skipt treppe_2d_symbol explicit (sind 2D-Curves,
  kein Volume). Vorher konnte Replace(curve, brep) fehlschlagen → das
  echte Treppen-Brep blieb stehen + neues kam dazu → Duplikat
- _find_objects_by_wall_id mit HiddenObjects+LockedObjects-Iterator,
  findet auch Objs auf hidden 3D-Layer
- Anti-Dup-Cleanup in _regenerate_element: bei mehreren treppe_volume
  mit gleicher element_id → alle ausser dem ersten loeschen

State-Pipeline:
- geschosse-Liste enthaelt jetzt okff+hoehe (fuer Frontend-Constraints)
- Treppe-State neu: ukOver, arrowStyle, lockS, targetS, targetA
- Hidden-Source-Fallback in _send_state findet auch Treppen wenn der
  3D-Layer aus ist (sodass Properties-Panel angezeigt wird)

Dimensionen-Panel:
- on_select + on_idle skippen waehrend Partnership-Cascade oder
  User-Transform — kein Flicker mehr beim Drag

Andere:
- Wand-Polyline-Vertex-Grips (alle Vertices, nicht nur Enden)
- PopupMenu unterstuetzt _divider + checked-Items
- TREPPEN/RAEUME Layer-Migration auf Capital-Case
- selection-partnership tolerant: hidden Source wird trotzdem in die
  Selection genommen (sonst kann Drag nicht durch Pure-Transform)
This commit is contained in:
2026-05-28 02:09:38 +02:00
parent bcf7d557b1
commit 970281e10a
3 changed files with 696 additions and 168 deletions
+230 -93
View File
@@ -2035,6 +2035,7 @@ function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
const [nStufen, setNStufen] = useState(String(treppe.nStufen ?? 15))
const [laufD, setLaufD] = useState(String(treppe.laufD ?? 0.18))
const [hStr, setHStr] = useState('')
const [ukStr, setUkStr] = useState('')
useEffect(() => {
setBreite(String(treppe.breite ?? 1.0))
setNStufen(String(treppe.nStufen ?? 15))
@@ -2049,9 +2050,27 @@ function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
const sa = 2 * S + A
const soll = treppe.soll || DEFAULT_TREPPE_SOLL
const hasHOver = treppe.hOver != null && treppe.hOver !== ''
const hasUkOver = treppe.ukOver != null && treppe.ukOver !== ''
useEffect(() => {
setHStr(hasHOver ? String(treppe.hOver) : fmtNum(H))
}, [treppe.id, treppe.hOver, H, hasHOver])
useEffect(() => {
setUkStr(hasUkOver ? String(treppe.ukOver) : '')
}, [treppe.id, treppe.ukOver, hasUkOver])
const onCommitUk = () => {
const trimmed = (ukStr || '').trim()
if (trimmed === '') {
if (hasUkOver) onUpdate({ ukOver: '' })
return
}
const v = parseFloat(trimmed)
if (Number.isNaN(v)) { setUkStr(hasUkOver ? String(treppe.ukOver) : ''); return }
if (Math.abs(v) < 1e-6) {
if (hasUkOver) onUpdate({ ukOver: '' })
} else if (Math.abs(v - (parseFloat(treppe.ukOver) || 0)) > 1e-5) {
onUpdate({ ukOver: v })
}
}
const allOK = (
(!soll.s[2] || (S >= soll.s[0] && S <= soll.s[1])) &&
@@ -2075,17 +2094,36 @@ function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
const ref = treppe.treppeReferenz ?? 'mid'
const REF_OPTIONS = [
{ code: 'links', label: 'Links' },
{ code: 'mid', label: 'Mittig' },
{ code: 'rechts', label: 'Rechts' },
{ 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' },
{ 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' },
{ code: 'plattenrand', label: 'gestuft', hint: 'Plattenunterseite folgt den Stufen, vertikal versetzt' },
]
// Konsistentes Grid: label(50) | control(1fr) | unit(14)
const rowStyle = {
display: 'grid',
gridTemplateColumns: '50px 1fr 14px',
alignItems: 'center', gap: 6,
}
const labelStyle = {
fontSize: 10, color: 'var(--text-secondary)',
}
const unitStyle = {
fontSize: 10, color: 'var(--text-muted)', textAlign: 'left',
}
const inputStyle = {
fontSize: 11, fontFamily: 'DM Mono, monospace', width: '100%',
}
const selectStyle = {
fontSize: 11, width: '100%', // Dropdowns nutzen System-Font (lesbar bei Worten)
}
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
@@ -2103,38 +2141,105 @@ function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Start</span>
<div style={rowStyle}>
<span style={labelStyle}>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={selectStyle}
title="Start-Geschoss — kann nicht hoeher als das Ziel-Geschoss sein">
{(() => {
// Ziel-Z bestimmen: aus Ziel-Geschoss oder aus hOver+startOkff+ukOver
let zielZ = null
if (treppe.geschossEnd) {
const g = geschosse.find(x => x.id === treppe.geschossEnd)
if (g) zielZ = Number(g.okff || 0)
} else if (treppe.hOver) {
const startG = geschosse.find(x => x.id === treppe.geschoss)
const startOkff = startG ? Number(startG.okff || 0) : 0
const ukO = Number(treppe.ukOver || 0)
zielZ = startOkff + ukO + Number(treppe.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>
return geschosse
.filter(g => zielZ === null || Number(g.okff || 0) < zielZ)
.map(g => <option key={g.id} value={g.id}>{g.name}</option>)
})()}
</select>
<span style={unitStyle}></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 10, color: 'var(--text-secondary)', width: 50 }}>Breite</span>
<div style={rowStyle}
title="Vertikaler Versatz des Treppen-Anfangs (relativ zum Geschoss-OKFF)">
<span style={labelStyle}>Versatz</span>
{hasUkOver ? (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={ukStr}
placeholder="0.00"
onChange={(e) => setUkStr(e.target.value)}
onBlur={onCommitUk}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
title="Versatz relativ zum Geschoss-OKFF"
style={{ ...inputStyle, flex: 1,
border: '1px solid var(--accent)' }} />
<button onClick={() => onUpdate({ ukOver: '' })}
title="Zurueck zu Geschoss-OKFF"
style={{ fontSize: 11, padding: '0 6px',
background: 'transparent', border: 'none',
color: 'var(--text-muted)', cursor: 'pointer' }}>×</button>
</div>
) : (
<select value=""
onChange={(e) => {
if (e.target.value === '__custom__') onUpdate({ ukOver: 0 })
}}
style={selectStyle}>
<option value="">(Geschoss-OKFF)</option>
<option value="__custom__">eigenes Z</option>
</select>
)}
<span style={unitStyle}>{hasUkOver ? 'm' : ''}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}>Ziel</span>
{hasHOver ? (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={hStr}
placeholder="1.50"
onChange={(e) => setHStr(e.target.value)}
onBlur={onCommitH}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }}
title="Treppen-Höhe (Delta Start → Ende)"
style={{ ...inputStyle, flex: 1,
border: '1px solid var(--accent)' }} />
<button onClick={() => onUpdate({ hOver: '' })}
title="Zurueck zu Geschoss-Verknuepfung"
style={{ fontSize: 11, padding: '0 6px',
background: 'transparent', border: 'none',
color: 'var(--text-muted)', cursor: 'pointer' }}>×</button>
</div>
) : (
<select
value={treppe.geschossEnd || ''}
onChange={(e) => {
const v = e.target.value
if (v === '__custom__') {
onUpdate({ hOver: H, geschossEnd: '' })
} else {
onUpdate({ geschossEnd: v, hOver: '' })
}
}}
style={selectStyle}>
<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>
)}
<span style={unitStyle}>{hasHOver ? 'm' : ''}</span>
</div>
<div style={rowStyle}>
<span style={labelStyle}>Breite</span>
<input type="text" value={breite}
onChange={(e) => setBreite(e.target.value)}
onBlur={() => {
@@ -2143,62 +2248,68 @@ function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
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>
style={inputStyle} />
<span style={unitStyle}>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 style={rowStyle} title={treppe.lockS
? 'Mit Trittmaß-Lock: nur Anzahlen die ein S-Werte nahe der Sollhöhe ergeben'
: 'Anzahl Tritte (2-40)'}>
<span style={labelStyle}>Stufen</span>
<select value={treppe.nStufen}
onChange={(e) => onUpdate({ nStufen: parseInt(e.target.value, 10) })}
style={selectStyle}>
{(() => {
// Mit Lock: filtere die N-Werte deren resultierendes S nahe an
// target_S liegt (±10%). Sonst 2-40.
const range = []
for (let i = 2; i <= 40; i++) range.push(i)
if (treppe.lockS && treppe.targetS > 0.05 && H > 0.1) {
const tgt = Number(treppe.targetS)
const tol = tgt * 0.10
return range
.filter(n => Math.abs(H / n - tgt) <= tol)
.map(n => (
<option key={n} value={n}>
{n} (S={(H / n).toFixed(3)} m)
</option>
))
}
return range.map(n => <option key={n} value={n}>{n}</option>)
})()}
</select>
<span style={unitStyle}>×</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 }}>
<div style={rowStyle}>
<span style={labelStyle}>Lage</span>
<select value={ref}
onChange={(e) => onUpdate({ treppeReferenz: e.target.value })}
style={selectStyle}>
{REF_OPTIONS.map(o => (
<BarToggle key={o.code}
label={o.label}
active={ref === o.code}
onClick={() => onUpdate({ treppeReferenz: o.code })} />
<option key={o.code} value={o.code}>{o.label}</option>
))}
</div>
</select>
<span style={unitStyle}></span>
</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 }}>
<div style={rowStyle} title="Form der Treppen-Unterseite">
<span style={labelStyle}>Unten</span>
<select value={modus}
onChange={(e) => onUpdate({ treppeModus: e.target.value })}
style={selectStyle}>
{MODUS_OPTIONS.map(o => (
<BarToggle key={o.code}
label={o.label}
active={modus === o.code}
onClick={() => onUpdate({ treppeModus: o.code })}
title={o.hint} />
<option key={o.code} value={o.code} title={o.hint}>{o.label}</option>
))}
</div>
</select>
<span style={unitStyle}></span>
</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>
<div style={rowStyle} title="Dicke der Lauf-Platte (Materialdicke unter den Stufen)">
<span style={labelStyle}>Platte</span>
<input type="text" value={laufD}
onChange={(e) => setLaufD(e.target.value)}
onBlur={() => {
@@ -2207,8 +2318,8 @@ function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
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>
style={inputStyle} />
<span style={unitStyle}>m</span>
</div>
)}
@@ -2219,24 +2330,41 @@ function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
letterSpacing: '0.06em', textTransform: 'uppercase' }}>
2D-Plansymbol
</span>
{[
['showLauflinie', 'Lauflinie'],
['showAussen', 'Außenlinie'],
['showBruch', 'Bruchsymbol'],
].map(([key, label]) => {
const on = treppe[key] !== false
return (
<label key={key} style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer',
}}>
<input type="checkbox" checked={on}
onChange={(e) => onUpdate({ [key]: e.target.checked })}
style={{ accentColor: 'var(--accent)' }} />
<span>{label}</span>
</label>
)
})}
<label style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer',
}}>
<input type="checkbox" checked={treppe.showLauflinie !== false}
onChange={(e) => onUpdate({ showLauflinie: e.target.checked })}
style={{ accentColor: 'var(--accent)' }} />
<span>Lauflinie</span>
</label>
{treppe.showLauflinie !== false && (
<div style={{
display: 'grid', gridTemplateColumns: '50px 1fr 14px',
alignItems: 'center', gap: 6, marginLeft: 18,
}}>
<span style={labelStyle}>Pfeil</span>
<select value={treppe.arrowStyle || 'klassisch'}
onChange={(e) => onUpdate({ arrowStyle: e.target.value })}
style={selectStyle}>
<option value="klassisch">klassisch</option>
<option value="filled">gefüllt</option>
<option value="breit">breit</option>
<option value="voll">voll (bis zu den Seiten)</option>
</select>
<span style={unitStyle}></span>
</div>
)}
<label style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer',
}}>
<input type="checkbox" checked={treppe.showBruch !== false}
onChange={(e) => onUpdate({ showBruch: e.target.checked })}
style={{ accentColor: 'var(--accent)' }} />
<span>Bruchsymbol</span>
</label>
<label style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 10, cursor: 'pointer',
@@ -2283,6 +2411,15 @@ function TreppeProperties({ treppe, geschosse, onUpdate, onDelete }) {
}}>auto</button>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}
title="Wenn an: Beim Aendern der Hoehe (oder Versatz/Ziel) wird die Anzahl Stufen automatisch nachgerechnet, damit S konstant bleibt.">
<input type="checkbox" checked={!!treppe.lockS}
onChange={(e) => onUpdate({ lockS: e.target.checked })}
style={{ accentColor: 'var(--accent)', width: 12, height: 12 }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
Trittmaß fixiert (S{treppe.targetS ? ' = ' + Number(treppe.targetS).toFixed(3) + ' m' : ''})
</span>
</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"