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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user