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
+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>