partforge 0.67.3 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.67.3",
3
+ "version": "0.68.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/app-demo.js CHANGED
@@ -14,4 +14,8 @@ import { mount } from "./framework/index.js";
14
14
  window.__pfRuntime = mount(demoPart, {
15
15
  createWorker: (name) =>
16
16
  new Worker(new URL("./demo-worker.js", import.meta.url), { type: "module", name }),
17
+ onAnnotationSend: (payload) => {
18
+ window.__pfLastAnnotation = payload;
19
+ console.log("annotation payload", payload);
20
+ },
17
21
  });
@@ -0,0 +1,134 @@
1
+ // Viewbar chrome for annotation mode: the pencil toggle + contextual actions
2
+ // (Undo / Clear / Send) shown while the mode is on. A direct sibling of
3
+ // measure-controls.js — same no-op-without-button contract, same attribute
4
+ // restore discipline on detach. The mode object (annotate-mode.js) owns all
5
+ // behavior; this file only puts it on screen. One extra contract: a host whose
6
+ // markup HAS the button but whose mount passed no onAnnotationSend gets the
7
+ // button hidden entirely (spec: no dead Send) — mount passes mode = null.
8
+ //
9
+ // `send: "host"` drops the Send button from the row and leaves Undo/Clear.
10
+ // It is for a host that draws its own send affordance (partforge-cloud pairs
11
+ // the sketch with a typed prompt in its own composer, then calls
12
+ // runtime.annotate.send()) — two Send buttons in two places, one of which
13
+ // ignores the typed message, is the failure this avoids.
14
+ import { attachButtonTooltips } from "../tooltip.js";
15
+ import { runCleanupSteps, captureAttributes, restoreAttributes } from "../teardown.js";
16
+
17
+ const BUTTON_ATTRIBUTES = ["type", "aria-pressed", "aria-label", "title", "disabled", "hidden"];
18
+ const PENCIL_ICON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>`;
19
+
20
+ const noop = () => {};
21
+
22
+ export function attachAnnotateControls(viewer, mode, { annotate: button } = {}, { tooltip, escapeScope, send = "viewbar" } = {}) {
23
+ if (!button) return { detach: noop };
24
+
25
+ const hostAttributes = captureAttributes(button, BUTTON_ATTRIBUTES);
26
+ if (!mode) {
27
+ button.hidden = true;
28
+ let restored = false;
29
+ return {
30
+ detach() {
31
+ if (restored) return;
32
+ restored = true;
33
+ restoreAttributes(button, hostAttributes);
34
+ },
35
+ };
36
+ }
37
+ const hostHtml = button.innerHTML;
38
+ const hostOn = button.classList.contains("on");
39
+
40
+ button.type = "button";
41
+ button.innerHTML = PENCIL_ICON;
42
+ button.setAttribute("aria-pressed", "false");
43
+ if (!tooltip && !button.hasAttribute("title")) button.title = "Sketch";
44
+
45
+ const actions = document.createElement("span");
46
+ actions.className = "pf-annotate-actions";
47
+ const undoButton = document.createElement("button");
48
+ undoButton.type = "button";
49
+ undoButton.textContent = "Undo";
50
+ undoButton.title = "Remove the last stroke";
51
+ undoButton.setAttribute("aria-label", "Remove the last stroke");
52
+ const clearButton = document.createElement("button");
53
+ clearButton.type = "button";
54
+ clearButton.textContent = "Clear";
55
+ clearButton.title = "Remove all strokes";
56
+ clearButton.setAttribute("aria-label", "Remove all strokes");
57
+ let sendButton = null;
58
+ if (send !== "host") {
59
+ sendButton = document.createElement("button");
60
+ sendButton.type = "button";
61
+ sendButton.className = "pf-annotate-send";
62
+ sendButton.textContent = "Send";
63
+ sendButton.title = "Send the annotation";
64
+ sendButton.setAttribute("aria-label", "Send the annotation");
65
+ }
66
+ actions.append(...[undoButton, clearButton, sendButton].filter(Boolean));
67
+ button.after(actions);
68
+
69
+ const buttons = [button, undoButton, clearButton, sendButton].filter(Boolean);
70
+ const tooltipBinding = tooltip
71
+ ? attachButtonTooltips(tooltip, buttons.map((element) => ({ element })))
72
+ : null;
73
+
74
+ function sync() {
75
+ const on = mode.isEnabled();
76
+ button.setAttribute("aria-pressed", String(on));
77
+ button.setAttribute("aria-label", on ? "Stop sketching" : "Sketch");
78
+ button.classList.toggle("on", on);
79
+ actions.hidden = !on;
80
+ const empty = mode.strokeCount() === 0;
81
+ undoButton.disabled = empty;
82
+ clearButton.disabled = empty;
83
+ if (sendButton) sendButton.disabled = empty;
84
+ tooltipBinding?.sync();
85
+ }
86
+
87
+ const onToggle = () => { mode.setEnabled(!mode.isEnabled()); sync(); };
88
+ const onUndo = () => { mode.undo(); sync(); };
89
+ const onClear = () => { mode.clear(); sync(); };
90
+ const onSendClick = () => { mode.send(); sync(); };
91
+ const onEscape = (event) => {
92
+ if (event.key !== "Escape" || !mode.isEnabled()) return;
93
+ event.preventDefault();
94
+ // Consume the keystroke — same order-independence contract as
95
+ // measure-controls.js vs cutaway (which covers itself with escapeGuard;
96
+ // mount extends that guard to include annotate).
97
+ event.stopImmediatePropagation();
98
+ mode.setEnabled(false);
99
+ sync();
100
+ tooltipBinding?.hide();
101
+ };
102
+ const offInk = mode.onInkChange(sync);
103
+ const offMode = mode.onModeChange(sync);
104
+
105
+ button.addEventListener("click", onToggle);
106
+ undoButton.addEventListener("click", onUndo);
107
+ clearButton.addEventListener("click", onClear);
108
+ sendButton?.addEventListener("click", onSendClick);
109
+ const escapeTargets = [escapeScope ?? viewer.domElement, ...buttons];
110
+ for (const element of escapeTargets) element.addEventListener("keydown", onEscape);
111
+ sync();
112
+
113
+ let detached = false;
114
+ return {
115
+ detach() {
116
+ if (detached) return;
117
+ detached = true;
118
+ runCleanupSteps([
119
+ offInk,
120
+ offMode,
121
+ () => button.removeEventListener("click", onToggle),
122
+ () => undoButton.removeEventListener("click", onUndo),
123
+ () => clearButton.removeEventListener("click", onClear),
124
+ () => sendButton?.removeEventListener("click", onSendClick),
125
+ ...escapeTargets.map((element) => () => element.removeEventListener("keydown", onEscape)),
126
+ () => tooltipBinding?.detach(),
127
+ () => actions.remove(),
128
+ () => restoreAttributes(button, hostAttributes),
129
+ () => { button.innerHTML = hostHtml; },
130
+ () => button.classList.toggle("on", hostOn),
131
+ ], "annotate control cleanup failed");
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,165 @@
1
+ // Annotation-mode orchestrator — the one annotate module touching both the DOM
2
+ // and the viewer (the measure-mode.js stance). Owns pointer→ink, the mode
3
+ // lifecycle, and payload assembly. The overlay canvas is lazy-created on first
4
+ // enable and kept across toggles; INK is not — exiting the mode discards it,
5
+ // because screen-space ink is only meaningful against the camera pose it was
6
+ // drawn over (deliberately unlike measure pins).
7
+ import * as THREE from "three";
8
+ import { createInkStore, anchorSpecs, DEFAULT_STROKE_WIDTH } from "./ink.js";
9
+ import { createInkCanvas } from "./ink-canvas.js";
10
+ import { raycastViewer } from "../selection/raycast.js";
11
+
12
+ export const ANNOTATION_VERSION = 1;
13
+ // Long-edge bound on BOTH pictures in the payload. The ink canvas is stage
14
+ // sized × devicePixelRatio, so an unbounded send on a large hi-DPI display
15
+ // hands the host a multi-megabyte pair of base64 strings — slow to encode,
16
+ // and past the ceiling a host that ships them anywhere has to enforce. 2048
17
+ // is captureCurrent's own default and comfortably above what any reviewer
18
+ // (human or model) reads a sketch at; a smaller stage exports at its own size
19
+ // and pays nothing.
20
+ const SEND_MAX_EDGE = 2048;
21
+
22
+ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCanvas = createInkCanvas } = {}) {
23
+ const ink = createInkStore();
24
+ let canvas = null; // lazy; created on first enable
25
+ let enabled = false;
26
+ let drawing = false;
27
+ const modeListeners = new Set();
28
+ const notifyMode = () => { for (const cb of [...modeListeners]) cb(); };
29
+ const offInk = ink.onChange(() => canvas?.setStrokes(ink.strokes()));
30
+
31
+ const rectOf = () => canvas.element.getBoundingClientRect();
32
+ const normalized = (event, rect) => [
33
+ Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
34
+ Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
35
+ ];
36
+
37
+ // isPrimary === false (a second simultaneous touch) is ignored; undefined
38
+ // (plain MouseEvent, some test environments) draws normally.
39
+ const onPointerDown = (event) => {
40
+ if (event.isPrimary === false || drawing) return;
41
+ const rect = rectOf();
42
+ if (!rect.width || !rect.height) return;
43
+ drawing = true;
44
+ canvas.element.setPointerCapture?.(event.pointerId);
45
+ const [nx, ny] = normalized(event, rect);
46
+ ink.begin(nx, ny, { width: DEFAULT_STROKE_WIDTH, aspect: rect.width / rect.height });
47
+ };
48
+ const onPointerMove = (event) => {
49
+ if (!drawing || event.isPrimary === false) return;
50
+ const [nx, ny] = normalized(event, rectOf());
51
+ ink.extend(nx, ny);
52
+ };
53
+ const onPointerEnd = (event) => {
54
+ if (!drawing || event.isPrimary === false) return;
55
+ drawing = false;
56
+ ink.end();
57
+ };
58
+
59
+ function ensureCanvas() {
60
+ if (canvas) return;
61
+ canvas = createCanvas(stage);
62
+ canvas.element.addEventListener("pointerdown", onPointerDown);
63
+ canvas.element.addEventListener("pointermove", onPointerMove);
64
+ canvas.element.addEventListener("pointerup", onPointerEnd);
65
+ canvas.element.addEventListener("pointercancel", onPointerEnd);
66
+ }
67
+
68
+ function setEnabled(on) {
69
+ if (on === enabled) return;
70
+ enabled = on;
71
+ if (on) {
72
+ ensureCanvas();
73
+ canvas.show();
74
+ } else {
75
+ drawing = false;
76
+ ink.clear(); // spec: ink never survives an exit
77
+ canvas?.hide();
78
+ }
79
+ notifyMode();
80
+ }
81
+
82
+ // The camera pose in two frames. World replays exactly against THIS build;
83
+ // the parts frame (through the inverse of the shared parts parent's
84
+ // matrixWorld — the measure-mode idiom) stays pinned to the CAD geometry, so
85
+ // it survives the per-view bbox recentring when the model is rebuilt later.
86
+ function cameraBlock() {
87
+ const { pos, target } = viewer.getCameraState();
88
+ const world = { pos, target, up: viewer.camera.up.toArray(), fov: viewer.camera.fov };
89
+ const parent = Object.values(viewer._subMeshes ?? {})[0]?.parent ?? null;
90
+ if (!parent) return { world, parts: null };
91
+ parent.updateWorldMatrix(true, false);
92
+ const inv = parent.matrixWorld.clone().invert();
93
+ const map = (v) => new THREE.Vector3(v[0], v[1], v[2]).applyMatrix4(inv).toArray();
94
+ const up = new THREE.Vector3(world.up[0], world.up[1], world.up[2]).transformDirection(inv).toArray();
95
+ return { world, parts: { pos: map(world.pos), target: map(world.target), up, fov: world.fov } };
96
+ }
97
+
98
+ function send() {
99
+ if (!enabled || ink.isEmpty()) return false;
100
+ const rect = rectOf();
101
+ if (!rect.width || !rect.height) return false;
102
+ const { width, height, dpr } = canvas.size();
103
+ // Model render FIRST: on a lost WebGL context captureCurrent returns null
104
+ // and we abort with the ink intact — nothing is silently dropped.
105
+ const model = viewer.captureCurrent({ size: Math.min(Math.max(width, height), SEND_MAX_EDGE) });
106
+ if (!model) return false;
107
+ const strokes = ink.strokes();
108
+ const aspect = rect.width / rect.height;
109
+ const anchors = strokes.flatMap((stroke, index) =>
110
+ anchorSpecs(stroke.points, aspect).map((spec) => {
111
+ const hit = raycastViewer(
112
+ viewer,
113
+ rect.left + spec.screen[0] * rect.width,
114
+ rect.top + spec.screen[1] * rect.height,
115
+ );
116
+ return {
117
+ stroke: index,
118
+ ...(spec.kind ? { kind: spec.kind } : { t: spec.t }),
119
+ screen: spec.screen,
120
+ // a miss is kept as null — "circled empty space" is signal
121
+ hit: hit ? { subPart: hit.subPart, pointLocal: hit.pointLocal } : null,
122
+ };
123
+ }));
124
+ const { view, params } = getContext();
125
+ onSend?.({
126
+ version: ANNOTATION_VERSION,
127
+ strokes,
128
+ anchors,
129
+ images: { drawing: canvas.toDataUrl({ maxEdge: SEND_MAX_EDGE }), model },
130
+ camera: cameraBlock(),
131
+ viewport: { width: rect.width, height: rect.height, dpr },
132
+ context: { view, params: { ...params } },
133
+ });
134
+ setEnabled(false); // sent: exit and discard
135
+ return true;
136
+ }
137
+
138
+ let detached = false;
139
+ return {
140
+ setEnabled,
141
+ isEnabled: () => enabled,
142
+ undo: () => ink.undo(),
143
+ clear: () => ink.clear(),
144
+ strokeCount: () => ink.strokeCount(),
145
+ send,
146
+ onInkChange: (cb) => ink.onChange(cb),
147
+ onModeChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
148
+ detach() {
149
+ if (detached) return;
150
+ detached = true;
151
+ // Leave the state machine honest: a detach while enabled must not
152
+ // strand isEnabled() at true forever. Runs before listeners/canvas
153
+ // teardown below — setEnabled(false) notifies mode listeners and hides
154
+ // the canvas, both of which still need to be live for this call.
155
+ setEnabled(false);
156
+ offInk();
157
+ if (!canvas) return;
158
+ canvas.element.removeEventListener("pointerdown", onPointerDown);
159
+ canvas.element.removeEventListener("pointermove", onPointerMove);
160
+ canvas.element.removeEventListener("pointerup", onPointerEnd);
161
+ canvas.element.removeEventListener("pointercancel", onPointerEnd);
162
+ canvas.dispose();
163
+ },
164
+ };
165
+ }
@@ -0,0 +1,129 @@
1
+ // The annotation ink layer: a transparent 2D canvas stacked over the viewer
2
+ // canvas (the first screen-space overlay canvas in the framework — everything
3
+ // else that follows the model is in-scene, see dim3-scene.js). Appended to the
4
+ // STAGE, not document.body, so it lives in .pf-stage's positioning context and
5
+ // behaves under the narrow-pane layout. While visible it owns all pointer
6
+ // events, which is what freezes orbit controls during annotation — no viewer
7
+ // changes needed. Strokes render dark-core-over-light-halo so ink reads on
8
+ // both themes and any model color.
9
+ import { runCleanupSteps } from "../teardown.js";
10
+
11
+ const CORE_COLOR = "#d92d20";
12
+ const HALO_COLOR = "rgba(255, 255, 255, 0.85)";
13
+ const HALO_RATIO = 2.2; // halo pass width relative to the core width
14
+
15
+ export function createInkCanvas(stage, {
16
+ getContext2d = (canvas) => canvas.getContext("2d"),
17
+ createCanvas = () => document.createElement("canvas"),
18
+ } = {}) {
19
+ const canvas = createCanvas();
20
+ canvas.className = "pf-ink-canvas";
21
+ canvas.hidden = true;
22
+ stage.appendChild(canvas);
23
+ const ctx = getContext2d(canvas);
24
+ let strokes = [];
25
+
26
+ // Strokes are normalized, so a pass is written against whatever bitmap it is
27
+ // handed — the live canvas, or the scratch one toDataUrl uses to bound an
28
+ // export. Width scales with the target's short edge for the same reason.
29
+ function drawPass(target, targetCtx, color, widthScale) {
30
+ targetCtx.strokeStyle = color;
31
+ targetCtx.fillStyle = color;
32
+ targetCtx.lineCap = "round";
33
+ targetCtx.lineJoin = "round";
34
+ const short = Math.min(target.width, target.height);
35
+ for (const stroke of strokes) {
36
+ const w = stroke.width * short * widthScale;
37
+ if (stroke.points.length === 1) {
38
+ const [nx, ny] = stroke.points[0];
39
+ targetCtx.beginPath();
40
+ targetCtx.arc(nx * target.width, ny * target.height, w / 2, 0, Math.PI * 2);
41
+ targetCtx.fill();
42
+ continue;
43
+ }
44
+ targetCtx.lineWidth = w;
45
+ targetCtx.beginPath();
46
+ stroke.points.forEach(([nx, ny], i) => {
47
+ const x = nx * target.width;
48
+ const y = ny * target.height;
49
+ if (i === 0) targetCtx.moveTo(x, y);
50
+ else targetCtx.lineTo(x, y);
51
+ });
52
+ targetCtx.stroke();
53
+ }
54
+ }
55
+
56
+ function drawInto(targetCtx, target) {
57
+ targetCtx.clearRect(0, 0, target.width, target.height);
58
+ drawPass(target, targetCtx, HALO_COLOR, HALO_RATIO);
59
+ drawPass(target, targetCtx, CORE_COLOR, 1);
60
+ }
61
+
62
+ function draw() {
63
+ if (!ctx) return;
64
+ drawInto(ctx, canvas);
65
+ }
66
+
67
+ function resize() {
68
+ const rect = stage.getBoundingClientRect();
69
+ const dpr = globalThis.devicePixelRatio || 1;
70
+ const width = Math.max(1, Math.round(rect.width * dpr));
71
+ const height = Math.max(1, Math.round(rect.height * dpr));
72
+ if (canvas.width !== width || canvas.height !== height) {
73
+ canvas.width = width;
74
+ canvas.height = height;
75
+ }
76
+ draw();
77
+ }
78
+
79
+ // The viewer's own ResizeObserver is internal (viewer.js exposes no resize
80
+ // hook), so the overlay runs its own — ink is normalized, so a resize is
81
+ // just a re-rasterize at the new bitmap size. Skip it while hidden: the
82
+ // stage keeps resizing (rail drags, window resizes) whether or not
83
+ // annotate mode is on, and re-rasterizing an invisible canvas is wasted
84
+ // work; show() already calls resize() so nothing is missed on re-entry.
85
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
86
+ if (canvas.hidden) return;
87
+ resize();
88
+ });
89
+ observer?.observe(stage);
90
+
91
+ let disposed = false;
92
+ return {
93
+ element: canvas,
94
+ show() { canvas.hidden = false; resize(); },
95
+ hide() { canvas.hidden = true; },
96
+ setStrokes(next) { strokes = next; draw(); },
97
+ // The ink layer as a transparent PNG. `maxEdge` bounds the exported
98
+ // bitmap: the live canvas is stage-sized × devicePixelRatio, so on a large
99
+ // hi-DPI display it runs to several thousand pixels a side, and a PNG that
100
+ // big is both slow to encode and large enough that a host with a payload
101
+ // ceiling would have to drop it — losing the drawing while keeping the
102
+ // picture of the model, which is the one outcome worse than failing. Above
103
+ // the bound the strokes are re-rasterized into a scratch canvas rather than
104
+ // resampled, so thin ink stays crisp instead of turning to mush. Under it
105
+ // (the ordinary case) nothing is copied and the live canvas exports
106
+ // directly.
107
+ toDataUrl({ maxEdge } = {}) {
108
+ const long = Math.max(canvas.width, canvas.height);
109
+ if (!maxEdge || long <= maxEdge) return canvas.toDataURL("image/png");
110
+ const scale = maxEdge / long;
111
+ const scratch = createCanvas();
112
+ scratch.width = Math.max(1, Math.round(canvas.width * scale));
113
+ scratch.height = Math.max(1, Math.round(canvas.height * scale));
114
+ const scratchCtx = getContext2d(scratch);
115
+ if (!scratchCtx) return canvas.toDataURL("image/png");
116
+ drawInto(scratchCtx, scratch);
117
+ return scratch.toDataURL("image/png");
118
+ },
119
+ size: () => ({ width: canvas.width, height: canvas.height, dpr: globalThis.devicePixelRatio || 1 }),
120
+ dispose() {
121
+ if (disposed) return;
122
+ disposed = true;
123
+ runCleanupSteps([
124
+ () => observer?.disconnect(),
125
+ () => canvas.remove(),
126
+ ], "ink canvas cleanup failed");
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,124 @@
1
+ // Pure stroke model for annotation mode: normalized-coordinate polylines with
2
+ // point thinning while drawing, undo/clear, closed-stroke detection and anchor
3
+ // selection. No DOM, no three — unit-testable directly (the feature-dims.js
4
+ // stance). Points are [nx, ny] normalized 0..1 per viewport axis; distances
5
+ // are measured in viewport-DIAGONAL units so thresholds mean the same thing
6
+ // horizontally and vertically regardless of aspect.
7
+
8
+ // Stroke width as a fraction of the viewport's short edge (spec: payload
9
+ // carries this unit so any re-render can reproduce line weight).
10
+ export const DEFAULT_STROKE_WIDTH = 0.004;
11
+ // Spec: endpoints within 5% of the viewport diagonal = closed stroke.
12
+ const CLOSED_THRESHOLD = 0.05;
13
+ // pointermove fires per-pixel; keep only points this far (in diagonal units)
14
+ // from the previous kept point. ~2px at 1080p.
15
+ const MIN_POINT_DISTANCE = 0.0015;
16
+
17
+ export function diagDistance(a, b, aspect = 1) {
18
+ const dx = (a[0] - b[0]) * aspect;
19
+ const dy = a[1] - b[1];
20
+ return Math.hypot(dx, dy) / Math.hypot(aspect, 1);
21
+ }
22
+
23
+ export function createInkStore({ minDistance = MIN_POINT_DISTANCE } = {}) {
24
+ const strokes = [];
25
+ let active = null;
26
+ const listeners = new Set();
27
+ const notify = () => { for (const cb of [...listeners]) cb(); };
28
+ return {
29
+ begin(nx, ny, { width = DEFAULT_STROKE_WIDTH, aspect = 1 } = {}) {
30
+ active = { points: [[nx, ny]], width, aspect };
31
+ strokes.push(active);
32
+ notify();
33
+ },
34
+ extend(nx, ny) {
35
+ if (!active) return;
36
+ const last = active.points[active.points.length - 1];
37
+ if (diagDistance([nx, ny], last, active.aspect) < minDistance) return;
38
+ active.points.push([nx, ny]);
39
+ notify();
40
+ },
41
+ end() {
42
+ if (!active) return;
43
+ active = null; // one-point strokes stay: a click leaves a visible dot
44
+ notify();
45
+ },
46
+ strokes: () => strokes.map((s) => ({ points: s.points.map((p) => [...p]), width: s.width })),
47
+ isEmpty: () => strokes.length === 0,
48
+ strokeCount: () => strokes.length,
49
+ undo() {
50
+ if (!strokes.length) return;
51
+ strokes.pop();
52
+ active = null;
53
+ notify();
54
+ },
55
+ clear() {
56
+ if (!strokes.length && !active) return;
57
+ strokes.length = 0;
58
+ active = null;
59
+ notify();
60
+ },
61
+ onChange(cb) { listeners.add(cb); return () => listeners.delete(cb); },
62
+ };
63
+ }
64
+
65
+ export function pointAt(points, t, aspect = 1) {
66
+ if (points.length === 1) return [...points[0]];
67
+ const lengths = [0];
68
+ for (let i = 1; i < points.length; i++) {
69
+ lengths.push(lengths[i - 1] + diagDistance(points[i], points[i - 1], aspect));
70
+ }
71
+ const total = lengths[lengths.length - 1];
72
+ if (total === 0) return [...points[0]];
73
+ const target = t * total;
74
+ let i = 1;
75
+ while (i < lengths.length - 1 && lengths[i] < target) i++;
76
+ const span = lengths[i] - lengths[i - 1];
77
+ const f = span === 0 ? 0 : (target - lengths[i - 1]) / span;
78
+ const [ax, ay] = points[i - 1];
79
+ const [bx, by] = points[i];
80
+ return [ax + (bx - ax) * f, ay + (by - ay) * f];
81
+ }
82
+
83
+ export function isClosedStroke(points, aspect = 1) {
84
+ if (points.length < 3) return false;
85
+ return diagDistance(points[0], points[points.length - 1], aspect) <= CLOSED_THRESHOLD;
86
+ }
87
+
88
+ // Area-weighted polygon centroid (shoelace); for a degenerate (near-zero-area)
89
+ // point set, fall back to the plain point average.
90
+ export function strokeCentroid(points) {
91
+ let area2 = 0, cx = 0, cy = 0;
92
+ for (let i = 0; i < points.length; i++) {
93
+ const [x0, y0] = points[i];
94
+ const [x1, y1] = points[(i + 1) % points.length];
95
+ const cross = x0 * y1 - x1 * y0;
96
+ area2 += cross;
97
+ cx += (x0 + x1) * cross;
98
+ cy += (y0 + y1) * cross;
99
+ }
100
+ if (Math.abs(area2) < 1e-9) {
101
+ let sx = 0, sy = 0;
102
+ for (const [x, y] of points) { sx += x; sy += y; }
103
+ return [sx / points.length, sy / points.length];
104
+ }
105
+ return [cx / (3 * area2), cy / (3 * area2)];
106
+ }
107
+
108
+ // Anchor sample points for one stroke: start / arc-length-midpoint / end, plus
109
+ // the enclosed-region centroid when the stroke closes on itself ("what did
110
+ // they circle"). A one-point dot gets a single anchor. The orchestrator turns
111
+ // each spec's normalized `screen` point into a raycast.
112
+ export function anchorSpecs(points, aspect = 1) {
113
+ if (points.length === 0) return [];
114
+ if (points.length === 1) return [{ t: 0, screen: [...points[0]] }];
115
+ const specs = [
116
+ { t: 0, screen: [...points[0]] },
117
+ { t: 0.5, screen: pointAt(points, 0.5, aspect) },
118
+ { t: 1, screen: [...points[points.length - 1]] },
119
+ ];
120
+ if (isClosedStroke(points, aspect)) {
121
+ specs.push({ kind: "centroid", screen: strokeCentroid(points) });
122
+ }
123
+ return specs;
124
+ }
@@ -323,6 +323,20 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
323
323
  #viewbar .pf-measure-actions { display: flex; gap: 4px; }
324
324
  #viewbar .pf-measure-actions[hidden] { display: none; }
325
325
  #viewbar .pf-measure-actions button { width: auto; min-width: 56px; padding: 0 8px; }
326
+ #viewbar .pf-annotate-actions { display: flex; gap: 4px; }
327
+ #viewbar .pf-annotate-actions[hidden] { display: none; }
328
+ #viewbar .pf-annotate-actions button { width: auto; min-width: 56px; padding: 0 8px; }
329
+ /* Annotate's actions row has three buttons (Undo/Clear/Send) against
330
+ cutaway's/measure's two, so it is the first to overflow the stage's left
331
+ edge as the viewport narrows — the full-size pill (5 icon buttons + this
332
+ row, ~374px) already exceeds a 375-390px phone's usable width (viewport
333
+ minus the stage's 12px margins on both sides) before the shared 360px
334
+ rule below ever engages. Shrink only this row here; the icon buttons and
335
+ the other two action rows still have room down to 360px. */
336
+ @media (max-width: 430px) {
337
+ #viewbar .pf-annotate-actions { gap: 3px; }
338
+ #viewbar .pf-annotate-actions button { min-width: 40px; padding: 0 5px; font-size: 11px; }
339
+ }
326
340
  #viewbar button:disabled { opacity: .38; cursor: not-allowed; }
327
341
  #viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
328
342
  #viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
@@ -337,8 +351,12 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
337
351
  @media (max-width: 360px) {
338
352
  #viewbar { gap: 3px; }
339
353
  #viewbar button { width: 30px; height: 30px; font-size: 13px; }
340
- #viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions { gap: 3px; }
341
- #viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button { min-width: 44px; padding: 0 6px; }
354
+ #viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions, #viewbar .pf-annotate-actions { gap: 3px; }
355
+ #viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button, #viewbar .pf-annotate-actions button { min-width: 44px; padding: 0 6px; }
356
+ /* Annotate's three-button row (Undo/Clear/Send) is wider than cutaway's or
357
+ measure's two-button rows at the shared size above, so it still clips the
358
+ pill's left edge at 320px — shrink it further than the shared rule. */
359
+ #viewbar .pf-annotate-actions button { min-width: 38px; padding: 0 4px; font-size: 10px; }
342
360
  }
343
361
 
344
362
  /* ---- measurement mode -----------------------------------------------------
@@ -356,3 +356,21 @@
356
356
  @media (prefers-reduced-motion: reduce) {
357
357
  .pf-rail, .pf-rail-seam > span { transition: none; }
358
358
  }
359
+
360
+ /* ---- annotation ink layer: a transparent 2D canvas over the viewer --------
361
+ Shown only while annotation mode is on. It deliberately owns pointer events
362
+ while visible — that is what freezes orbit/pan/zoom during drawing. Below
363
+ the viewbar (z 15) so Undo/Clear/Send stay clickable. */
364
+ .pf-ink-canvas {
365
+ position: absolute;
366
+ inset: 0;
367
+ width: 100%;
368
+ height: 100%;
369
+ z-index: 10;
370
+ /* Pencil cursor (the viewbar toggle's lucide pencil), hotspot at the tip.
371
+ White halo under a dark stroke keeps it readable over both the model and
372
+ the backdrop; crosshair is the no-custom-cursor fallback. */
373
+ cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cg stroke='white' stroke-width='4.5'%3E%3Cpath d='M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z'/%3E%3Cpath d='m15 5 4 4'/%3E%3C/g%3E%3Cg stroke='%231d232b' stroke-width='2'%3E%3Cpath d='M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z'/%3E%3Cpath d='m15 5 4 4'/%3E%3C/g%3E%3C/svg%3E") 2 22, crosshair;
374
+ touch-action: none;
375
+ }
376
+ .pf-ink-canvas[hidden] { display: none; }
@@ -901,6 +901,31 @@ function dropSubresolutionPositiveLoops(out, delta) {
901
901
  });
902
902
  }
903
903
 
904
+ // Positive dilation may also discard whole SUB-SLIVER rings — the multi-segment
905
+ // sibling of the zero-chord splice loops above. The winding resolver can emit
906
+ // entire junk rings a few segments long (measured on "Scott" size 28, delta 5:
907
+ // 14 of 16 holes were resolver debris of 1e-8..1e-5 mm² beside two real ~6-8 mm²
908
+ // counters), and every one of them extrudes into a degenerate fin or sliver face
909
+ // that downstream mesh consumers trip over (the planar rim fillet's knife-edge
910
+ // refusals). The bar is the corpus oracle's own SLIVER convention (1e-3 mm² —
911
+ // test/offset-text.test.js, the fuzz suite): rings under it are "resolver
912
+ // artifacts, not features". The proof this cannot eat real geometry is
913
+ // dilation-only, in two halves: a genuine separate REGION of a dilation is at
914
+ // least the dilation disc (area ≥ π·δ²), and a genuine HOLE under that bar is a
915
+ // counter within a hair of closing — which the oracle already counts as closed.
916
+ // Erosion keeps everything, same as dropSubresolutionPositiveLoops: a tiny
917
+ // surviving island there is real geometry with no source-domain proof otherwise.
918
+ const RING_SLIVER = 1e-3; // mm² — the corpus oracle's sub-sliver bar
919
+ function dropSubSliverRings(out, delta) {
920
+ if (delta <= 0) return out;
921
+ const tiny = (ring) => Math.abs(ringArea(tessellateContour(ring, VALIDATE_SEGS))) < RING_SLIVER;
922
+ return out.flatMap((rg) => {
923
+ if (tiny(rg.outer)) return [];
924
+ const holes = rg.holes.filter((h) => !tiny(h));
925
+ return [holes.length === rg.holes.length ? rg : { outer: rg.outer, holes }];
926
+ });
927
+ }
928
+
904
929
  // Region-in / region-out offset: the engine behind Shape2D.offset on BOTH backends.
905
930
  // Fast path: raw per-ring offsets that validate cleanly are returned as-is (lines/arcs
906
931
  // exact). Cleanup path: anything dirty or invalid goes through resolveOffsetWinding
@@ -928,6 +953,7 @@ export function offsetRegions(regions, delta, { corners = "round" } = {}) {
928
953
  }
929
954
  out = sourceBackedPositiveRegions(regions, out, delta);
930
955
  out = dropSubresolutionPositiveLoops(out, delta);
956
+ out = dropSubSliverRings(out, delta);
931
957
  if (out.length === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
932
958
  return out;
933
959
  }
@@ -572,7 +572,10 @@ export function matchesSelector(chain, sel) {
572
572
  const rot2 = ([x, y], th) => [x * Math.cos(th) - y * Math.sin(th), x * Math.sin(th) + y * Math.cos(th)];
573
573
  function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
574
574
  const c = clamp1(n1[0] * n2[0] + n1[1] * n2[1]);
575
- if (1 + c < 1e-6) throw new UnsupportedEdgeError("~180° knife edge");
575
+ // `knifeEdge` marks the refusal as the anti-parallel-flank degeneracy, so the
576
+ // planar rim machinery can SKIP a noise stretch (a sliver facet's flipped
577
+ // normal) instead of failing the whole selection on it.
578
+ if (1 + c < 1e-6) throw Object.assign(new UnsupportedEdgeError("~180° knife edge"), { knifeEdge: true });
576
579
  const bl = Math.hypot(n1[0] + n2[0], n1[1] + n2[1]);
577
580
  const bis = [(n1[0] + n2[0]) / bl, (n1[1] + n2[1]) / bl];
578
581
  const delta = 0.02 * magnitude;
@@ -859,6 +862,35 @@ function collapseTightCorners(pts0, wallNs0, closed, magnitude) {
859
862
  return { pts: out, wallNs: outWalls };
860
863
  }
861
864
 
865
+ // Weld consecutive coincident chain points (the module's own 1/WELD vertex-identity
866
+ // grid, pivotKey's). collapseTightCorners can land a virtual corner V exactly ON a
867
+ // flanking chain point — an offset outline's micro-spike doubles back through the
868
+ // same vertex, so the flanking edge lines intersect AT it — and a coincident pair
869
+ // becomes a zero-length sweep path segment that k.sweep rejects, failing the whole
870
+ // fillet (the "Scott" offset-backing regression). Dropping the point drops the
871
+ // degenerate segment's WALL, keeping walls one-per-surviving-segment.
872
+ function weldChainPoints(pts, wallNs, closed) {
873
+ const eps = 1 / WELD;
874
+ const outP = [pts[0]], outW = [];
875
+ for (let i = 1; i < pts.length; i++) {
876
+ if (len(sub(pts[i], outP[outP.length - 1])) < eps) continue;
877
+ outP.push(pts[i]);
878
+ outW.push(wallNs[i - 1]); // wall of the span arriving at pts[i]
879
+ }
880
+ if (closed) {
881
+ // The closing segment's wall: the original closing span's — unless the wrap
882
+ // itself welds (last ≈ first), where the popped point's arriving wall is the
883
+ // span that now closes the loop.
884
+ let closingW = wallNs[pts.length - 1];
885
+ while (outP.length > 1 && len(sub(outP[outP.length - 1], outP[0])) < eps) {
886
+ outP.pop();
887
+ closingW = outW.pop();
888
+ }
889
+ outW.push(closingW);
890
+ }
891
+ return { pts: outP, wallNs: outW };
892
+ }
893
+
862
894
  function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null) {
863
895
  const { points, closed, convex, faceN } = chain;
864
896
  let { wallNs } = chain;
@@ -871,6 +903,10 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
871
903
  // bold outlines never hit this because the 0.4 mm round offset pads every
872
904
  // convex radius past the fold threshold).
873
905
  if (convex) ({ pts, wallNs } = collapseTightCorners(pts, wallNs, closed, magnitude));
906
+ ({ pts, wallNs } = weldChainPoints(pts, wallNs, closed));
907
+ // A chain welded below the grid (a sub-micron rim loop — offset-noise islands)
908
+ // has nothing a blend of this magnitude can attach to; skip it rather than fail.
909
+ if (pts.length < (closed ? 3 : 2)) return [];
874
910
  const m = pts.length;
875
911
  const at = (i) => pts[((i % m) + m) % m];
876
912
  const nSeg = closed ? m : m - 1;
@@ -999,9 +1035,48 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
999
1035
  return k.sweep(poly, path3D, { closed: isClosed });
1000
1036
  };
1001
1037
 
1038
+ // Sweep one open stretch; when the sweep refuses a VERTEX fold the pre-split
1039
+ // guard let through — the guard classifies bends by the LOCAL wall normals,
1040
+ // and an offset outline's micro-spike facets carry noise normals that can
1041
+ // read reflex (lenient reach) where the sweep's frame-transported measure is
1042
+ // salient (full magnitude) — split at that exact vertex and sweep the pieces.
1043
+ // That is the same treatment the guard itself would have applied with the
1044
+ // right classification: adjacent stretches mitre into each other across the
1045
+ // split via their overshoots. The sweep is the oracle, so the two can never
1046
+ // disagree into a failure.
1047
+ const buildStretch = (path, wallN, depth = 0) => {
1048
+ try {
1049
+ return [toolFor(overshoot(path), false, wallN)];
1050
+ } catch (e) {
1051
+ // A knife PROFILE here means this stretch's wall is a degenerate sliver's
1052
+ // flipped normal (anti-parallel to the face) — a real rim wall is ~90° to
1053
+ // its face and cannot produce it. The rim piece is sub-resolution noise;
1054
+ // skip it rather than fail every other stretch of the selection.
1055
+ if (e?.knifeEdge) return [];
1056
+ const v = e?.foldVertex;
1057
+ // overshoot() prepended one point, so sweep index v is path index v-1
1058
+ const i = v != null ? v - (over > 0 && path.length >= 2 ? 1 : 0) : null;
1059
+ if (i == null || depth > 16 || !(i > 0 && i < path.length - 1)) throw e;
1060
+ return [
1061
+ ...buildStretch(path.slice(0, i + 1), wallN, depth + 1),
1062
+ ...buildStretch(path.slice(i), wallN, depth + 1),
1063
+ ];
1064
+ }
1065
+ };
1066
+
1002
1067
  try {
1003
1068
  if (closed && breaks.length === 0) {
1004
- return [toolFor(pts.map((p) => [p[0], p[1], p[2]]), true, wallNs[nSeg - 1])];
1069
+ const loop = pts.map((p) => [p[0], p[1], p[2]]);
1070
+ try {
1071
+ return [toolFor(loop, true, wallNs[nSeg - 1])];
1072
+ } catch (e) {
1073
+ if (e?.knifeEdge) return []; // degenerate sliver loop — nothing to blend
1074
+ const v = e?.foldVertex;
1075
+ if (v == null) throw e;
1076
+ // the loop folds at v with no break to split on: open it there and let
1077
+ // buildStretch's splitting take over (the seam gets the overshoot mitre)
1078
+ return buildStretch([...loop.slice(v), ...loop.slice(0, v + 1)], wallNs[v % nSeg]);
1079
+ }
1005
1080
  }
1006
1081
  // Open stretches between breaks. An open chain's endpoints are implicit breaks; a
1007
1082
  // closed chain's stretches wrap from each break to the next.
@@ -1017,7 +1092,7 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
1017
1092
  const aS = arcAt(s), aE = arcAt(e);
1018
1093
  if (aS) path = pullBack(path, aS.t, false);
1019
1094
  if (aE) path = pullBack(path, aE.t, true);
1020
- tools.push(toolFor(overshoot(path), false, wallNs[s % nSeg]));
1095
+ tools.push(...buildStretch(path, wallNs[s % nSeg]));
1021
1096
  }
1022
1097
  for (const got of cornerArcs.values()) {
1023
1098
  tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs));
@@ -1505,7 +1580,20 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
1505
1580
  (endTins.get(kk) ?? endTins.set(kk, []).get(kk)).push(info.tin);
1506
1581
  }
1507
1582
  }
1508
- const { chains: effective, arcs, horns, pivots } = roundSalientCorners(planarized, magnitude);
1583
+ let { chains: effective, arcs, horns, pivots } = roundSalientCorners(planarized, magnitude);
1584
+ // An edge whose two flanks fold back on themselves (anti-parallel normals) is a
1585
+ // zero-thickness fin or slit rim — a self-touching offset outline extrudes these.
1586
+ // There is no wedge between the flanks for a blend to live in (profile2D's own
1587
+ // ~180° knife-edge refusal), so skip the chain rather than fail every OTHER edge
1588
+ // of the selection with it.
1589
+ // Planar variant of the same degeneracy: a zero-area sliver in the face
1590
+ // triangulation flips its facet normal, classifying as a "wall" anti-parallel
1591
+ // to the face — profile2D's projected normals then hit the same refusal.
1592
+ const knife = (ch) => ch.kind === "planar"
1593
+ ? ch.wallNs.every((wn) => dot(ch.faceN, wn) < -1 + 1e-6)
1594
+ : ch.n1 && ch.n2 && dot(ch.n1, ch.n2) < -1 + 1e-6;
1595
+ effective = effective.filter((ch) => !knife(ch));
1596
+ arcs = arcs.filter((ch) => !knife(ch));
1509
1597
  const pSegs = blendSegs(segs, magnitude);
1510
1598
  const toolsFor = (ch) =>
1511
1599
  ch.kind === "planar"
@@ -43,6 +43,13 @@ const rodrigues = (v, k, ang) => {
43
43
  const placeRing = (profile2D, center, N, B) =>
44
44
  profile2D.map(([x, y]) => add(center, add(scl(N, x), scl(B, y))));
45
45
 
46
+ // A per-VERTEX fold refusal (miter would fold / reversal is ambiguous) carries the
47
+ // offending path index as `foldVertex`, so a caller that owns the path can split it
48
+ // there and sweep the pieces instead of failing — mesh-fillet's planar chains do
49
+ // exactly that when their pre-split guard's wall-normal heuristic disagrees with
50
+ // this module's direction-aware measure (offset-outline micro-noise walls).
51
+ const foldError = (message, vtx) => Object.assign(new Error(message), { foldVertex: vtx });
52
+
46
53
  // The seed frame a sweep of `path3D` will start from — exported so a caller authoring a
47
54
  // profile FOR a sweep (mesh-fillet's planar-chain tool) can express it in exactly the
48
55
  // frame the sweep will use, instead of replicating the reference-vector pick and drifting
@@ -91,7 +98,7 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
91
98
  const axisRaw = cross(tIn, tOut), s = vlen(axisRaw);
92
99
  const cdot = Math.max(-1, Math.min(1, dot(tIn, tOut)));
93
100
  if (cdot < -1 + 1e-6)
94
- throw new Error(`sweep: 180° reversal at vertex ${vtx} is ambiguous — insert an intermediate point or use cornerRadius`);
101
+ throw foldError(`sweep: 180° reversal at vertex ${vtx} is ambiguous — insert an intermediate point or use cornerRadius`, vtx);
95
102
  if (s < EPS) { stations.push(placeRing(profile2D, center, N, B)); return; } // collinear: no turn, frame unchanged
96
103
  const axis = scl(axisRaw, 1 / s);
97
104
  const theta = Math.atan2(s, cdot); // exterior turn angle
@@ -127,7 +134,7 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
127
134
  let reachIn = 0;
128
135
  for (const [x, y] of profile2D) reachIn = Math.max(reachIn, x * uN + y * uB);
129
136
  if ((reachIn / cosh) * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
130
- throw new Error(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`);
137
+ throw foldError(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`, vtx);
131
138
  stations.push(profile2D.map(([x, y]) => {
132
139
  const p = add(scl(Nh, x), scl(Bh, y)); // profile point in the miter plane (spanned by u, axis)
133
140
  return add(center, add(scl(axis, dot(p, axis)), scl(u, dot(p, u) / cosh))); // stretch the u component
@@ -28,6 +28,8 @@ import { attachAnimationControls } from "./animation-controls.js";
28
28
  import { resolveDefaultView } from "./default-view.js";
29
29
  import { createMeasureMode } from "./measure/measure-mode.js";
30
30
  import { attachMeasureControls } from "./measure/measure-controls.js";
31
+ import { createAnnotateMode } from "./annotate/annotate-mode.js";
32
+ import { attachAnnotateControls } from "./annotate/annotate-controls.js";
31
33
 
32
34
  // The mount handle, factored out so its shape is unit-testable without booting
33
35
  // the full mount() pipeline (WASM + workers + DOM).
@@ -37,6 +39,14 @@ const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {}
37
39
  // Same no-op-default stance as attachTooltips/setHostPane below, for a
38
40
  // makeHandle caller (or a direct test) that doesn't wire measure mode.
39
41
  const NOOP_MEASURE = { isEnabled: () => false, setEnabled: () => {}, clearPins: () => {}, pinCount: () => 0 };
42
+ // Same stance as NOOP_MEASURE: the handle's annotate surface exists whether or
43
+ // not this mount wired the mode (it wires only when the host passes
44
+ // onAnnotationSend — without a sink, Send would have nowhere to go).
45
+ const NOOP_ANNOTATE = {
46
+ isEnabled: () => false, setEnabled: () => {}, undo: () => {}, clear: () => {},
47
+ strokeCount: () => 0, send: () => false,
48
+ onInkChange: () => () => {}, onModeChange: () => () => {},
49
+ };
40
50
  // The STEP-on-Manifold import crossover's broken-state message (a second
41
51
  // needs-import-mesh after the mesh is already primed — see the "needs-import-mesh"
42
52
  // case below). One shared string so the status line, onBuild, and the ready
@@ -49,7 +59,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
49
59
  // carries the worker's own error text. See the correlated "error" case below.
50
60
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
51
61
 
52
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure }) {
62
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate }) {
53
63
  return {
54
64
  ready, dispose, setParams,
55
65
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -91,6 +101,11 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
91
101
  // button. Dimensions render in the scene, so a dimensioned capture is just
92
102
  // captureCurrent() taken while the mode is on.
93
103
  measure: measure ?? NOOP_MEASURE,
104
+ // Annotation-mode API (spec 2026-08-18): { isEnabled, setEnabled, clear,
105
+ // strokeCount, send, onModeChange } — an embedder drives the mode without
106
+ // the built-in pencil button. send() delivers to onAnnotationSend and
107
+ // returns false when there is no ink or the capture failed.
108
+ annotate: annotate ?? NOOP_ANNOTATE,
94
109
  };
95
110
  }
96
111
 
@@ -182,6 +197,12 @@ function createCleanupStack() {
182
197
  // const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
183
198
  // // OS gave up — surface it rather than showing a dead
184
199
  // // canvas. Returns an unsubscribe.
200
+ // runtime.annotate: { isEnabled, setEnabled, undo, clear, strokeCount, send, onInkChange,
201
+ // onModeChange } — drive annotation mode without the built-in button;
202
+ // // no-op when onAnnotationSend was not supplied. Both
203
+ // // subscribes return an unsubscribe; onInkChange fires on
204
+ // // every stroke/undo/clear, which is what a host driving its
205
+ // // own Send button gates that button on (strokeCount() > 0).
185
206
  // runtime.dispose(); // full teardown
186
207
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
187
208
  // those are repaired in the viewer and produce no build at all.
@@ -194,10 +215,27 @@ function createCleanupStack() {
194
215
  // // is a snapshot copy. Never fired by setParams or
195
216
  // // animation playback — hosts call setParams from their
196
217
  // // own undo/reset, and firing here would loop.
218
+ // onAnnotationSend(payload) // receive user annotations (freehand ink over the frozen
219
+ // // view). Supplying this reveals the #annotate viewbar
220
+ // // button; omitting it hides the button entirely.
221
+ // // payload.images carries two data URLs (the ink drawing
222
+ // // and the rendered model), each bounded to a 2048px long
223
+ // // edge — a stage bigger than that exports scaled down
224
+ // // rather than at its own hi-DPI size. Still hundreds of
225
+ // // KB of base64 apiece, so a host should not assume this
226
+ // // payload is small, only that it is bounded.
227
+ // annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
228
+ // // Send beside Undo/Clear in the annotate actions row.
229
+ // // "host" drops it and leaves Undo/Clear: the host draws
230
+ // // its own send control — e.g. a composer that pairs the
231
+ // // sketch with a typed message — and calls
232
+ // // runtime.annotate.send() itself. Ignored without
233
+ // // onAnnotationSend (there is no button to place).
197
234
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
198
235
  // exactly once here — submodules take element refs and never query the document.
199
236
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
200
- export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit,
237
+ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
238
+ annotateSend = "viewbar",
201
239
  container: legacyContainer, controls: legacyControls } = {}) {
202
240
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
203
241
  const byId = (id) => document.getElementById(id);
@@ -228,6 +266,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
228
266
  theme: elements.chrome?.theme ?? byId("theme"),
229
267
  cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
230
268
  measure: elements.chrome?.measure ?? byId("measure"),
269
+ annotate: elements.chrome?.annotate ?? byId("annotate"),
231
270
  railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
232
271
  },
233
272
  };
@@ -349,6 +388,30 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
349
388
  getParamsVersion: () => loop.version(),
350
389
  });
351
390
  cleanup.defer(() => measureMode.detach());
391
+ // Annotation mode (spec 2026-08-18): freehand ink over the frozen view,
392
+ // delivered to the host via onAnnotationSend. Wired only when the host
393
+ // passes the sink; the chrome hides the button otherwise (mode = null).
394
+ let annotateMode = null;
395
+ if (onAnnotationSend) {
396
+ annotateMode = createAnnotateMode(viewer, {
397
+ stage: els.viewer,
398
+ getContext: () => ({ view: view(), params }),
399
+ onSend: onAnnotationSend,
400
+ });
401
+ cleanup.defer(() => annotateMode.detach());
402
+ // Annotate and measure both claim canvas pointer input — mutually
403
+ // exclusive, whichever turns on turns the other off.
404
+ cleanup.defer(annotateMode.onModeChange(() => {
405
+ if (annotateMode.isEnabled()) measureMode.setEnabled(false);
406
+ }));
407
+ cleanup.defer(measureMode.onModeChange(() => {
408
+ if (measureMode.isEnabled()) annotateMode.setEnabled(false);
409
+ }));
410
+ }
411
+ const annotateChrome = attachAnnotateControls(viewer, annotateMode, {
412
+ annotate: els.chrome.annotate,
413
+ }, { tooltip, escapeScope: els.viewer, send: annotateSend });
414
+ cleanup.defer(() => annotateChrome.detach());
352
415
  // escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
353
416
  // #viewbar, not descendants of the canvas — attaching Escape to
354
417
  // viewer.domElement alone would leave a guarded Escape from those buttons
@@ -360,13 +423,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
360
423
  cleanup.defer(() => measureChrome.detach());
361
424
  const cutawayChrome = attachCutawayControls(viewer, {
362
425
  cutaway: els.chrome.cutaway,
363
- }, { tooltip, escapeGuard: () => measureMode.isEnabled() });
426
+ }, { tooltip, escapeGuard: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false) });
364
427
  cleanup.defer(() => cutawayChrome.detach());
365
- // Suppress the always-on hover tooltip while measure mode is active — its
366
- // own feature highlight + dims take over the pointer.
367
- const offMeasureHover = measureMode.onModeChange(() =>
368
- hover.setSuppressed(measureMode.isEnabled()));
369
- cleanup.defer(offMeasureHover);
428
+ // Suppress the always-on hover tooltip while measure OR annotate mode is
429
+ // active measure's highlight + dims take the pointer; annotate's overlay
430
+ // canvas takes it entirely.
431
+ const syncHoverSuppression = () =>
432
+ hover.setSuppressed(measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false));
433
+ cleanup.defer(measureMode.onModeChange(syncHoverSuppression));
434
+ if (annotateMode) cleanup.defer(annotateMode.onModeChange(syncHoverSuppression));
370
435
 
371
436
  // Current selection context for the pickers: the active view + live params +
372
437
  // derived values. Shared by every pick mode below.
@@ -392,7 +457,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
392
457
  // resync on mode changes. The ?pick/?pickserver harnesses below are
393
458
  // deliberately not guarded — one is armed by an explicit dev toggle,
394
459
  // the other per agent request.
395
- suppressed: () => measureMode.isEnabled(),
460
+ suppressed: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
396
461
  onPick: (selection) => onPick({
397
462
  selection,
398
463
  label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
@@ -869,6 +934,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
869
934
  clearPins: measureMode.clearPins,
870
935
  pinCount: measureMode.pinCount,
871
936
  },
937
+ annotate: annotateMode ? {
938
+ isEnabled: annotateMode.isEnabled,
939
+ setEnabled: annotateMode.setEnabled,
940
+ undo: annotateMode.undo,
941
+ clear: annotateMode.clear,
942
+ strokeCount: annotateMode.strokeCount,
943
+ send: annotateMode.send,
944
+ onInkChange: annotateMode.onInkChange,
945
+ onModeChange: annotateMode.onModeChange,
946
+ } : null,
872
947
  });
873
948
  } catch (error) {
874
949
  try {
package/types/index.d.ts CHANGED
@@ -88,6 +88,7 @@ export interface MountElements {
88
88
  theme?: HTMLElement | null;
89
89
  cutaway?: HTMLElement | null;
90
90
  measure?: HTMLElement | null;
91
+ annotate?: HTMLElement | null;
91
92
  railToggle?: HTMLElement | null;
92
93
  };
93
94
  }
@@ -110,6 +111,19 @@ export interface MountOptions {
110
111
  onDownload?: (file: DownloadPayload) => void;
111
112
  /** The active view (tab) name — emitted once on mount, then on every change. */
112
113
  onViewChange?: (view: string) => void;
114
+ /**
115
+ * Receive user annotations (freehand ink over the frozen view). Supplying
116
+ * this reveals the `#annotate` viewbar button; omitting it hides the button
117
+ * entirely.
118
+ */
119
+ onAnnotationSend?: (payload: AnnotationPayload) => void;
120
+ /**
121
+ * Who owns annotation mode's Send affordance. `"viewbar"` (the default) puts
122
+ * Send beside Undo/Clear in the actions row. `"host"` drops it and leaves
123
+ * Undo/Clear: the host draws its own send control — e.g. a composer pairing
124
+ * the sketch with a typed message — and calls `runtime.annotate.send()`.
125
+ */
126
+ annotateSend?: "viewbar" | "host";
113
127
  /** @deprecated alias for `elements.viewer`. */
114
128
  container?: HTMLElement | null;
115
129
  /** @deprecated alias for `elements.controls`. */
@@ -156,6 +170,66 @@ export interface MeasureRuntime {
156
170
  pinCount(): number;
157
171
  }
158
172
 
173
+ /** One freehand stroke: points normalized 0..1 in viewport space; width as a
174
+ * fraction of the viewport's short edge. */
175
+ export interface AnnotationStroke {
176
+ points: [number, number][];
177
+ width: number;
178
+ }
179
+
180
+ /** A raycast sample grounding a stroke in the model. `t` anchors sit at the
181
+ * stroke's start/mid/end by arc length; `kind: "centroid"` anchors sit at the
182
+ * enclosed-region centroid of a closed stroke. `hit` is null when the sample
183
+ * ray missed all geometry — a deliberate signal, not an error. */
184
+ export interface AnnotationAnchor {
185
+ stroke: number;
186
+ t?: number;
187
+ kind?: "centroid";
188
+ screen: [number, number];
189
+ hit: { subPart: string; pointLocal: [number, number, number] } | null;
190
+ }
191
+
192
+ /** A camera pose. `world` replays exactly against the annotated build; `parts`
193
+ * is the same pose in the shared CAD frame (survives per-view recentring when
194
+ * the model is rebuilt), or null when no meshes were live. */
195
+ export interface AnnotationCamera {
196
+ world: { pos: number[]; target: number[]; up: number[]; fov: number };
197
+ parts: { pos: number[]; target: number[]; up: number[]; fov: number } | null;
198
+ }
199
+
200
+ /** What onAnnotationSend receives. The drawing and the 3D render are separate
201
+ * images over the same framing, so a host can composite them now and
202
+ * re-render the model from the same camera against later updates. */
203
+ export interface AnnotationPayload {
204
+ version: 1;
205
+ strokes: AnnotationStroke[];
206
+ anchors: AnnotationAnchor[];
207
+ /** Two base64 data URLs. On a large hi-DPI stage the drawing PNG alone can
208
+ * run several MB of base64 — hosts should not assume this payload is small. */
209
+ images: { drawing: string; model: string };
210
+ camera: AnnotationCamera;
211
+ viewport: { width: number; height: number; dpr: number };
212
+ context: { view: string; params: Record<string, unknown> };
213
+ }
214
+
215
+ export interface AnnotateRuntime {
216
+ isEnabled(): boolean;
217
+ setEnabled(on: boolean): void;
218
+ /** Drop the most recent stroke. */
219
+ undo(): void;
220
+ clear(): void;
221
+ strokeCount(): number;
222
+ /**
223
+ * Assemble the payload and hand it to `onAnnotationSend`, then exit the mode
224
+ * and discard the ink. Returns false — delivering nothing and keeping the ink
225
+ * — when the mode is off, the canvas is empty, or the render failed.
226
+ */
227
+ send(): boolean;
228
+ /** Every stroke, undo and clear. Returns an unsubscribe. */
229
+ onInkChange(cb: () => void): () => void;
230
+ onModeChange(cb: () => void): () => void;
231
+ }
232
+
159
233
  /** Where playback is: idle, swinging the camera to an intro cue, playing, or paused. */
160
234
  export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
161
235
 
@@ -269,6 +343,8 @@ export interface PartRuntime {
269
343
  animation: AnimationRuntime | null;
270
344
  /** Measurement mode's runtime-controls API — mode on/off, unit, and pin state; dimensioned captures come from `captureCurrent()` while enabled. Always present (a no-op stand-in outside `makeHandle` tests). */
271
345
  measure: MeasureRuntime;
346
+ /** Annotation mode's runtime-controls API — mode on/off, ink state, and send. Always present; a no-op stand-in when `onAnnotationSend` was not supplied. */
347
+ annotate: AnnotateRuntime;
272
348
  }
273
349
 
274
350
  /** Mount a full parametric-part app from a `PartDefinition`. */