import * as THREE from "three"; import type { Annotation, AnnotationMode, ExportOptions } from "./types"; import { AnnotationStore } from "./annotationStore"; export interface SurfaceAnnotatorOptions { scene: THREE.Scene; camera: THREE.PerspectiveCamera; container: HTMLElement; controls: { enabled: boolean; }; mesh: THREE.Mesh; /** Computed from the mesh geometry's bounding-box diagonal when omitted. */ bboxDiagonal?: number; /** * Outward offset of the annotation line from the surface (world units), defaults to bboxDiagonal*0.002. * Providing it separately decouples it from the sampling spacing (minGap/maxJump, still derived from * bboxDiagonal) — useful on bumpy/voxel surfaces where a larger offset is needed to float the line * above the relief without also coarsening the sampling. */ epsilon?: number; /** Freehand contour color, defaults to #e5006e. */ freehandColor?: string; /** Geodesic contour color, defaults to #ffa24e. */ geodesicColor?: string; /** Point-marker color, defaults to #ffd166. */ pointColor?: string; /** Line width (pixels), defaults to 3. */ lineWidth?: number; /** Marker sphere radius, defaults to bboxDiagonal*0.006. */ markerRadius?: number; onModeChange?: (m: AnnotationMode) => void; /** Callback when the annotation list changes (add/remove/undo/clear). */ onChange?: (annotations: Annotation[]) => void; /** Callback when the engine changes the selection itself (delete, deselect-on-navigate) so the UI can mirror it. */ onSelectionChange?: (id: string | null) => void; /** Callback when interaction state changes (drawing, armed tool, draw-lock status, geodesic-editing). */ onInteractionChange?: (s: { drawing: boolean; armed: AnnotationMode; locked: boolean; /** True while a geodesic is editable (in-progress or a re-opened committed one) → its anchors can be dragged/deleted/inserted. */ editing: boolean; }) => void; } /** * Main surface-annotation controller (Phase 4): four modes navigate / freehand / geodesic / point, * Enter closes the loop, and data is managed by AnnotationStore (multiple color-labeled strips, * undo/delete/clear, export). * The render layer reconciles the scene against store.list(); undo/delete automatically add/remove three objects. */ export declare class SurfaceAnnotator { private o; private mode; private spaceHeld; private drawLock; private armed; private spaceDownAt; private spaceDragged; private static readonly TAP_MS; private pointerDown; private readonly markerRadius; private readonly epsilon; private readonly minGap; private readonly maxJump; private graph; private store; private managed; private seq; private selectedId; private activeStroke?; private activeLine?; private lastFreehand?; private activeGeo?; private activeGeoLine?; private activeGeoMarkers; private geoRay; private geoNdc; private hoveredGeoMarker; private draggingAnchor; private geoRedrawScheduled; private editingId; private geoClosed; private _projV; private listenersOn; constructor(opts: SurfaceAnnotatorOptions); /** Block the OS right-click menu when the pointer is over the WebGL canvas (not the HTML panels). */ private onContextMenu; /** Update the pixel resolution of all fat lines on window resize, otherwise line width distorts. */ private onResize; private get freehandColor(); private get geodesicColor(); private get pointColorVal(); getMode(): AnnotationMode; setMode(m: AnnotationMode): void; getStore(): AnnotationStore; /** Snapshot of the current annotation list. */ getAnnotations(): Annotation[]; undo(): void; clearAll(): void; deleteAnnotation(id: string): void; selectAnnotation(id: string | null): void; /** Change the selection from inside the engine (delete / deselect-on-navigate) and mirror it to the UI. */ private setSelected; /** Redraw the corresponding three object after a color change. */ refreshAnnotation(id: string): void; exportJSON(modelName: string, opts?: ExportOptions): { model: string; exportedAt: string; space: "local" | "world"; annotations: Record[]; }; setVisible(id: string, visible: boolean): void; /** Show/hide every annotation in this model in one reconcile (backs the per-model hide-all button). */ setAllVisible(visible: boolean): void; /** * Rebuild annotations from an exported payload (local-space points). Normals are taken from the * point when present ([x,y,z,nx,ny,nz]); otherwise recovered from the welded graph's nearest * vertex. Each imported item becomes first-class (select/recolor/hide/delete/export). */ importAnnotations(payload: { annotations: Array<{ id?: string; type: "contour" | "points"; mode: "freehand" | "geodesic" | null; label: string; color: string; closed: boolean; visible?: boolean; points: number[][]; anchors?: number[][]; }>; }): number; private get drawing(); private applyCameraGating; /** Emit the current interaction state (camera gating + whether a geodesic is editable). */ private emitInteraction; /** Reconcile the scene against store.list(): add missing objects, remove deleted ones (no dispose, kept for undo restore). */ private reconcile; private applySelection; private disposeObject; private nextId; /** Parse a raw exported point ([x,y,z] or [x,y,z,nx,ny,nz], local space) into an AnnotationVertex, recovering the normal from the nearest graph vertex when absent. */ private rawToVertex; private hit; /** * Whether the event target is the WebGL canvas inside the container. * Only accept the canvas: panels (GUIDE / control panel / the ✕ buttons in the annotation list) * are all children of the container, so checking contains alone would treat clicks on the panels * as drawing on the model — which "leaks through" the delete buttons and makes ✕ hard to click. * Responding only to pointer events on the canvas fully isolates the UI from the drawing surface. */ private insideContainer; private onPointerDown; /** After any anchor edit: refresh markers + line; drop/delete the contour when it becomes degenerate. */ private afterGeoEdit; /** Coalesce live drag redraws to one per animation frame (moveAnchorTo runs Dijkstra per move). */ private scheduleGeoRedraw; /** * Pick an in-progress anchor: return the index of the nearest anchor (screen pixel distance < * tolerance), or -1 on a miss. * * Uses "screen-space pixel distance" instead of ray/sphere intersection: the anchor spheres are * tiny, so strict ray/sphere hits require pixel-perfect aiming and most clicks miss — and a miss * falls through to the "add anchor" branch, so trying to delete a point ends up adding one. That * was the root cause of the old "deleting a point takes several clicks, must hit the sphere dead * center" problem. Projecting each anchor to the screen and taking the nearest within tolerance * makes deletion stable and reliable. The tolerance is enlarged relative to the sphere's screen * radius to make it easy to hit. */ private pickGeoMarker; /** * Set the currently hovered anchor (-1 = none): scale it up as a highlight and show a "grab" * cursor to signal it's draggable. Delete is now a right-click (no floating ✕ affordance). */ private setGeoHover; /** Rebuild the visible anchor spheres from the current anchors (slightly larger than placed points, so they're easy to see and grab). */ private rebuildGeoMarkers; /** Redraw the active geodesic from its current anchors (honoring the closed state); when editing a committed contour, also keep the stored annotation data in sync so export stays correct. */ private redrawGeoLine; /** Discard the in-progress geodesic: remove and dispose the line and all anchors. */ private clearActiveGeo; /** Remove all anchor spheres of the in-progress geodesic (called after committing: only the line remains). */ private removeGeoMarkers; private onPointerMove; private onPointerUp; private closeLastContour; /** Compute the geodesic path a→b along the mesh surface (local vertices), dropping the first point that coincides with a. a/b are already local. */ private surfacePathBetween; /** Finish the current geodesic: close it into a ring and commit it as one contour. */ private finishGeodesic; /** * Re-open a committed geodesic contour for editing: rebuild its anchors (from the stored positions) * as draggable markers and reuse its line for live updates. No-op for non-geodesic / anchorless * annotations. The edit session ends on Enter / mode-switch / selecting another / Esc. */ private tryOpenGeoEdit; /** * Finalize an edit session on a committed geodesic: the annotation data was kept in sync live * (redrawGeoLine), so here we just remove the edit markers, notify subscribers, and end the * session — or delete the annotation if it was reduced below 2 anchors. */ private recommitGeodesic; private isTypingTarget; private onKeyDown; private onKeyUp; private addListeners; private removeListeners; /** Attach input listeners and make this annotator live. Idempotent. */ activate(): void; /** Detach input listeners so this annotator ignores input while its lines stay in the scene. Idempotent. */ deactivate(): void; dispose(): void; }