Schnitt/Ansicht-Feature + Terrain-Volumen + Geschoss-Add-Dialog

Schnitt-Feature V1+V2:
- Neues rhino/schnitte.py mit Pick-Workflow, Activation (Clipping-Planes +
  Parallel-View), 2D-Plan-Symbol auf 18_Schnittlinien-Sublayer
- Doppelklick auf Symbol aktiviert den Schnitt
- Schnitt-Settings (cutAtLine/Tiefe/Höhen/Blickrichtung) im GeschossSettingsDialog
- View-Snapshot + Restore beim Wechsel Schnitt → Geschoss
- Symbol-Cleanup bei Delete via normalem Ebenen-Menü

Terrain als Volumen:
- swisstopo.volumize_terrain_object: Skirt + Bottom-Cap auf Mesh/Brep
  damit Clipping-Planes gefuellte Querschnitte erzeugen
- UI im SwisstopoApp mit Nachbearbeitung-Section + Tiefen-Eingabe

Geschoss-Add mit Dialog:
- + im GeschossManager oeffnet 3-Optionen-Picker (Geschoss/Schnitt/Zeichnung)
- Geschoss-Dialog mit Anker-Dropdown, Position über/unter, Auto-Name,
  Höhen-Prefill aus Anker

Fix: _send_state fallback — Element gilt als selektiert wenn Source ODER
Volume in der Selection ist (robust gegen Layer-Visibility wenn Referenz-
linien-Layer im aktuellen Mode versteckt ist)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 18:28:59 +02:00
parent 3277f61ced
commit 059cbf8d4d
8 changed files with 1356 additions and 22 deletions
+32
View File
@@ -76,6 +76,11 @@ export default function SwisstopoApp() {
const [replaceExisting, setReplaceExisting] = useState(true)
const [clipToBbox, setClipToBbox] = useState(false)
const [terrainRes, setTerrainRes] = useState('2.0')
// Terrain als geschlossenes Volumen (mit Boden 10m unter tiefstem Punkt)
// damit Section-Cuts gefuellte Querschnitte zeigen statt nur Linien.
// Wirkt auf 3D-Mesh / TIN / Patch — nicht auf 2D-Hoehenlinien und Schichten.
const [terrainVolume, setTerrainVolume] = useState(false)
const [terrainVolumeDepth, setTerrainVolumeDepth] = useState('10')
// Live-Log
const [logs, setLogs] = useState([])
const [running, setRunning] = useState(false)
@@ -165,6 +170,8 @@ export default function SwisstopoApp() {
buildVariant,
contourInterval: contourInt,
tlmKinds: tlmList,
terrainAsVolume: terrainVolume,
terrainVolumeDepth: parseFloat(terrainVolumeDepth) || 10,
})
}
@@ -366,6 +373,31 @@ export default function SwisstopoApp() {
onChange={setContourInt} />
</Field>
)}
{(getTerrain || getContourTin || getContourPatch) && (
<>
<SectionLabel>Nachbearbeitung</SectionLabel>
<Field label=""
hint="Wandelt die oben gewaehlten 3D-Terrain-Quellen (Terrain / TIN / Patch) in geschlossene Mesh-Volumen um — Skirt + Boden bei (min_z Tiefe). Damit liefert eine Clipping-Plane einen gefuellten Querschnitt statt nur Konturlinien. 2D-Linien und Schichten sind nicht betroffen.">
<label style={{ display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, cursor: 'pointer' }}>
<input type="checkbox" checked={terrainVolume}
onChange={(e) => setTerrainVolume(e.target.checked)} />
<Icon name="layers" size={13} /> Terrain als Volumen (mit Boden, schneidbar)
</label>
</Field>
{terrainVolume && (
<Field label="TIEFE">
<input type="text"
value={terrainVolumeDepth}
onChange={(e) => setTerrainVolumeDepth(e.target.value)}
style={{ width: 60, textAlign: 'right' }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}>
m unter tiefstem Punkt
</span>
</Field>
)}
</>
)}
<SectionLabel>Positionierung</SectionLabel>
+283 -8
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import Icon from './Icon'
import ContextMenu from './ContextMenu'
import { BarCombo, BarButton } from './BarControls'
import { openGeschossSettings, openGeschossDialog } from '../lib/rhinoBridge'
import { openGeschossSettings, openGeschossDialog, createSchnitt } from '../lib/rhinoBridge'
function GeschossBadge({ name }) {
return <span className="chip chip-info">{name}</span>
@@ -13,6 +13,10 @@ function ZeichnungsebeneRow({
onToggleVisible, onToggleLock, onToggleClipping, onDelete,
}) {
const isGeschoss = !!z.isGeschoss
const isSchnitt = z.type === 'schnitt'
// Schnitt vs Ansicht: cutAtLine!=false = Schnitt (mit Front-Cut), sonst Ansicht
const schnittIcon = (z.cutAtLine === false) ? 'visibility' : 'content_cut'
const schnittLabel = (z.cutAtLine === false) ? 'Ansicht' : 'Schnitt'
// Eye-Logik: die aktive Z ist IMMER sichtbar (Backend forciert das), also
// zeigen wir ihr Auge immer als "an" — ohne Ruecksicht aufs visible-Flag.
// Nicht-aktive: in 'all_force' ist visible-Flag ueberschrieben (alle an),
@@ -85,6 +89,18 @@ function ZeichnungsebeneRow({
{isGeschoss && <GeschossBadge name={z.name} />}
{isSchnitt && (
<span title={schnittLabel}
style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
fontSize: 10, color: 'var(--text-muted)',
fontFamily: 'var(--font-mono)',
}}>
<Icon name={schnittIcon} size={11}
style={{ color: active ? 'var(--accent)' : 'var(--text-muted)' }} />
</span>
)}
{isGeschoss ? (
<button
className={`btn-icon-xs ${z.hasClipping ? 'is-on' : ''}`}
@@ -128,14 +144,14 @@ export default function GeschossManager({
mode, onModeChange,
}) {
const [ctxMenu, setCtxMenu] = useState(null) // { x, y, id }
const [addMenu, setAddMenu] = useState(null) // { x, y } — Picker beim +
const [geschossDialog, setGeschossDialog] = useState(null) // { x, y, pos:'above'|'below', name, hoehe, schnitthoehe, anchorId }
const sorted = [...zeichnungsebenen].reverse()
const addQuick = () => {
// Standard: NICHT-Geschoss-Zeichnungsebene (z.B. Möblierung, Bemassung,
// Plangrafik etc.). User kann via Row-Kontextmenue auf Geschoss
// umschalten oder via Bearbeiten-Dialog (Pencil) ein Geschoss erstellen.
const nonGeschossCount = zeichnungsebenen.filter(z => !z.isGeschoss).length
const addZeichnung = () => {
const nonGeschossCount = zeichnungsebenen.filter(
z => !z.isGeschoss && z.type !== "schnitt").length
const newZ = {
id: `z_${Date.now()}`,
name: `Zeichnung ${nonGeschossCount + 1}`,
@@ -145,6 +161,151 @@ export default function GeschossManager({
onChange([...zeichnungsebenen, newZ])
}
// Vorgeschlagener Geschossname relativ zum Anker. Logik:
// EG + above → 1OG, EG + below → UG
// <N>OG + above → <N+1>OG, <N>OG + below → wenn N=1 dann EG, sonst <N-1>OG
// <N>UG + above → wenn N=1 dann EG, sonst <N-1>UG; <N>UG + below → <N+1>UG
// sonst: 'Neu'
const suggestGeschossName = (anchor, pos) => {
const nm = ((anchor?.name) || '').trim().toUpperCase()
const ogMatch = nm.match(/^(\d*)OG$/)
const ugMatch = nm.match(/^(\d*)UG$/)
if (nm === 'EG') return pos === 'above' ? '1OG' : 'UG'
if (ogMatch) {
const n = parseInt(ogMatch[1] || '1', 10)
if (pos === 'above') return `${n + 1}OG`
return n <= 1 ? 'EG' : `${n - 1}OG`
}
if (ugMatch) {
const n = parseInt(ugMatch[1] || '1', 10)
if (pos === 'below') return `${n + 1}UG`
return n <= 1 ? 'EG' : `${n - 1}UG`
}
return 'Neu'
}
// Project-Default-Hoehe: erst aktives Geschoss, dann erstes Geschoss in
// der Liste, dann hartcodiert 3.0. So uebernimmt jeder neue Eintrag den
// "typischen" Wert des Projekts ohne dass der User irgendwo setzen muss.
const defaultGeschossHoehe = () => {
const act = zeichnungsebenen.find(z => z.id === activeId)
if (act?.isGeschoss && act.hoehe != null) return act.hoehe
const first = zeichnungsebenen.find(z => z.isGeschoss && z.hoehe != null)
return first?.hoehe ?? 3.0
}
const defaultSchnitthoehe = () => {
const act = zeichnungsebenen.find(z => z.id === activeId)
if (act?.isGeschoss && act.schnitthoehe != null) return act.schnitthoehe
const first = zeichnungsebenen.find(z => z.isGeschoss && z.schnitthoehe != null)
return first?.schnitthoehe ?? 1.0
}
const openGeschossPrompt = (ev) => {
// Anker = aktives Geschoss (oder erstes Geschoss in der Liste). Falls
// gar keins da: einfach default-Werte ohne Anker.
const anchor = zeichnungsebenen.find(z => z.id === activeId && z.isGeschoss)
|| zeichnungsebenen.find(z => z.isGeschoss)
|| null
const pos = 'above'
const rect = ev?.currentTarget?.getBoundingClientRect()
setGeschossDialog({
x: rect ? rect.right - 240 : 200,
y: rect ? rect.bottom + 4 : 100,
pos,
name: suggestGeschossName(anchor, pos),
hoehe: defaultGeschossHoehe().toFixed(2),
schnitthoehe: defaultSchnitthoehe().toFixed(2),
anchorId: anchor?.id || null,
})
}
const confirmGeschoss = () => {
if (!geschossDialog) return
const { pos, name, hoehe, schnitthoehe, anchorId } = geschossDialog
const h = parseFloat(String(hoehe).replace(',', '.'))
const sh = parseFloat(String(schnitthoehe).replace(',', '.'))
const newZ = {
id: `z_${Date.now()}`,
name: (name || 'Neu').trim(),
isGeschoss: true,
hoehe: isFinite(h) && h > 0 ? h : 3.0,
schnitthoehe: isFinite(sh) && sh > 0 ? sh : 1.0,
visible: true,
}
// Insertion-Index: anchor finden, dann +1 (above) oder vorne ein (below).
// Array-Reihenfolge = bottom-up (recalcOkff stacks von index 0 aufwaerts),
// also "above" = nach anchor, "below" = vor anchor.
let nextList
if (anchorId) {
const idx = zeichnungsebenen.findIndex(z => z.id === anchorId)
if (idx >= 0) {
const insertAt = pos === 'above' ? idx + 1 : idx
nextList = [
...zeichnungsebenen.slice(0, insertAt),
newZ,
...zeichnungsebenen.slice(insertAt),
]
} else {
nextList = [...zeichnungsebenen, newZ]
}
} else {
nextList = [...zeichnungsebenen, newZ]
}
onChange(nextList)
setGeschossDialog(null)
}
const updateGeschossDialog = (patch) => {
if (!geschossDialog) return
const next = { ...geschossDialog, ...patch }
// Wenn pos ODER anchorId sich aendert: Name + Hoehen neu vorschlagen,
// aber nur wenn der User die jeweiligen Felder nicht schon manuell
// ueberschrieben hat (heuristisch: aktueller Wert === alter Vorschlag).
const posChanged = patch.pos && patch.pos !== geschossDialog.pos
const anchorChanged = patch.anchorId && patch.anchorId !== geschossDialog.anchorId
if (posChanged || anchorChanged) {
const oldAnchor = zeichnungsebenen.find(z => z.id === geschossDialog.anchorId)
const newAnchor = zeichnungsebenen.find(z => z.id === (patch.anchorId || geschossDialog.anchorId))
const oldSuggestedName = suggestGeschossName(oldAnchor, geschossDialog.pos)
if (geschossDialog.name === oldSuggestedName) {
next.name = suggestGeschossName(newAnchor, next.pos)
}
// Bei Anchor-Wechsel: Hoehe/Schnitthoehe auch nachziehen wenn unveraendert
if (anchorChanged && newAnchor?.isGeschoss) {
const oldHoeheStr = (oldAnchor?.hoehe ?? 3.0).toFixed(2)
const oldShStr = (oldAnchor?.schnitthoehe ?? 1.0).toFixed(2)
if (geschossDialog.hoehe === oldHoeheStr && newAnchor.hoehe != null) {
next.hoehe = newAnchor.hoehe.toFixed(2)
}
if (geschossDialog.schnitthoehe === oldShStr && newAnchor.schnitthoehe != null) {
next.schnitthoehe = newAnchor.schnitthoehe.toFixed(2)
}
}
}
setGeschossDialog(next)
}
const addSchnitt = () => createSchnitt() // triggert interaktiven Pick + Auto-Activate
const openAddMenu = (ev) => {
if (!ev) { addZeichnung(); return } // Fallback ohne Event-Position
const rect = ev.currentTarget.getBoundingClientRect()
setAddMenu({ x: rect.right - 180, y: rect.bottom + 4 })
}
// ContextMenu Items — onClick kriegt KEIN Event. Daher fuer Geschoss
// einen kleinen Trick: ContextMenu schliesst, dann oeffnen wir den
// Dialog mit einer kuenstlichen Position (rechts vom Panel).
const addMenuItems = [
{ label: 'Geschoss', icon: 'layers',
onClick: () => openGeschossPrompt({
currentTarget: { getBoundingClientRect: () =>
({ right: addMenu?.x + 180, bottom: addMenu?.y + 20 }) } }) },
{ label: 'Schnitt / Ansicht', icon: 'content_cut', onClick: addSchnitt },
{ divider: true },
{ label: 'Zeichnung', icon: 'edit_note', onClick: addZeichnung },
]
const toggleVisible = (id) => {
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, visible: !(z.visible !== false) } : z))
// In "active" / "all_force" greift visible-Flag nicht — wer aufs Auge
@@ -226,9 +387,9 @@ export default function GeschossManager({
onGear={() => openGeschossDialog(zeichnungsebenen)}
gearIcon="settings"
gearTitle="Einstellungen"
onSecond={addQuick}
onSecond={openAddMenu}
secondIcon="add"
secondTitle="Zeichnungsebene hinzufügen"
secondTitle="Hinzufuegen: Geschoss / Schnitt / Zeichnung"
>
{MODES.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
@@ -309,6 +470,120 @@ export default function GeschossManager({
onClose={() => setCtxMenu(null)}
/>
)}
{addMenu && (
<ContextMenu
x={addMenu.x} y={addMenu.y}
items={addMenuItems}
onClose={() => setAddMenu(null)}
/>
)}
{geschossDialog && (() => {
// Geschoss-Liste (top-down sortiert wie im Panel) fuer das Dropdown
const geschossOptions = [...zeichnungsebenen]
.filter(z => z.isGeschoss)
.reverse()
return (
<>
{/* Backdrop — Klick schliesst */}
<div onClick={() => setGeschossDialog(null)}
style={{ position: 'fixed', inset: 0, zIndex: 999,
background: 'transparent' }} />
<div style={{
position: 'fixed', zIndex: 1000,
left: Math.max(8, Math.min(geschossDialog.x, window.innerWidth - 260)),
top: Math.max(8, Math.min(geschossDialog.y, window.innerHeight - 220)),
width: 240,
background: 'var(--bg-dialog)', color: 'var(--text-primary)',
border: '1px solid var(--border)', borderRadius: 6,
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
padding: 10,
fontSize: 11, fontFamily: 'var(--font)',
display: 'flex', flexDirection: 'column', gap: 8,
}}
onClick={(ev) => ev.stopPropagation()}>
<div style={{ fontSize: 10, color: 'var(--text-muted)',
fontWeight: 600, letterSpacing: 0.4,
textTransform: 'uppercase' }}>
Neues Geschoss
</div>
{/* Position: Anker-Dropdown + Über/Unter-Toggle */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>
Position relativ zu
</span>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<select
value={geschossDialog.anchorId || ''}
onChange={(ev) => updateGeschossDialog({ anchorId: ev.target.value })}
disabled={geschossOptions.length === 0}
style={{ flex: 1, fontSize: 11, minWidth: 0 }}>
{geschossOptions.length === 0 && <option value=""> keins </option>}
{geschossOptions.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<BarButton icon="arrow_upward"
active={geschossDialog.pos === 'above'}
onClick={() => updateGeschossDialog({ pos: 'above' })}
title="Über dem Anker einfügen" />
<BarButton icon="arrow_downward"
active={geschossDialog.pos === 'below'}
onClick={() => updateGeschossDialog({ pos: 'below' })}
title="Unter dem Anker einfügen" />
</div>
</div>
{/* Name */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Name</span>
<input type="text" value={geschossDialog.name}
onChange={(ev) => updateGeschossDialog({ name: ev.target.value })}
autoFocus
onKeyDown={(ev) => {
if (ev.key === 'Enter') confirmGeschoss()
else if (ev.key === 'Escape') setGeschossDialog(null)
}}
style={{ width: '100%' }} />
</div>
{/* Hoehe + Schnitthoehe */}
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}>Höhe (m)</span>
<input type="text" value={geschossDialog.hoehe}
onChange={(ev) => updateGeschossDialog({ hoehe: ev.target.value })}
onKeyDown={(ev) => {
if (ev.key === 'Enter') confirmGeschoss()
else if (ev.key === 'Escape') setGeschossDialog(null)
}}
style={{ width: '100%', textAlign: 'right' }} />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontSize: 9, color: 'var(--text-muted)' }}
title="Höhe der horizontalen Schnitt-Plane über OKFF">
Schnitt (m)
</span>
<input type="text" value={geschossDialog.schnitthoehe}
onChange={(ev) => updateGeschossDialog({ schnitthoehe: ev.target.value })}
onKeyDown={(ev) => {
if (ev.key === 'Enter') confirmGeschoss()
else if (ev.key === 'Escape') setGeschossDialog(null)
}}
style={{ width: '100%', textAlign: 'right' }} />
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 4, marginTop: 4 }}>
<button className="btn-text"
onClick={() => setGeschossDialog(null)}>Abbrechen</button>
<button className="btn-contained"
onClick={confirmGeschoss}>Hinzufügen</button>
</div>
</div>
</>
)
})()}
</>
)
}
+85 -13
View File
@@ -39,12 +39,19 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
const [draft, setDraft] = useState({ ...geschoss })
const set = (patch) => setDraft({ ...draft, ...patch })
const isG = !!draft.isGeschoss
const hoehe = draft.hoehe ?? 3.0
const schnitt = draft.schnitthoehe ?? 1.0
const hasClip = !!draft.hasClipping
const okff = draft.okff ?? 0
const clipZ = (okff + schnitt).toFixed(2)
const isG = !!draft.isGeschoss
const isSchnitt = draft.type === 'schnitt'
const hoehe = draft.hoehe ?? 3.0
const schnitt = draft.schnitthoehe ?? 1.0
const hasClip = !!draft.hasClipping
const okff = draft.okff ?? 0
const clipZ = (okff + schnitt).toFixed(2)
// Schnitt-Felder
const cutAtLine = draft.cutAtLine !== false // default true = Schnitt
const depthBack = draft.depthBack ?? 8.0
const heightMin = draft.heightMin ?? -1.0
const heightMax = draft.heightMax ?? 12.0
const dirSign = draft.dirSign ?? 1
// embedded=true: in einem Satelliten-Fenster gerendert — kein Backdrop,
// keine Width-Constraint, fuellt das ganze WebView.
@@ -103,14 +110,72 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
/>
</Field>
<Toggle
label="Ist Geschoss"
checked={isG}
onChange={(v) => set({ isGeschoss: v })}
hint={isG ? 'Höhe & Clipping verfügbar' : 'reines Zeichenblatt'}
/>
{/* Geschoss-Toggle nur fuer non-schnitt Eintraege — Schnitt-Type
ist exklusiv (kein Geschoss zugleich). */}
{!isSchnitt && (
<Toggle
label="Ist Geschoss"
checked={isG}
onChange={(v) => set({ isGeschoss: v })}
hint={isG ? 'Höhe & Clipping verfügbar' : 'reines Zeichenblatt'}
/>
)}
{isG && (
{isSchnitt && (
<>
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
<Toggle
label="Front-Cut (Schnitt durchschneiden)"
checked={cutAtLine}
onChange={(v) => set({ cutAtLine: v })}
hint={cutAtLine
? 'Schnitt: alles vor der Schnittlinie wird weggeschnitten'
: 'Ansicht: nur Tiefenbegrenzung hinten, kein Front-Cut'}
/>
<Field label="TIEFE HINTEN (m)"
hint="Wie weit hinter der Schnittlinie noch sichtbar ist">
<input
type="number" step="0.5" min="0.5"
value={depthBack}
onChange={(ev) => set({ depthBack: parseFloat(ev.target.value) || 8.0 })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<div style={{ display: 'flex', gap: 6 }}>
<Field label="HÖHE UNTEN (m)">
<input
type="number" step="0.1"
value={heightMin}
onChange={(ev) => set({ heightMin: parseFloat(ev.target.value) })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
<Field label="HÖHE OBEN (m)">
<input
type="number" step="0.1"
value={heightMax}
onChange={(ev) => set({ heightMax: parseFloat(ev.target.value) })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
</div>
<Field label="BLICKRICHTUNG"
hint="Wechselt zwischen den beiden Seiten der Schnittlinie">
<button className={dirSign >= 0 ? 'btn-contained' : 'btn-outlined'}
onClick={() => set({ dirSign: 1 })}
style={{ flex: 1, fontSize: 11 }}> Seite A</button>
<button className={dirSign < 0 ? 'btn-contained' : 'btn-outlined'}
onClick={() => set({ dirSign: -1 })}
style={{ flex: 1, fontSize: 11 }}>Seite B </button>
</Field>
</>
)}
{isG && !isSchnitt && (
<>
<div style={{ height: 1, background: 'var(--border-light)', margin: '6px 0' }} />
@@ -180,6 +245,13 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
if (out.hoehe == null) out.hoehe = 3.0
if (out.schnitthoehe == null) out.schnitthoehe = 1.0
}
if (out.type === 'schnitt') {
if (out.depthBack == null) out.depthBack = 8.0
if (out.heightMin == null) out.heightMin = -1.0
if (out.heightMax == null) out.heightMax = 12.0
if (out.dirSign == null) out.dirSign = 1
if (out.cutAtLine == null) out.cutAtLine = true
}
onSave(out)
}}>Übernehmen</button>
</div>
+10
View File
@@ -175,6 +175,16 @@ export function openElementeProperties() { send('OPEN_ELEMENTE_PROPERTIES', {})
export function setDarstellung(d) { send('SET_DARSTELLUNG', { darstellung: d || '' }) }
// Anordnen — 2D-Z-Stack via Rhino-DisplayOrder. dir: 'front'|'forward'|'backward'|'back'
export function arrangeSelection(dir) { send('ARRANGE', { dir }) }
// Schnitt/Ansicht — interaktiver 2-Punkt-Pick im Rhino-Viewport. Erzeugt
// eine neue Zeichnungsebene type=schnitt + 2D-Plan-Symbol + aktiviert sie.
// opts: { cutAtLine: bool, depthBack: m, heightMin: m, heightMax: m, namePrefix }
export function createSchnitt(opts = {}) {
send('CREATE_SCHNITT', {
cutAtLine: true, depthBack: 8.0, heightMin: -1.0, heightMax: 12.0,
...opts,
})
}
export function deleteSchnitt(id) { send('DELETE_SCHNITT', { id }) }
export function saveOeffStyle(name, settings) {
send('SAVE_OEFF_STYLE', { name, settings })
}