/* 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/. */ /** * DOM-billboard overlay for annotation pins. * * Sits on top of the WebGPU canvas and re-projects every pin's world * position to screen space each frame via the camera callbacks. * Uses a single rAF loop driven by camera/canvas events (we listen * to a per-frame tick exposed by the camera) so the loop pauses when * nothing's moving — the runtime cost when idle is zero. * * Key invariants: * • The layer is `pointer-events: none` by default. Each pin and * popover opts into `pointer-events: auto` so 3D interactions * (orbit, pan, pick) still pass through the empty space between * pins. * • Only one popover or drop-input is visible at a time. They * anchor to the pin's last projected position and re-anchor as * the camera moves. * • Persistence happens on commit/edit/delete via the slice's * localStorage write — this layer never touches storage directly. */ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useViewerStore } from '@/store'; import { useIfc } from '@/hooks/useIfc'; import type { AnnotationPosition } from '@/store/slices/annotationsSlice'; import { AnnotationPin } from './AnnotationPin'; import { AnnotationPopover } from './AnnotationPopover'; import { AnnotationDropInput } from './AnnotationDropInput'; interface ProjectedPin { id: string; index: number; /** Screen-space position relative to the canvas. Null when behind the camera. */ screen: { x: number; y: number } | null; preview: string; } function makePreview(note: string, maxLen = 60): string { const trimmed = note.trim(); if (trimmed.length === 0) return '(empty note)'; return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}…` : trimmed; } /** * Pins live in the canvas's coordinate space. The wrapping
* matches the canvas's bounding rect; pins are positioned absolutely * within it. We mirror the canvas geometry via a ResizeObserver + * a per-frame projection tick. */ export function AnnotationLayer() { const annotations = useViewerStore((s) => s.annotations); const draft = useViewerStore((s) => s.draft); const selectedAnnotationId = useViewerStore((s) => s.selectedAnnotationId); const selectAnnotation = useViewerStore((s) => s.selectAnnotation); const updateAnnotation = useViewerStore((s) => s.updateAnnotation); const removeAnnotation = useViewerStore((s) => s.removeAnnotation); const commitDraft = useViewerStore((s) => s.commitDraft); const cancelDraft = useViewerStore((s) => s.cancelDraft); const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks); const { ifcDataStore, models } = useIfc(); // Track canvas geometry so the overlay sits exactly on top. const containerRef = useRef(null); const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null); useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const parent = container.parentElement; if (!parent) return; let observer: ResizeObserver | null = null; const measure = (canvas: HTMLCanvasElement) => { const rect = canvas.getBoundingClientRect(); setBounds({ width: rect.width, height: rect.height }); }; const bind = (canvas: HTMLCanvasElement) => { measure(canvas); observer = new ResizeObserver(() => measure(canvas)); observer.observe(canvas); }; const initialCanvas = parent.querySelector('canvas') as HTMLCanvasElement | null; if (initialCanvas) { bind(initialCanvas); return () => observer?.disconnect(); } // Canvas not mounted yet (initial mount before viewport renders) — // watch the parent for the canvas to appear, then bind once it does. const mutationObserver = new MutationObserver(() => { const canvas = parent.querySelector('canvas') as HTMLCanvasElement | null; if (canvas) { bind(canvas); mutationObserver.disconnect(); } }); mutationObserver.observe(parent, { childList: true, subtree: true }); return () => { mutationObserver.disconnect(); observer?.disconnect(); }; }, []); // Stable list view so React doesn't churn when the Map identity // changes but the entries are equal. const annotationList = useMemo(() => Array.from(annotations.values()), [annotations]); // Per-frame projection tick. We don't have a global "camera moved" // event, so a rAF loop is the cheapest way to keep pins glued to // the world. The loop is mostly idle — projection is < 10 µs per // pin and the typical scene has < 20 pins. const [projectedPins, setProjectedPins] = useState([]); const [draftScreen, setDraftScreen] = useState<{ x: number; y: number } | null>(null); useEffect(() => { const project = cameraCallbacks.projectToScreen; if (!project) { setProjectedPins([]); setDraftScreen(null); return; } let raf: number | null = null; let lastSerialized = ''; const tick = () => { const next: ProjectedPin[] = annotationList.map((ann, i) => ({ id: ann.id, index: i + 1, screen: project(ann.position), preview: makePreview(ann.note), })); // Cheap deep-eq check: serialize the screen positions. Skip the // setState when nothing moved, otherwise we re-render every // frame even when the camera is still. const serialized = next.map((p) => `${p.id}:${p.screen?.x ?? 'x'}:${p.screen?.y ?? 'y'}`).join(','); if (serialized !== lastSerialized) { lastSerialized = serialized; setProjectedPins(next); } const draftPos = useViewerStore.getState().draft?.position ?? null; const draftScreenNext = draftPos ? project(draftPos) : null; setDraftScreen((prev) => { if (prev === draftScreenNext) return prev; if (prev && draftScreenNext && prev.x === draftScreenNext.x && prev.y === draftScreenNext.y) { return prev; } return draftScreenNext; }); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => { if (raf !== null) cancelAnimationFrame(raf); }; // The list of annotations is captured per-render via annotationList; // that closure is what the rAF tick reads. Pin position changes // automatically pick up via the next render's loop replacement. }, [cameraCallbacks, annotationList]); const selectedAnnotation = selectedAnnotationId ? annotations.get(selectedAnnotationId) : null; const selectedScreen = useMemo(() => { if (!selectedAnnotation) return null; return projectedPins.find((p) => p.id === selectedAnnotation.id)?.screen ?? null; }, [selectedAnnotation, projectedPins]); // Resolve entity type + id for the popover header. Cheap lookup // against whichever data store the annotation was anchored to. const resolveEntityType = (modelId: string | null, expressId: number | null): string | null => { if (expressId === null) return null; // Federation safety: when the annotation carries a modelId that // isn't in the current `models` map, falling back to // `ifcDataStore` would silently resolve `expressId` against the // wrong model (the same id can exist in many federated models). // The fallback is therefore restricted to single-model sessions. let dataStore: typeof ifcDataStore | null; if (!modelId) { dataStore = ifcDataStore; } else { const scoped = models.get(modelId)?.ifcDataStore; if (scoped) { dataStore = scoped; } else if (models.size <= 1) { dataStore = ifcDataStore; } else { return null; } } if (!dataStore?.entities) return null; return dataStore.entities.getTypeName(expressId) || null; }; if (!bounds) { return
; } return (
{/* Pins */} {projectedPins.map((pin) => { if (!pin.screen) return null; const annotation = annotations.get(pin.id); if (!annotation) return null; const isSelected = selectedAnnotationId === pin.id; return (
selectAnnotation(isSelected ? null : pin.id)} />
); })} {/* Popover for the selected pin */} {selectedAnnotation && selectedScreen && ( updateAnnotation(selectedAnnotation.id, note)} onDelete={() => removeAnnotation(selectedAnnotation.id)} onClose={() => selectAnnotation(null)} /> )} {/* Drop input + ghost pin while drafting */} {draft && draftScreen && ( <>
commitDraft(note)} onCancel={cancelDraft} /> )}
); } export type { AnnotationPosition };