/* 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/. */ /** * Live 3D placement preview for the Add Element tool. * * Renders SVG lines / rectangles / polygons over the canvas, anchored * to renderer-frame world coords pulled from the addElement slice * (`pendingPoints` + `hoverPoint`). Each point is projected to screen * via the camera's `projectToScreen` callback so the preview tracks * the camera in real time. * * What it draws (per element type): * - column: nothing (single click — snap dot is enough) * - wall: first click → marker; on hover → marker → cursor + length * - beam: identical to wall * - slab rectangle: first click → corner marker; on hover → axis- * aligned rectangle with the diagonal, plus W/D readouts * - slab polygon: pending edges + closing-edge ghost back to start * when ≥3 points exist (so the user can preview the close) */ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useViewerStore } from '@/store'; import { useIfc } from '@/hooks/useIfc'; import type { AddElementVec3 } from '@/store/slices/addElementSlice'; type Pt = { x: number; y: number }; type Project = (worldPos: { x: number; y: number; z: number }) => { x: number; y: number } | null; const PRIMARY = '#10b981'; // emerald-500 const PRIMARY_LIGHT = 'rgba(16, 185, 129, 0.18)'; const GHOST = 'rgba(16, 185, 129, 0.45)'; export function AddElementOverlay() { const activeTool = useViewerStore((s) => s.activeTool); const type = useViewerStore((s) => s.addElementType); const slabMode = useViewerStore((s) => s.addElementSlabMode); const pendingPoints = useViewerStore((s) => s.addElementPendingPoints); const hoverPoint = useViewerStore((s) => s.addElementHoverPoint); const autoSpacePreview = useViewerStore((s) => s.addElementAutoSpacePreview); const projectToScreen = useViewerStore((s) => s.cameraCallbacks.projectToScreen); const { models, ifcDataStore } = useIfc(); const addElementModelId = useViewerStore((s) => s.addElementModelId); const activeModelId = useViewerStore((s) => s.activeModelId); // Camera realtime updates intentionally bypass React renders for // performance (see `updateCameraRotationRealtime`), so we drive our // own RAF tick while the tool is active to re-project pending + // hover points each frame. The tick state is just a number that // forces a re-render; the projection itself is read fresh from the // store callback. // // Two perf gates: // 1. Skip the loop entirely when there's nothing to project. // pendingPoints / hoverPoint / autoSpacePreview already trigger // React re-renders via the store, so the only reason we'd need // a per-frame tick is to track the camera while content exists. // 2. Only re-render when the camera actually moved since last tick. // A held tool with a static camera does ~0 work. const getViewpoint = useViewerStore((s) => s.cameraCallbacks.getViewpoint); const hasOverlayContent = pendingPoints.length > 0 || hoverPoint !== null || (autoSpacePreview != null && autoSpacePreview.outlines.length > 0); const [frameTick, setFrameTick] = useState(0); const rafRef = useRef(null); const lastViewpointRef = useRef<{ px: number; py: number; pz: number; tx: number; ty: number; tz: number; fov: number; } | null>(null); useEffect(() => { if (activeTool !== 'addElement') return; if (!hasOverlayContent) return; let mounted = true; const loop = () => { if (!mounted) return; const vp = getViewpoint?.(); if (vp) { const last = lastViewpointRef.current; const moved = !last || last.px !== vp.position.x || last.py !== vp.position.y || last.pz !== vp.position.z || last.tx !== vp.target.x || last.ty !== vp.target.y || last.tz !== vp.target.z || last.fov !== vp.fov; if (moved) { lastViewpointRef.current = { px: vp.position.x, py: vp.position.y, pz: vp.position.z, tx: vp.target.x, ty: vp.target.y, tz: vp.target.z, fov: vp.fov, }; setFrameTick((t) => (t + 1) & 0xffff); } } else { // Fallback for environments without getViewpoint — preserves the // pre-fix behaviour of an unconditional tick so the projection // can't get stuck stale. setFrameTick((t) => (t + 1) & 0xffff); } rafRef.current = requestAnimationFrame(loop); }; rafRef.current = requestAnimationFrame(loop); return () => { mounted = false; if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); rafRef.current = null; lastViewpointRef.current = null; }; }, [activeTool, hasOverlayContent, getViewpoint]); const projection = useMemo( () => makeProjection(projectToScreen), // Re-creating the memoized projection on every tick is wasted — // the underlying function reference rarely changes. We only // depend on `projectToScreen` itself; the RAF tick triggers the // re-render that calls the projection again with current camera. [projectToScreen], ); // Reading frameTick keeps React from optimizing the render away. void frameTick; if (activeTool !== 'addElement') return null; if (!projection) return null; // Resolve storey elevation for the auto-space preview projection. // IFC Z (storey elevation) maps directly to renderer Y (Y-up). let storeyElevation = 0; if (autoSpacePreview) { const effectiveModelId = addElementModelId ?? activeModelId ?? null; const ds = effectiveModelId ? models.get(effectiveModelId)?.ifcDataStore ?? ifcDataStore : ifcDataStore; const elev = ds?.spatialHierarchy?.storeyElevations?.get(autoSpacePreview.storeyExpressId); if (typeof elev === 'number' && Number.isFinite(elev)) storeyElevation = elev; } const ifcToRenderer = (xy: [number, number]) => projection({ x: xy[0], y: storeyElevation, z: -xy[1] }); const screenPending = pendingPoints .map(projection) .filter((p): p is Pt => p !== null); const hover = hoverPoint ? projection(hoverPoint) : null; const hasPreview = !!autoSpacePreview && autoSpacePreview.outlines.length > 0; if (screenPending.length === 0 && !hover && !hasPreview) return null; return ( {/* Hover-ghost for single-click placements — column/door/window. */} {(type === 'column' || type === 'door' || type === 'window') && hoverPoint && ( )} {/* Two-click axial placements share the same start→end preview. */} {type === 'wall' || type === 'beam' || type === 'member' ? ( ) : null} {/* Rectangle profile (slab / roof / plate / space) — flat rect on storey floor. */} {(type === 'slab' || type === 'roof' || type === 'plate' || type === 'space') && slabMode === 'rectangle' ? ( ) : null} {/* Polygon profile (same set of types) — pending polyline + ghost close. */} {(type === 'slab' || type === 'roof' || type === 'plate' || type === 'space') && slabMode === 'polygon' ? ( ) : null} {/* Pending point markers — drawn on top so they're always visible. */} {screenPending.map((p, i) => ( ))} {/* Auto-space preview: candidate outlines from the wall-graph face finder. Distinct from the click-to-place preview to avoid confusion when both are active. */} {hasPreview && autoSpacePreview!.outlines.map((outline, idx) => { const pts: Pt[] = []; for (const xy of outline) { const sp = ifcToRenderer(xy); if (sp) pts.push(sp); } if (pts.length < 3) return null; const polygon = pts.map((p) => `${p.x},${p.y}`).join(' '); const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length; const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length; const region = autoSpacePreview!.regions[idx]; return ( {region && ( )} ); })} ); } /* ------------------------------------------------------------------ */ /* Per-type preview components */ /* ------------------------------------------------------------------ */ function WallBeamPreview({ pending, hover, pendingWorld, hoverWorld, projection, }: { pending: Pt[]; hover: Pt | null; pendingWorld: AddElementVec3[]; hoverWorld: AddElementVec3 | null; projection: Project; }) { if (pending.length === 0 || !hover) return null; const start = pending[0]; const startWorld = pendingWorld[0]; const length = hoverWorld ? worldDistance2D(startWorld, hoverWorld) : 0; const mid = { x: (start.x + hover.x) / 2, y: (start.y + hover.y) / 2 }; // 3D ghost box — read the per-type params from the store so the // outline matches the about-to-commit element's actual size. const ghost = useViewerStore.getState(); const type = ghost.addElementType; const thick = type === 'wall' ? ghost.addElementWallParams.Thickness : type === 'beam' ? ghost.addElementBeamParams.Width : ghost.addElementMemberParams.Width; const height = type === 'wall' ? ghost.addElementWallParams.Height : type === 'beam' ? ghost.addElementBeamParams.Height : ghost.addElementMemberParams.Height; let ghostOutline: string | null = null; if (hoverWorld) { const corners = linearBoxCorners(startWorld, hoverWorld, thick, height); const projected = corners.map((c) => projection({ x: c[0], y: c[1], z: c[2] })); if (projected.every((p): p is Pt => p !== null)) { ghostOutline = projectedHullOutline(projected as Pt[]); } } return ( <> {ghostOutline && ( )} {length > 0.001 &&