Snap-Bar + Drag-Reorder + Schnittperspektive + Top-View Z-Guard + Multi-Geschoss-Clipping

Snap-Bar (Oberleiste):
- 4x2 Icon-Grid mit architektonischen Osnap-Modi (End/Mid/Int/Perp/Cen/Near)
- Master-O Toggle + Grid-Sichtbarkeit
- Symbol-Wahl angelehnt an Rhinos eigene Snap-Marker
- Ortho + Grid-Snap raus (in Rhino-Footer)
- Backend: _osnap_flag_map + _get/_set_osnap_modes + _set_grid_visible

Drag-to-Reorder (GeschossManager):
- HTML5-Drag auf jeder Zeile
- Drop-Indikator (accent-Border oben/unten je nach Cursor-Position)
- Gedraggte Row faded auf opacity 0.4
- Array-Reorder + onChange triggert recalcOkff -> OKFFs konsistent

Schnittperspektive:
- projection: 'parallel' | 'perspective' im Schnitt-Settings-Dialog
- Augenhoehe (cameraHeight) nur bei Perspektive sichtbar
- activate_schnitt mit ChangeToPerspectiveProjection(50 FOV)
- skip_view=True bei Grip-Drag-Re-Activate damit View nicht ploetzlich
  in Section springt

Top-View Z-Guard:
- _is_active_view_top_like + _suppress_z_drift_if_top_view in
  _on_object_replaced — bei Plan-View wird Z-Drift einer source-curve
  automatisch zurueckgerollt (gegen ungewolltes Snappen auf z!=0 oder
  Gumball-Z)

Multi-Geschoss-Clipping-UX:
- Klick auf cut-Icon einer nicht-aktiven Geschoss-Zeile aktiviert das
  Geschoss mit + toggelt Clipping → Plane erscheint sofort

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 23:44:12 +02:00
parent 736325fba1
commit ee01c7ebdc
7 changed files with 426 additions and 17 deletions
+93
View File
@@ -17,6 +17,8 @@ import {
setDarstellung,
arrangeSelection,
toggleReferenzlinien,
toggleOsnap,
setOsnapMode, toggleGridVisible,
} from './lib/rhinoBridge'
const PRESETS = [
@@ -706,6 +708,97 @@ export default function OberleisteApp() {
<div style={sep} />
{/* ====== SNAP-BAR (Architektur-Osnaps + Grid) ======
4×2 Grid, nur Icons, schlank. Symbol-Wahl orientiert sich an
Rhinos eigenen Snap-Markern (End=Quadrat, Mid=Dreieck, Cen=
Kreis, Int=X, Perp=Winkel, Near=Plus). Ortho + Grid-Snap sind
in Rhinos Footer-Bar — hier nur was dort fehlt.
Reihe 1: Master-O | End | Mid | Int
Reihe 2: Perp | Cen | Near | Grid */}
{(() => {
const om = state.osnapModes || {}
const osnapDisabled = !state.osnap
const IconBtn = ({ icon, active, disabled, onClick, isFirst, title }) => (
<button onClick={onClick} disabled={disabled} title={title}
onMouseEnter={(e) => {
if (disabled || active) return
e.currentTarget.style.background = 'var(--bg-item-hover)'
e.currentTarget.style.color = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
if (active) return
e.currentTarget.style.background = 'var(--bg-input)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
style={{
height: BAR_H, width: BAR_H, padding: 0,
background: active ? 'var(--accent)' : 'var(--bg-input)',
color: active ? 'var(--bg-panel)' : 'var(--text-primary)',
border: 'none',
borderLeft: isFirst ? 'none' : '1px solid var(--border)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.35 : 1,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
appearance: 'none', WebkitAppearance: 'none',
lineHeight: 1, boxSizing: 'border-box', flexShrink: 0,
transition: 'background 0.15s, color 0.15s',
}}>
<Icon name={icon} size={12} />
</button>
)
const rowStyle = {
display: 'inline-flex', width: BAR_H * 4,
height: BAR_H + 2, boxSizing: 'border-box',
border: '1px solid var(--border)', borderRadius: 999,
overflow: 'hidden', flexShrink: 0,
}
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0,
}}>
<div style={rowStyle}>
<IconBtn icon="gps_fixed" isFirst
active={!!state.osnap}
onClick={() => toggleOsnap(!state.osnap)}
title={state.osnap ? 'Object-Snap an' : 'Object-Snap aus'} />
<IconBtn icon="crop_square" disabled={osnapDisabled}
active={!!om.end}
onClick={() => setOsnapMode('end', !om.end)}
title="Endpunkt (End)" />
<IconBtn icon="change_history" disabled={osnapDisabled}
active={!!om.mid}
onClick={() => setOsnapMode('mid', !om.mid)}
title="Mittelpunkt (Mid)" />
<IconBtn icon="close" disabled={osnapDisabled}
active={!!om.int}
onClick={() => setOsnapMode('int', !om.int)}
title="Schnittpunkt (Intersection)" />
</div>
<div style={rowStyle}>
<IconBtn icon="square_foot" isFirst disabled={osnapDisabled}
active={!!om.perp}
onClick={() => setOsnapMode('perp', !om.perp)}
title="Lotrecht (Perpendicular)" />
<IconBtn icon="radio_button_unchecked" disabled={osnapDisabled}
active={!!om.cen}
onClick={() => setOsnapMode('cen', !om.cen)}
title="Kreis-/Bogen-Mittelpunkt (Center)" />
<IconBtn icon="add" disabled={osnapDisabled}
active={!!om.near}
onClick={() => setOsnapMode('near', !om.near)}
title="Naechster Punkt (Near)" />
<IconBtn
icon={state.gridVisible === false ? 'grid_off' : 'grid_on'}
active={state.gridVisible !== false}
onClick={() => toggleGridVisible(state.gridVisible === false)}
title="Konstruktions-Raster ein-/ausblenden" />
</div>
</div>
)
})()}
<div style={sep} />
{/* ====== TEXT-Block (Vectorworks-Stil) ======
Reihe 1: Style ▼ | Font ▼ | Size ▼
Reihe 2: [B][I][U] | [L][C][R] | [+]
+82 -1
View File
@@ -11,6 +11,8 @@ function GeschossBadge({ name }) {
function ZeichnungsebeneRow({
z, active, mode, onClick, onContextMenu,
onToggleVisible, onToggleLock, onToggleClipping, onDelete,
onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd,
isDragging, dropPos,
}) {
const isGeschoss = !!z.isGeschoss
const isSchnitt = z.type === 'schnitt'
@@ -48,6 +50,12 @@ function ZeichnungsebeneRow({
}
return (
<div
draggable
onDragStart={(ev) => onDragStart && onDragStart(ev, z.id)}
onDragOver={(ev) => onDragOver && onDragOver(ev, z.id)}
onDragLeave={(ev) => onDragLeave && onDragLeave(ev, z.id)}
onDrop={(ev) => onDrop && onDrop(ev, z.id)}
onDragEnd={(ev) => onDragEnd && onDragEnd(ev)}
onClick={onClick}
onContextMenu={onContextMenu}
style={{
@@ -56,7 +64,16 @@ function ZeichnungsebeneRow({
margin: 0,
background: active ? 'var(--active-dim)' : 'var(--bg-item)',
borderRadius: active ? 999 : 0,
borderBottom: active ? '1px solid transparent' : '1px solid var(--border-light)',
// Drop-Indikator: 2px accent-Linie an Top oder Bottom — zeigt
// wo der gedragte Eintrag nach Loslassen landet. Sonst normale
// unten-Trennlinie. isDragging: leichtes Faden des gehobenen
// Eintrags fuer visuelles Feedback.
borderTop: dropPos === 'top'
? '2px solid var(--accent)' : '0 solid transparent',
borderBottom: dropPos === 'bottom'
? '2px solid var(--accent)'
: (active ? '1px solid transparent' : '1px solid var(--border-light)'),
opacity: isDragging ? 0.4 : 1,
cursor: 'pointer',
userSelect: 'none',
minHeight: 24,
@@ -146,6 +163,59 @@ export default function GeschossManager({
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 }
// Drag-State fuer Reorder
const [dragId, setDragId] = useState(null)
const [dropOverId, setDropOverId] = useState(null)
const [dropPos, setDropPos] = useState(null) // 'top' | 'bottom'
const handleDragStart = (ev, id) => {
setDragId(id)
try {
ev.dataTransfer.effectAllowed = 'move'
ev.dataTransfer.setData('text/plain', id)
} catch (e) {}
}
const handleDragOver = (ev, targetId) => {
if (!dragId || dragId === targetId) return
ev.preventDefault()
try { ev.dataTransfer.dropEffect = 'move' } catch (e) {}
const rect = ev.currentTarget.getBoundingClientRect()
const pos = (ev.clientY < rect.top + rect.height / 2) ? 'top' : 'bottom'
if (targetId !== dropOverId) setDropOverId(targetId)
if (pos !== dropPos) setDropPos(pos)
}
const handleDragLeave = (ev, targetId) => {
// Wenn der Cursor die ganze Row verlaesst (nicht in ein Child), reset.
// Heuristik: relatedTarget liegt nicht innerhalb der Row.
if (!ev.currentTarget.contains(ev.relatedTarget)) {
if (dropOverId === targetId) {
setDropOverId(null); setDropPos(null)
}
}
}
const handleDrop = (ev, targetId) => {
ev.preventDefault()
const srcId = dragId
setDragId(null); setDropOverId(null); setDropPos(null)
if (!srcId || srcId === targetId) return
// Array-Reihenfolge: bottom-up (recalcOkff stacks von index 0 hoch).
// Display ist umgekehrt (top = letztes Array-Element). Klick auf
// 'top' eines Display-Eintrags Z heisst: oberhalb Z, d.h. nach Z
// im Array (höherer Index). 'bottom' = unterhalb Z in Display =
// vor Z im Array.
const arr = [...zeichnungsebenen]
const srcIdx = arr.findIndex(z => z.id === srcId)
if (srcIdx < 0) return
const [moved] = arr.splice(srcIdx, 1)
const tgtIdx = arr.findIndex(z => z.id === targetId)
if (tgtIdx < 0) return
const insertIdx = (dropPos === 'top') ? tgtIdx + 1 : tgtIdx
arr.splice(insertIdx, 0, moved)
onChange(arr) // ZeichnungsebenenApp ruft recalcOkff → OKFFs stimmen wieder
}
const handleDragEnd = () => {
setDragId(null); setDropOverId(null); setDropPos(null)
}
const sorted = [...zeichnungsebenen].reverse()
@@ -320,6 +390,10 @@ export default function GeschossManager({
const toggleClipping = (id) => {
onChange(zeichnungsebenen.map(z => z.id === id ? { ...z, hasClipping: !z.hasClipping } : z))
// Backend rendert die Clipping-Plane nur fuer das AKTIVE Geschoss.
// Klick auf cut-Icon einer nicht-aktiven Zeile → mit-aktivieren,
// sonst kriegt der User keine sichtbare Rueckmeldung.
if (id !== activeId) onActiveChange(id)
}
const duplicate = (id) => {
@@ -459,6 +533,13 @@ export default function GeschossManager({
onToggleLock={() => toggleLock(z.id)}
onToggleClipping={() => toggleClipping(z.id)}
onDelete={() => remove(z.id)}
isDragging={dragId === z.id}
dropPos={dropOverId === z.id ? dropPos : null}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
</div>
+36
View File
@@ -48,7 +48,12 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
const clipZ = (okff + schnitt).toFixed(2)
// Schnitt-Felder
const cutAtLine = draft.cutAtLine !== false // default true = Schnitt
const projection = draft.projection || 'parallel'
const depthBack = draft.depthBack ?? 8.0
// Kamera-Hoehe (m) — nur bei Perspektive relevant. Default = Mitte
// der Hoehenrange (= klassischer "Augenpunkt-in-der-Mitte"-Look).
const camHeightDefault = ((draft.heightMin ?? -1.0) + (draft.heightMax ?? 12.0)) / 2
const cameraHeight = draft.cameraHeight ?? camHeightDefault
const heightMin = draft.heightMin ?? -1.0
const heightMax = draft.heightMax ?? 12.0
const dirSign = draft.dirSign ?? 1
@@ -172,6 +177,30 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
onClick={() => set({ dirSign: -1 })}
style={{ flex: 1, fontSize: 11 }}>Seite B </button>
</Field>
<Field label="PROJEKTION"
hint={projection === 'perspective'
? 'Schnittperspektive — perspektivische Section mit gleichem Clipping. Cutaway-Visualisierung.'
: 'Klassischer Schnitt — Parallelprojektion, masstabsgetreu.'}>
<button className={projection === 'parallel' ? 'btn-contained' : 'btn-outlined'}
onClick={() => set({ projection: 'parallel' })}
style={{ flex: 1, fontSize: 11 }}>Parallel</button>
<button className={projection === 'perspective' ? 'btn-contained' : 'btn-outlined'}
onClick={() => set({ projection: 'perspective' })}
style={{ flex: 1, fontSize: 11 }}>Perspektive</button>
</Field>
{projection === 'perspective' && (
<Field label="AUGENHÖHE (m)"
hint="Z-Höhe von Kamera und Blickziel. 1.60 = Augenhöhe (klassische Schnittperspektive). 0 = Bodennivau. Höher = Bird's-Eye.">
<input
type="number" step="0.1"
value={cameraHeight}
onChange={(ev) => set({ cameraHeight: parseFloat(ev.target.value) })}
style={{ flex: 1, fontSize: 11, textAlign: 'right', minWidth: 0 }}
/>
</Field>
)}
</>
)}
@@ -251,6 +280,13 @@ export default function GeschossSettingsDialog({ geschoss, onSave, onClose, embe
if (out.heightMax == null) out.heightMax = 12.0
if (out.dirSign == null) out.dirSign = 1
if (out.cutAtLine == null) out.cutAtLine = true
if (out.projection == null) out.projection = 'parallel'
// cameraHeight: nur wenn Perspektive aktiv UND noch nicht
// gesetzt → Default aus Hoehen-Mitte. Bei Parallel das Feld
// nicht ungewollt persistieren (bleibt undefined).
if (out.projection === 'perspective' && out.cameraHeight == null) {
out.cameraHeight = ((out.heightMin ?? -1.0) + (out.heightMax ?? 12.0)) / 2
}
}
onSave(out)
}}>Übernehmen</button>
+4
View File
@@ -181,6 +181,10 @@ export function arrangeSelection(dir) { send('ARRANGE', { dir }) }
export function toggleReferenzlinien(visible) {
send('TOGGLE_REFERENZLINIEN', { visible: !!visible })
}
// Snap-Toggles (Architektur-relevante Osnaps + Grid) — neue Helpers.
// toggleOrtho/toggleGridSnap/toggleOsnap existieren bereits weiter oben.
export function setOsnapMode(key, on) { send('SET_OSNAP_MODE', { key, enabled: !!on }) }
export function toggleGridVisible(on) { send('TOGGLE_GRID_VISIBLE', { visible: !!on }) }
// 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 }