/* 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 };