/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Move gizmo for the selected entity. Renders three SVG axis arrows * (X red, Y green, Z blue — IFC Z-up convention) anchored to the * entity's bounding-box center. Dragging an arrow translates the * entity along that axis via `MutationSlice.translateEntity`. * * Render conditions: * - `editEnabled` is on * - `activeTool === 'select'` (so the gizmo doesn't fight measure / * section / addElement) * - exactly one entity is selected * - the selection has a placement chain that can be translated * (`resolvePlacementChain` returns non-null) * * Coordinate spaces: * The renderer is Y-up. IFC is Z-up. We project two world points * per axis (origin and origin + 1m in the chosen direction in * renderer frame) to screen. The screen-space vector between them * IS our "1 metre" delta projection — so the per-frame drag math * reduces to a single dot product against the cursor's screen * delta. No camera matrix inversion required; we lean entirely on * the existing `projectToScreen` callback. * * The drag commits a single `translateEntity` call per frame (no * batching). Each call lands as one mutation on the undo stack, * which is intentionally coarse — fine for v1; if it gets noisy we * can collapse runs in a later pass. */ import { useMemo, useRef } from 'react'; import { useViewerStore } from '@/store'; import { useIfc } from '@/hooks/useIfc'; import { useCameraTickSubscription } from '@/hooks/useCameraTickSubscription'; import { getEntityCenter } from '@/utils/viewportUtils'; type Vec2 = { x: number; y: number }; type Vec3 = { x: number; y: number; z: number }; type Project = (worldPos: Vec3) => Vec2 | null; type Axis = 'x' | 'y' | 'z'; const AXIS_COLORS: Record = { x: '#ef4444', // red — IFC X y: '#10b981', // green — IFC Y z: '#3b82f6', // blue — IFC Z (up) }; /** Renderer-frame unit vector for each IFC axis. */ const AXIS_RENDERER_OFFSET: Record = { // IFC +X = renderer +X x: { x: 1, y: 0, z: 0 }, // IFC +Y = renderer -Z (renderer Z is forward; IFC Y is plan-forward) y: { x: 0, y: 0, z: -1 }, // IFC +Z = renderer +Y (Z-up vs Y-up) z: { x: 0, y: 1, z: 0 }, }; function pickViewerOrigin(meshes: import('@ifc-lite/geometry').MeshData[] | null, globalId: number): Vec3 | null { return getEntityCenter(meshes ?? null, globalId); } export function GizmoOverlay() { const editEnabled = useViewerStore((s) => s.editEnabled); const activeTool = useViewerStore((s) => s.activeTool); const selectedEntity = useViewerStore((s) => s.selectedEntity); const selectedEntityId = useViewerStore((s) => s.selectedEntityId); const projectToScreen = useViewerStore((s) => s.cameraCallbacks.projectToScreen); const getViewpoint = useViewerStore((s) => s.cameraCallbacks.getViewpoint); const translateEntity = useViewerStore((s) => s.translateEntity); const readEntityPosition = useViewerStore((s) => s.readEntityPosition); const mutationVersion = useViewerStore((s) => s.mutationVersion); const { models, geometryResult } = useIfc(); // Drag state — refs instead of state to avoid re-render thrashing. const dragRef = useRef<{ axis: Axis; originScreen: Vec2; axisScreenPerMeter: Vec2; // dx/dy in pixels for a +1m world move accumulatedDelta: Vec3; // IFC-frame delta applied so far this drag cursorStart: Vec2; batchId: string; // shared by every per-frame translate so one Ctrl+Z reverts the whole drag } | null>(null); // Decide whether the gizmo is renderable at all this frame. The // movability gate is `readEntityPosition` (slice action that // lazily creates the StoreEditor) so the arrows surface on the // user's first selection — no need to wait for an unrelated // mutation to prime the editor cache. const ready = useMemo(() => { if (!editEnabled) return null; if (activeTool !== 'select') return null; if (!selectedEntity || selectedEntityId === null) return null; if (!projectToScreen) return null; const model = models.get(selectedEntity.modelId); if (!model?.ifcDataStore) return null; const coords = readEntityPosition(selectedEntity.modelId, selectedEntity.expressId); if (!coords) return null; // Origin in renderer frame — bbox center of the entity's meshes. const meshes = (model.geometryResult ?? geometryResult)?.meshes ?? null; const origin = pickViewerOrigin(meshes, selectedEntityId); if (!origin) return null; return { origin, modelId: selectedEntity.modelId, expressId: selectedEntity.expressId }; // mutationVersion is a dep so the origin re-resolves after edits. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ editEnabled, activeTool, selectedEntity, selectedEntityId, models, geometryResult, projectToScreen, readEntityPosition, mutationVersion, ]); // Camera-tick subscription — wakes the gizmo on real viewport // motion (camera tick bypasses React renders for perf, see // `Viewport.tsx` `updateCameraRotationRealtime`). Skipped when // the gizmo isn't visible. void useCameraTickSubscription(getViewpoint, ready !== null); if (!ready) return null; const project = projectToScreen as Project; const originScreen = project(ready.origin); if (!originScreen) return null; // Axis tip screen positions. Each axis is scaled so its // projected arrow lands at a constant target length in pixels, // regardless of camera distance — fixed-world-length arrows // shrink to invisibility from far away. The per-axis scale // also handles the foreshortening case (an axis pointing // straight at the camera projects to ~0 px; we cap the world // length so the math doesn't blow up). // // For drag math we still want a "screen pixels per metre" basis // along each axis. We compute that with a probe at 1 m world // length, THEN re-project at the final scaled length so the // visible arrow matches the cursor mapping. const TARGET_SCREEN_PX = 80; const MAX_WORLD_LENGTH = 1_000; // cap for near-parallel axes const axisTips: Partial> = {}; const axisPerMeter: Partial> = {}; const axisWorldLengths: Partial> = {}; for (const axis of ['x', 'y', 'z'] as const) { const off = AXIS_RENDERER_OFFSET[axis]; // Probe at 1 metre to measure pixels-per-metre along this // axis. If the axis is nearly parallel to the camera view // direction the projected length is tiny — we clamp the // resulting world length so the arrow stays bounded. const probeTip: Vec3 = { x: ready.origin.x + off.x, y: ready.origin.y + off.y, z: ready.origin.z + off.z, }; const probeScreen = project(probeTip); if (!probeScreen) continue; const probeDx = probeScreen.x - originScreen.x; const probeDy = probeScreen.y - originScreen.y; const probePx = Math.hypot(probeDx, probeDy); if (probePx < 1e-3) { // Axis is parallel to view direction — skip arrow; the // user can rotate to grab it. continue; } const worldLength = Math.min(MAX_WORLD_LENGTH, TARGET_SCREEN_PX / probePx); axisWorldLengths[axis] = worldLength; const tipWorld: Vec3 = { x: ready.origin.x + off.x * worldLength, y: ready.origin.y + off.y * worldLength, z: ready.origin.z + off.z * worldLength, }; const tipScreen = project(tipWorld); if (!tipScreen) continue; axisTips[axis] = tipScreen; // Pixels per metre along this axis = probe pixels (probe was // exactly 1 metre, so the ratio is the probe length itself). axisPerMeter[axis] = { x: probeDx, y: probeDy }; } void axisWorldLengths; // surfaced for future use (e.g. rotate-ring radius) const startDrag = (axis: Axis, e: React.PointerEvent) => { e.stopPropagation(); e.preventDefault(); const perMetre = axisPerMeter[axis]; if (!perMetre) return; (e.target as SVGElement).setPointerCapture(e.pointerId); dragRef.current = { axis, originScreen, axisScreenPerMeter: perMetre, accumulatedDelta: { x: 0, y: 0, z: 0 }, cursorStart: { x: e.clientX, y: e.clientY }, batchId: `gizmo_drag_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, }; }; const onDragMove = (e: React.PointerEvent) => { const drag = dragRef.current; if (!drag) return; const cursorDelta: Vec2 = { x: e.clientX - drag.cursorStart.x, y: e.clientY - drag.cursorStart.y, }; // Scalar projection of the cursor delta onto the on-screen axis // direction. |axisScreenPerMeter|^2 is the squared pixel length // of a 1m world segment along this axis — dividing by it converts // pixels back into metres. const ax = drag.axisScreenPerMeter; const denom = ax.x * ax.x + ax.y * ax.y; if (denom < 1e-6) return; const metres = (cursorDelta.x * ax.x + cursorDelta.y * ax.y) / denom; // Delta since the LAST frame of this drag. const previous = drag.accumulatedDelta; const delta: [number, number, number] = [0, 0, 0]; const idx = drag.axis === 'x' ? 0 : drag.axis === 'y' ? 1 : 2; delta[idx] = metres - (drag.axis === 'x' ? previous.x : drag.axis === 'y' ? previous.y : previous.z); if (Math.abs(delta[idx]) < 1e-6) return; // Only advance the per-axis accumulator if the mutation actually // landed. If `translateEntity` rejects (placement chain doesn't // resolve, missing data store, etc.) the model state didn't // change, so leaving the accumulator at its previous value means // the next pointer-move frame retries the full delta instead of // silently dropping it. Keeps drag state and model state in sync. const result = translateEntity(ready.modelId, ready.expressId, delta, drag.batchId); if (!result.ok) return; if (drag.axis === 'x') previous.x = metres; else if (drag.axis === 'y') previous.y = metres; else previous.z = metres; }; const onDragEnd = (e: React.PointerEvent) => { if (!dragRef.current) return; try { (e.target as SVGElement).releasePointerCapture(e.pointerId); } catch { /* pointer already released — safe to ignore */ } dragRef.current = null; }; return ( {(['x', 'y', 'z'] as const).map((axis) => ( ))} {/* Outer hit area (transparent, wider) on top of each visible arrow so the user can grab without pixel precision. Pointer events on the SVG itself are off — the per-handle layer turns them back on. */} {(['x', 'y', 'z'] as const).map((axis) => { const tip = axisTips[axis]; if (!tip) return null; const colour = AXIS_COLORS[axis]; return ( startDrag(axis, e)} onPointerMove={onDragMove} onPointerUp={onDragEnd} onPointerCancel={onDragEnd} /> ); })} {/* Origin handle — small dot that hints "this is what you're moving". */} ); }