partforge 0.86.0 → 0.88.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.
@@ -1,20 +1,49 @@
1
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).
2
+ // and the viewer (the measure-mode.js stance). Owns pointer -> tool state
3
+ // machine -> elements, the mode lifecycle, and payload assembly. The overlay
4
+ // canvas is lazy-created on first enable and kept across toggles; the ELEMENT
5
+ // LIST is not exiting the mode discards it, because screen-space ink is only
6
+ // meaningful against the camera pose it was drawn over (deliberately unlike
7
+ // measure pins). The active tool and color DO persist across an enable cycle
8
+ // within a session — cheap continuity, no spec reason to reset them.
7
9
  import * as THREE from "three";
8
- import { createInkStore, anchorSpecs, DEFAULT_STROKE_WIDTH } from "./ink.js";
10
+ import {
11
+ createElementStore, DEFAULT_STROKE_WIDTH, INK_COLORS,
12
+ rectFromDrag, ellipseFromDrag, lineFromDrag, appendThinned,
13
+ probe, handlesOf, centerOf, translateElement, rectAnchorFor,
14
+ resizeRectFromAnchor, resizeEllipseHandle, applyRotation,
15
+ eraseSegment, describeElement, elementAnchors, visibleFraction,
16
+ } from "./elements.js";
9
17
  import { createInkCanvas } from "./ink-canvas.js";
10
18
  import { raycastViewer } from "../selection/raycast.js";
11
19
 
12
- // v2 added the camera block's `projection` / `orthoHeight` and made `fov`
13
- // nullable: the viewer gained an orthographic camera, and a user can switch to
14
- // it and THEN open Sketch. An additive optional field alone would have left any
15
- // consumer that reconstructs the camera from `fov` silently wrong rather than
16
- // loudly broken, so the version moves.
17
- export const ANNOTATION_VERSION = 2;
20
+ // v3: strokes -> typed elements (rect/ellipse/line/freehand), each carrying
21
+ // its own params, gaps (eraser spans) and a plain-language description the
22
+ // payload shape this file assembles in send() below. A consumer that reads
23
+ // `strokes`/`anchors[].stroke` off the old shape must be told loudly, hence
24
+ // the version bump rather than an additive field.
25
+ export const ANNOTATION_VERSION = 3;
26
+ // Self-describing coordinate legend, shipped verbatim in every payload as
27
+ // `frames`. Three frames coexist in the payload (params are stage-space,
28
+ // anchor screens are per-axis normalized, descriptions are viewport
29
+ // percentages) — this block is what stops a consumer from conflating them.
30
+ // Keys mirror the payload paths they describe.
31
+ const FRAME_LEGEND = Object.freeze({
32
+ "elements[].params":
33
+ "stage space: y 0..1 top-down, x 0..aspect (see viewport.aspect); sizes and radii in the same units; rot in radians (rotDeg is the same angle in degrees)",
34
+ "elements[].anchors[].screen":
35
+ "[x, y] normalized to the viewport (0..1 inside it; values outside mean the point sits off-stage) — x = stage x / aspect",
36
+ "elements[].anchors[].ray":
37
+ "origin (mm) and unit dir in the parts frame — intersect with a plane (partforge/oracle's rayPlane) to place sketch geometry; present only when the model had meshes at send time",
38
+ "elements[].description":
39
+ "positions and sizes as % of viewport width/height, radii as % of the short edge, angles in degrees",
40
+ "elements[].erased":
41
+ "[start, end] spans 0..1 in the element's own domain — line/freehand: fraction along the path; rect: perimeter clockwise from the top-left corner; ellipse: fraction of a full turn from 3 o'clock, screen-clockwise, before rotation",
42
+ "elements[].anchors[].hit":
43
+ "pointLocal is millimetres in the named sub-part's local frame; null means the anchor points at empty space",
44
+ camera:
45
+ "pos/target in millimetres — world replays against this exact build, parts is pinned to the CAD geometry and survives rebuilds",
46
+ });
18
47
  // Long-edge bound on BOTH pictures in the payload. The ink canvas is stage
19
48
  // sized × devicePixelRatio, so an unbounded send on a large hi-DPI display
20
49
  // hands the host a multi-megabyte pair of base64 strings — slow to encode,
@@ -24,43 +53,416 @@ export const ANNOTATION_VERSION = 2;
24
53
  // and pays nothing.
25
54
  const SEND_MAX_EDGE = 2048;
26
55
 
56
+ // Pixel thresholds for the hand tool's hit-testing and the eraser brush,
57
+ // converted to stage units per event by dividing by rect.height (the same
58
+ // factor stagePoint uses for both axes — see elements.js's header comment on
59
+ // why stage space is aspect-uniform under that convention).
60
+ const HANDLE_PX = 8;
61
+ const ROTATE_BAND_PX = 22;
62
+ const ERASER_PX = 16;
63
+ // One cursor-distance magnet for every snap: the line's 0/45/90° snap, the
64
+ // rect/ellipse 1:1 snap while drawing, and the hand-tool resize snaps all
65
+ // engage when the cursor is within this many CSS pixels of the snapped shape.
66
+ const SNAP_PX = 8;
67
+ const MIN_DRAG_PX = 6; // sub-6px shape drags commit nothing
68
+ const FREEHAND_MIN_DIST = 0.003; // stage units (~ink.js's old thinning at aspect 1)
69
+
70
+ const stagePoint = (event, rect) => [
71
+ Math.min(rect.width / rect.height, Math.max(0, (event.clientX - rect.left) / rect.height)),
72
+ Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
73
+ ];
74
+ const stageUnits = (px, rect) => px / rect.height;
75
+ const strokeWidthPx = (rect) => DEFAULT_STROKE_WIDTH * Math.min(rect.width, rect.height);
76
+
27
77
  export function createAnnotateMode(viewer, { stage, getContext, onSend, createCanvas = createInkCanvas } = {}) {
28
- const ink = createInkStore();
78
+ const store = createElementStore();
29
79
  let canvas = null; // lazy; created on first enable
30
80
  let enabled = false;
31
- let drawing = false;
81
+ let tool = "pen";
82
+ let color = "red";
83
+ let gesture = null; // the in-flight pointer gesture, or null between gestures
84
+ let hoverProbe = null; // hand tool: what's under the pointer right now (no gesture)
32
85
  const modeListeners = new Set();
86
+ const toolListeners = new Set();
33
87
  const notifyMode = () => { for (const cb of [...modeListeners]) cb(); };
34
- const offInk = ink.onChange(() => canvas?.setStrokes(ink.strokes()));
88
+ const notifyTool = () => { for (const cb of [...toolListeners]) cb(); };
35
89
 
36
90
  const rectOf = () => canvas.element.getBoundingClientRect();
37
- const normalized = (event, rect) => [
38
- Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
39
- Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
40
- ];
91
+
92
+ // ---- scene sync ------------------------------------------------------
93
+ // One function, called on every store change (via the subscription below)
94
+ // AND every gesture frame that doesn't touch the store — a draw-tool
95
+ // preview, the eraser ring following the pointer, hover feedback.
96
+ function syncScene() {
97
+ canvas?.setScene({
98
+ elements: gesture?.preview ? [...store.list(), gesture.preview] : store.list(),
99
+ overlay: buildOverlay(),
100
+ });
101
+ }
102
+ const offStore = store.onChange(syncScene);
103
+
104
+ function reachParams(rect) {
105
+ return {
106
+ reach: Math.max(stageUnits(10, rect), 1.5 * strokeWidthPx(rect) / rect.height),
107
+ handleR: stageUnits(HANDLE_PX, rect),
108
+ band: stageUnits(ROTATE_BAND_PX, rect),
109
+ };
110
+ }
111
+
112
+ // ---- draw-tool preview + overlay label helpers ------------------------
113
+ function buildPreview(kind, x0, y0, x, y, event) {
114
+ if (kind === "rect") {
115
+ const { params } = rectFromDrag(x0, y0, x, y, { force: event.shiftKey, snapDistance: stageUnits(SNAP_PX, rectOf()) });
116
+ return { type: "rect", color, width: DEFAULT_STROKE_WIDTH, params, gaps: [] };
117
+ }
118
+ if (kind === "ellipse") {
119
+ const { params } = ellipseFromDrag(x0, y0, x, y, { force: event.shiftKey, snapDistance: stageUnits(SNAP_PX, rectOf()) });
120
+ return { type: "ellipse", color, width: DEFAULT_STROKE_WIDTH, params, gaps: [] };
121
+ }
122
+ const { params } = lineFromDrag(x0, y0, x, y, {
123
+ force: event.shiftKey,
124
+ snapDistance: stageUnits(SNAP_PX, rectOf()),
125
+ });
126
+ return { type: "line", color, width: DEFAULT_STROKE_WIDTH, params, gaps: [] };
127
+ }
128
+
129
+ // Pixel-value label text, distinct from describeElement (which is
130
+ // percentage-based and lives in the send() payload) — the brief wants the
131
+ // live overlay readable at a glance while dragging.
132
+ function shapeLabelText(el, rect) {
133
+ const toPx = (v) => Math.round(v * rect.height);
134
+ const p = el.params;
135
+ if (el.type === "rect") {
136
+ return p.w === p.h ? `${toPx(p.w)} · square` : `${toPx(p.w)} × ${toPx(p.h)}`;
137
+ }
138
+ if (el.type === "ellipse") {
139
+ return p.rx === p.ry ? `r ${toPx(p.rx)}` : `rx ${toPx(p.rx)} ry ${toPx(p.ry)}`;
140
+ }
141
+ if (el.type === "line") {
142
+ const len = Math.round(Math.hypot(p.x2 - p.x1, p.y2 - p.y1) * rect.height);
143
+ const angle = Math.round(Math.atan2(p.y2 - p.y1, p.x2 - p.x1) * 180 / Math.PI);
144
+ return `${len} · ${angle}°`;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function shapeGuide(el) {
150
+ const p = el.params;
151
+ if (el.type === "rect") return { kind: "rect", cx: p.cx, cy: p.cy, w: p.w, h: p.h };
152
+ if (el.type === "ellipse") return { kind: "rect", cx: p.cx, cy: p.cy, w: p.rx * 2, h: p.ry * 2 };
153
+ if (el.type === "line") return { kind: "cross", cx: p.x2, cy: p.y2 };
154
+ return null;
155
+ }
156
+
157
+ function shapeLabelAnchor(el) {
158
+ const p = el.params;
159
+ if (el.type === "rect") return [p.cx + p.w / 2, p.cy - p.h / 2];
160
+ if (el.type === "ellipse") return [p.cx + p.rx, p.cy - p.ry];
161
+ // Line: the MIDPOINT, like a CAD dimension riding the line — the endpoint
162
+ // is exactly the cursor, so a label anchored there always fights it.
163
+ return [(p.x1 + p.x2) / 2, (p.y1 + p.y2) / 2];
164
+ }
165
+
166
+ function activeHandlePos(el, handleId) {
167
+ // A dragged line endpoint IS the cursor; keep line labels at the midpoint.
168
+ if (el.type === "line") return shapeLabelAnchor(el);
169
+ const h = handlesOf(el).find((candidate) => candidate.id === handleId);
170
+ return h ? [h.x, h.y] : shapeLabelAnchor(el);
171
+ }
172
+
173
+ function drawPreviewOverlay() {
174
+ const rect = rectOf();
175
+ const el = gesture.preview;
176
+ const text = shapeLabelText(el, rect);
177
+ const [lx, ly] = shapeLabelAnchor(el);
178
+ return { guide: shapeGuide(el), label: text ? { x: lx, y: ly, text } : undefined };
179
+ }
180
+
181
+ function handEditOverlay() {
182
+ const rect = rectOf();
183
+ const text = shapeLabelText(gesture.el, rect);
184
+ let label;
185
+ if (text) {
186
+ // Anchor at the handle actually being dragged when there is one, so the
187
+ // label tracks the corner/endpoint under the pointer rather than a
188
+ // fixed corner of the shape.
189
+ const [lx, ly] = gesture.handleId
190
+ ? activeHandlePos(gesture.el, gesture.handleId)
191
+ : shapeLabelAnchor(gesture.el);
192
+ label = { x: lx, y: ly, text };
193
+ }
194
+ return { glowEl: gesture.el, handlesEl: gesture.el, label };
195
+ }
196
+
197
+ function rotateOverlay() {
198
+ const deg = Math.round((gesture.total || 0) * 180 / Math.PI);
199
+ const sign = deg > 0 ? "+" : "";
200
+ const [cx, cy] = gesture.center;
201
+ return {
202
+ glowEl: gesture.el,
203
+ label: { x: cx, y: cy, text: `${sign}${deg}°` },
204
+ };
205
+ }
206
+
207
+ function buildOverlay() {
208
+ if (gesture) {
209
+ switch (gesture.kind) {
210
+ case "line": case "rect": case "ellipse":
211
+ return drawPreviewOverlay();
212
+ case "hand-move": case "hand-endpoint": case "hand-resize-rect": case "hand-resize-ellipse":
213
+ return handEditOverlay();
214
+ case "hand-rotate":
215
+ return rotateOverlay();
216
+ default:
217
+ return {};
218
+ }
219
+ }
220
+ if (tool === "hand" && hoverProbe) {
221
+ const overlay = { glowEl: hoverProbe.el };
222
+ if (hoverProbe.kind === "handle" || hoverProbe.kind === "outline") overlay.handlesEl = hoverProbe.el;
223
+ return overlay;
224
+ }
225
+ return {};
226
+ }
227
+
228
+ // ---- hand-tool gesture construction ------------------------------------
229
+ function buildHandGesture(p, x, y) {
230
+ if (p.kind === "outline") {
231
+ return { kind: "hand-move", el: p.el, lastX: x, lastY: y, mutatesStore: true };
232
+ }
233
+ if (p.kind === "handle") {
234
+ if (p.el.type === "line") {
235
+ return { kind: "hand-endpoint", el: p.el, handleId: p.handle.id, mutatesStore: true };
236
+ }
237
+ if (p.el.type === "rect") {
238
+ return {
239
+ kind: "hand-resize-rect", el: p.el,
240
+ anchor: rectAnchorFor(p.el, p.handle), rot: p.el.params.rot || 0,
241
+ handleId: p.handle.id, mutatesStore: true,
242
+ };
243
+ }
244
+ return { kind: "hand-resize-ellipse", el: p.el, handleId: p.handle.id, mutatesStore: true }; // ellipse
245
+ }
246
+ // rotate
247
+ const center = centerOf(p.el);
248
+ const a0 = Math.atan2(y - center[1], x - center[0]);
249
+ return { kind: "hand-rotate", el: p.el, center, a0, orig: structuredClone(p.el.params), mutatesStore: true };
250
+ }
251
+
252
+ function sameProbe(a, b) {
253
+ if (a === b) return true;
254
+ if (!a || !b) return false;
255
+ return a.kind === b.kind && a.el === b.el && a.handle?.id === b.handle?.id;
256
+ }
257
+
258
+ function updateHover(x, y, rect) {
259
+ const next = probe(store.list(), x, y, reachParams(rect));
260
+ if (sameProbe(hoverProbe, next)) return;
261
+ hoverProbe = next;
262
+ syncScene();
263
+ syncCursorClasses();
264
+ }
265
+
266
+ // ---- ink-canvas cursor classes -----------------------------------------
267
+ // Chrome-only (no geometry here): sketch-toolbar.css keys off these to draw
268
+ // the hand tool's grab/handle/rotate/grabbing states and the eraser's blank
269
+ // cursor. `dragging` is deliberately narrower than "any hand gesture in
270
+ // flight" — it means specifically a translate (hand-move); a resize/
271
+ // endpoint/rotate gesture leaves whatever handle/rotate class was already
272
+ // set by the hover that started it, because updateHover() is only called
273
+ // between gestures (see onPointerMove) and hoverProbe is never cleared when
274
+ // a gesture begins or ends.
275
+ function syncCursorClasses() {
276
+ if (!canvas) return;
277
+ const cl = canvas.element.classList;
278
+ cl.toggle("hand", tool === "hand");
279
+ cl.toggle("erasing", tool === "eraser");
280
+ const dragging = gesture?.kind === "hand-move";
281
+ cl.toggle("dragging", dragging);
282
+ const kind = tool === "hand" && !dragging ? hoverProbe?.kind : null;
283
+ cl.toggle("over", kind === "outline");
284
+ cl.toggle("handle", kind === "handle");
285
+ cl.toggle("rotate", kind === "rotate");
286
+ }
287
+
288
+ // ---- pointer routing ---------------------------------------------------
289
+ function beginGesture(x, y, event, rect) {
290
+ if (tool === "pen") {
291
+ store.snapshot();
292
+ const el = { type: "freehand", color, width: DEFAULT_STROKE_WIDTH, params: { points: [[x, y]] }, gaps: [] };
293
+ store.add(el);
294
+ // Pen snapshots and mutates at pointerdown exactly like a hand edit —
295
+ // the in-progress points ARE the committed element the whole time —
296
+ // so Escape must roll it back the same way, not just drop it.
297
+ return { kind: "pen", el, mutatesStore: true };
298
+ }
299
+ if (tool === "line" || tool === "rect" || tool === "ellipse") {
300
+ return { kind: tool, x0: x, y0: y, preview: buildPreview(tool, x, y, x, y, event) };
301
+ }
302
+ if (tool === "eraser") {
303
+ store.snapshot();
304
+ return { kind: "eraser", lastX: x, lastY: y, mutatesStore: true };
305
+ }
306
+ // hand
307
+ const p = probe(store.list(), x, y, reachParams(rect));
308
+ if (!p) return null;
309
+ store.snapshot();
310
+ return buildHandGesture(p, x, y);
311
+ }
312
+
313
+ function handleGestureMove(x, y, event, rect) {
314
+ switch (gesture.kind) {
315
+ case "pen": {
316
+ const added = appendThinned(gesture.el.params.points, x, y, FREEHAND_MIN_DIST);
317
+ if (added) store.touch(gesture.el);
318
+ return;
319
+ }
320
+ case "line": case "rect": case "ellipse": {
321
+ gesture.preview = buildPreview(gesture.kind, gesture.x0, gesture.y0, x, y, event);
322
+ syncScene();
323
+ return;
324
+ }
325
+ case "eraser": {
326
+ const radius = stageUnits(ERASER_PX, rect);
327
+ const halfWidth = stageUnits(strokeWidthPx(rect) / 2, rect);
328
+ const result = eraseSegment(store.list(), gesture.lastX, gesture.lastY, x, y, { radius, halfWidth });
329
+ if (result.changed) store.setList(result.list); // notifies -> syncScene
330
+ gesture.lastX = x; gesture.lastY = y;
331
+ return;
332
+ }
333
+ case "hand-move": {
334
+ translateElement(gesture.el, x - gesture.lastX, y - gesture.lastY);
335
+ gesture.lastX = x; gesture.lastY = y;
336
+ store.touch(gesture.el);
337
+ return;
338
+ }
339
+ case "hand-endpoint": {
340
+ const p = gesture.el.params;
341
+ if (gesture.handleId === "p1") { p.x1 = x; p.y1 = y; } else { p.x2 = x; p.y2 = y; }
342
+ store.touch(gesture.el);
343
+ return;
344
+ }
345
+ case "hand-resize-rect": {
346
+ resizeRectFromAnchor(gesture.el, gesture.anchor[0], gesture.anchor[1], gesture.rot, x, y, { force: event.shiftKey, snapDistance: stageUnits(SNAP_PX, rectOf()) });
347
+ store.touch(gesture.el);
348
+ return;
349
+ }
350
+ case "hand-resize-ellipse": {
351
+ resizeEllipseHandle(gesture.el, gesture.handleId, x, y, { force: event.shiftKey, snapDistance: stageUnits(SNAP_PX, rectOf()) });
352
+ store.touch(gesture.el);
353
+ return;
354
+ }
355
+ case "hand-rotate": {
356
+ const a = Math.atan2(y - gesture.center[1], x - gesture.center[0]);
357
+ let total = a - gesture.a0;
358
+ if (event.shiftKey) total = Math.round(total / (Math.PI / 12)) * (Math.PI / 12);
359
+ gesture.total = total;
360
+ applyRotation(gesture.el, gesture.orig, gesture.center, total);
361
+ store.touch(gesture.el);
362
+ return;
363
+ }
364
+ default:
365
+ }
366
+ }
41
367
 
42
368
  // isPrimary === false (a second simultaneous touch) is ignored; undefined
43
- // (plain MouseEvent, some test environments) draws normally.
369
+ // (plain MouseEvent, some test environments) behaves normally.
44
370
  const onPointerDown = (event) => {
45
- if (event.isPrimary === false || drawing) return;
371
+ if (!enabled || event.isPrimary === false || gesture) return;
46
372
  const rect = rectOf();
47
373
  if (!rect.width || !rect.height) return;
48
- drawing = true;
374
+ const [x, y] = stagePoint(event, rect);
375
+ const next = beginGesture(x, y, event, rect);
376
+ if (!next) return;
377
+ gesture = next;
49
378
  canvas.element.setPointerCapture?.(event.pointerId);
50
- const [nx, ny] = normalized(event, rect);
51
- ink.begin(nx, ny, { width: DEFAULT_STROKE_WIDTH, aspect: rect.width / rect.height });
379
+ syncScene();
380
+ syncCursorClasses();
52
381
  };
382
+
53
383
  const onPointerMove = (event) => {
54
- if (!drawing || event.isPrimary === false) return;
55
- const [nx, ny] = normalized(event, rectOf());
56
- ink.extend(nx, ny);
384
+ if (!enabled || event.isPrimary === false) return;
385
+ const rect = rectOf();
386
+ if (!rect.width || !rect.height) return;
387
+ const [x, y] = stagePoint(event, rect);
388
+ if (gesture) { handleGestureMove(x, y, event, rect); return; }
389
+ if (tool === "hand") updateHover(x, y, rect);
390
+ // eraser hover needs no per-move work: the brush ring is a CSS cursor
57
391
  };
392
+
58
393
  const onPointerEnd = (event) => {
59
- if (!drawing || event.isPrimary === false) return;
60
- drawing = false;
61
- ink.end();
394
+ if (!gesture || event.isPrimary === false) return;
395
+ const rect = rectOf();
396
+ const [x, y] = stagePoint(event, rect);
397
+ canvas.element.releasePointerCapture?.(event.pointerId);
398
+ if (gesture.kind === "line" || gesture.kind === "rect" || gesture.kind === "ellipse") {
399
+ const dxPx = (x - gesture.x0) * rect.height;
400
+ const dyPx = (y - gesture.y0) * rect.height;
401
+ if (Math.hypot(dxPx, dyPx) >= MIN_DRAG_PX) {
402
+ store.snapshot();
403
+ store.add(gesture.preview);
404
+ }
405
+ }
406
+ gesture = null;
407
+ syncScene();
408
+ syncCursorClasses();
409
+ };
410
+
411
+ // Escape cancels an in-flight gesture, or — with none in flight — exits the
412
+ // mode outright. Hand edits, the eraser, and pen all snapshot and mutate
413
+ // the store at pointerdown, so they roll back via store.undo(); a draw-tool
414
+ // preview never touches the store (that only happens at pointerup), so
415
+ // dropping the gesture is enough there.
416
+ //
417
+ // The ink canvas (.pf-ink-canvas) is never focusable — it has no tabindex
418
+ // and is never document.activeElement — so a keydown listener on
419
+ // canvas.element itself can never fire from a real keystroke. Real Escape
420
+ // keystrokes land wherever focus actually is and reach us only via
421
+ // capture, which is why this listens on the canvas's ownerDocument in the
422
+ // CAPTURE phase: document is the outermost node, so this runs before any
423
+ // phase happens anywhere else in the tree.
424
+ //
425
+ // This mode now OWNS Escape-driven exit, rather than deferring to the
426
+ // chrome (annotate-controls.js's bubble-phase handler on
427
+ // viewer.domElement/button). While sketch mode is on, the sketch toolbar
428
+ // replaces #viewbar at the top of the stage (sketch-toolbar.js / mount.js),
429
+ // so the pencil toggle that used to hold that Escape handler is hidden and
430
+ // unreachable — focus has nowhere to land that bubbles through it. This
431
+ // document-capture listener is the only reliable keyboard path left, so it
432
+ // has to do the exiting itself: when there is no gesture, and the mode is
433
+ // enabled, Escape exits the mode. Either way (gesture cancelled, or mode
434
+ // exited) the event is consumed here (preventDefault + stopPropagation, the
435
+ // latter during capture halting capture/target/bubble entirely) so it can
436
+ // never also reach downstream chrome that treats Escape as ITS exit key
437
+ // (cutaway, measure) — a single Escape press must resolve to exactly one
438
+ // effect.
439
+ const onEscapeCapture = (event) => {
440
+ if (event.key !== "Escape") return;
441
+ // A host composer (partforge-cloud shows one during sketch, to gather
442
+ // notes alongside the drawing) can be focused while sketch mode is still
443
+ // enabled. Escape there is the user editing text, not a request to leave
444
+ // sketch mode — since exiting calls store.reset() and discards the whole
445
+ // drawing irrecoverably, this guard must not act or consume the event,
446
+ // so it falls through to the field's own (or the browser's) handling.
447
+ const target = event.target;
448
+ const tag = target?.tagName;
449
+ if (target?.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
450
+ if (gesture) {
451
+ if (gesture.mutatesStore) store.undo();
452
+ gesture = null;
453
+ syncScene();
454
+ syncCursorClasses();
455
+ } else {
456
+ // This listener is only attached while enabled (see setEnabled below),
457
+ // so reaching here with no gesture means: exit the mode.
458
+ setEnabled(false);
459
+ }
460
+ event.preventDefault();
461
+ event.stopPropagation();
62
462
  };
63
463
 
464
+ let escapeDoc = null; // the document currently holding the capture-phase Escape listener
465
+
64
466
  function ensureCanvas() {
65
467
  if (canvas) return;
66
468
  canvas = createCanvas(stage);
@@ -76,11 +478,17 @@ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCa
76
478
  if (on) {
77
479
  ensureCanvas();
78
480
  canvas.show();
481
+ escapeDoc = canvas.element.ownerDocument;
482
+ escapeDoc?.addEventListener("keydown", onEscapeCapture, true);
79
483
  } else {
80
- drawing = false;
81
- ink.clear(); // spec: ink never survives an exit
484
+ gesture = null;
485
+ hoverProbe = null;
486
+ store.reset(); // spec: ink never survives an exit; tool/color do
82
487
  canvas?.hide();
488
+ escapeDoc?.removeEventListener("keydown", onEscapeCapture, true);
489
+ escapeDoc = null;
83
490
  }
491
+ syncCursorClasses();
84
492
  notifyMode();
85
493
  }
86
494
 
@@ -88,24 +496,35 @@ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCa
88
496
  // the parts frame (through the inverse of the shared parts parent's
89
497
  // matrixWorld — the measure-mode idiom) stays pinned to the CAD geometry, so
90
498
  // it survives the per-view bbox recentring when the model is rebuilt later.
91
- function cameraBlock() {
499
+ // The shared parts parent's inverse world matrix, or null when no meshes
500
+ // exist. cameraBlock() and send()'s anchor-ray loop both map through this —
501
+ // one definition keeps the payload's parts frame single-sourced.
502
+ function partsInverse() {
503
+ const parent = Object.values(viewer._subMeshes ?? {})[0]?.parent ?? null;
504
+ if (!parent) return null;
505
+ parent.updateWorldMatrix(true, false);
506
+ return parent.matrixWorld.clone().invert();
507
+ }
508
+
509
+ function cameraBlock(inv) {
92
510
  const { pos, target } = viewer.getCameraState();
93
511
  const cam = viewer.camera;
94
512
  const ortho = !!cam.isOrthographicCamera;
513
+ // 4 decimals ≈ 0.1 µm at mm scale — far below anything a sketch encodes,
514
+ // and it keeps float dust (1e-16 up-vector components) out of what an LLM
515
+ // reads.
516
+ const r4 = (v) => v.map((q) => +q.toFixed(4));
95
517
  const world = {
96
- pos,
97
- target,
98
- up: cam.up.toArray(),
518
+ pos: r4(pos),
519
+ target: r4(target),
520
+ up: r4(cam.up.toArray()),
99
521
  projection: ortho ? "orthographic" : "perspective",
100
- fov: ortho ? null : cam.fov,
101
- orthoHeight: ortho ? Math.abs(cam.top - cam.bottom) / Math.max(cam.zoom, 1e-6) : null,
522
+ fov: ortho ? null : +cam.fov.toFixed(4),
523
+ orthoHeight: ortho ? +(Math.abs(cam.top - cam.bottom) / Math.max(cam.zoom, 1e-6)).toFixed(4) : null,
102
524
  };
103
- const parent = Object.values(viewer._subMeshes ?? {})[0]?.parent ?? null;
104
- if (!parent) return { world, parts: null };
105
- parent.updateWorldMatrix(true, false);
106
- const inv = parent.matrixWorld.clone().invert();
107
- const map = (v) => new THREE.Vector3(v[0], v[1], v[2]).applyMatrix4(inv).toArray();
108
- const up = new THREE.Vector3(world.up[0], world.up[1], world.up[2]).transformDirection(inv).toArray();
525
+ if (!inv) return { world, parts: null };
526
+ const map = (v) => r4(new THREE.Vector3(v[0], v[1], v[2]).applyMatrix4(inv).toArray());
527
+ const up = r4(new THREE.Vector3(world.up[0], world.up[1], world.up[2]).transformDirection(inv).toArray());
109
528
  return {
110
529
  world,
111
530
  parts: {
@@ -124,39 +543,81 @@ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCa
124
543
  }
125
544
 
126
545
  function send() {
127
- if (!enabled || ink.isEmpty()) return false;
546
+ if (!enabled || store.isEmpty()) return false;
128
547
  const rect = rectOf();
129
548
  if (!rect.width || !rect.height) return false;
130
549
  const { width, height, dpr } = canvas.size();
131
550
  // Model render FIRST: on a lost WebGL context captureCurrent returns null
132
- // and we abort with the ink intact — nothing is silently dropped.
551
+ // and we abort with the elements intact — nothing is silently dropped.
133
552
  const model = viewer.captureCurrent({ size: Math.min(Math.max(width, height), SEND_MAX_EDGE) });
134
553
  if (!model) return false;
135
- const strokes = ink.strokes();
136
554
  const aspect = rect.width / rect.height;
137
- const anchors = strokes.flatMap((stroke, index) =>
138
- anchorSpecs(stroke.points, aspect).map((spec) => {
139
- const hit = raycastViewer(
140
- viewer,
141
- rect.left + spec.screen[0] * rect.width,
142
- rect.top + spec.screen[1] * rect.height,
143
- );
555
+ const round4 = (v) => +v.toFixed(4);
556
+ const roundParams = (p) => Object.fromEntries(Object.entries(p).map(([k, v]) =>
557
+ [k, Array.isArray(v) ? v.map((q) => q.map(round4)) : round4(v)]));
558
+ // rot ships in both units: radians (`rot`, the internal representation)
559
+ // and degrees folded to (-180, 180] (`rotDeg`, matching the description).
560
+ const withRotDeg = (p) => {
561
+ if (!("rot" in p)) return p;
562
+ let d = (p.rot * 180) / Math.PI % 360;
563
+ if (d > 180) d -= 360;
564
+ if (d <= -180) d += 360;
565
+ return { ...p, rotDeg: +d.toFixed(2) };
566
+ };
567
+ const inv = partsInverse();
568
+ // Per-anchor pick rays, from the LIVE camera (the exact code path that
569
+ // produces `hit`), origin canonicalized to the point on the ray line
570
+ // nearest the camera position — a no-op for perspective, and for
571
+ // orthographic it slides three's near-plane origin onto the plane through
572
+ // the camera position, making the embedded ray definitionally identical
573
+ // to annotationRay's reconstruction (spec: annotation-ray design).
574
+ const rayCaster = new THREE.Raycaster();
575
+ const anchorRay = (screen) => {
576
+ if (!inv) return null; // no parts frame → no ray (same rule as hits)
577
+ rayCaster.setFromCamera(new THREE.Vector2(2 * screen[0] - 1, 1 - 2 * screen[1]), viewer.camera);
578
+ const { origin, direction } = rayCaster.ray;
579
+ const along = viewer.camera.position.clone().sub(origin).dot(direction);
580
+ origin.add(direction.clone().multiplyScalar(along));
581
+ origin.applyMatrix4(inv);
582
+ direction.transformDirection(inv); // rigid → exact for directions
583
+ return { origin: origin.toArray().map(round4), dir: direction.toArray().map(round4) };
584
+ };
585
+ const elements = store.list().map((el, i) => ({
586
+ id: `e${i + 1}`, // stable within this payload — how a reply names an element
587
+ type: el.type,
588
+ color: { name: el.color, hex: INK_COLORS[el.color] },
589
+ width: el.width,
590
+ params: withRotDeg(el.type === "ellipse" && el.params.rx === el.params.ry
591
+ ? { cx: round4(el.params.cx), cy: round4(el.params.cy), r: round4(el.params.rx), rot: round4(el.params.rot || 0), circle: true }
592
+ : el.type === "rect"
593
+ ? { ...roundParams(el.params), square: el.params.w === el.params.h }
594
+ : roundParams(el.params)),
595
+ erased: el.gaps.map(([a, b]) => [round4(a), round4(b)]),
596
+ visibleFraction: +visibleFraction(el).toFixed(3),
597
+ description: `${el.color} ${describeElement(el, aspect)}`,
598
+ anchors: elementAnchors(el).map(({ at, run, x, y }) => {
599
+ const screen = [x / aspect, y]; // per-axis normalized, the v2 screen frame
600
+ const hit = raycastViewer(viewer,
601
+ rect.left + screen[0] * rect.width, rect.top + screen[1] * rect.height);
602
+ const ray = anchorRay(screen);
144
603
  return {
145
- stroke: index,
146
- ...(spec.kind ? { kind: spec.kind } : { t: spec.t }),
147
- screen: spec.screen,
148
- // a miss is kept as null "circled empty space" is signal
604
+ at,
605
+ ...(run !== undefined ? { run } : {}), // center anchors span all runs
606
+ screen: screen.map(round4),
607
+ ...(ray ? { ray } : {}), // omitted, not null, when no parts frame
149
608
  hit: hit ? { subPart: hit.subPart, pointLocal: hit.pointLocal } : null,
150
609
  };
151
- }));
610
+ }),
611
+ }));
152
612
  const { view, params } = getContext();
153
613
  onSend?.({
154
614
  version: ANNOTATION_VERSION,
155
- strokes,
156
- anchors,
615
+ summary: `${elements.length} annotation${elements.length === 1 ? "" : "s"}: ${elements.map((e) => e.description).join("; ")}`,
616
+ frames: FRAME_LEGEND,
617
+ elements,
157
618
  images: { drawing: canvas.toDataUrl({ maxEdge: SEND_MAX_EDGE }), model },
158
- camera: cameraBlock(),
159
- viewport: { width: rect.width, height: rect.height, dpr },
619
+ camera: cameraBlock(inv),
620
+ viewport: { width: rect.width, height: rect.height, aspect: round4(aspect), dpr },
160
621
  context: { view, params: { ...params } },
161
622
  });
162
623
  setEnabled(false); // sent: exit and discard
@@ -167,12 +628,30 @@ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCa
167
628
  return {
168
629
  setEnabled,
169
630
  isEnabled: () => enabled,
170
- undo: () => ink.undo(),
171
- clear: () => ink.clear(),
172
- strokeCount: () => ink.strokeCount(),
631
+ undo: () => store.undo(),
632
+ clear: () => store.clear(),
633
+ strokeCount: () => store.count(),
173
634
  send,
174
- onInkChange: (cb) => ink.onChange(cb),
635
+ setTool(next) {
636
+ if (next === tool) return;
637
+ tool = next;
638
+ gesture = null;
639
+ hoverProbe = null;
640
+ syncScene();
641
+ syncCursorClasses();
642
+ notifyTool();
643
+ },
644
+ tool: () => tool,
645
+ setColor(next) {
646
+ if (next === color) return;
647
+ color = next;
648
+ notifyTool();
649
+ },
650
+ color: () => color,
651
+ canUndo: () => store.canUndo(),
652
+ onInkChange: (cb) => store.onChange(cb),
175
653
  onModeChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
654
+ onToolChange: (cb) => { toolListeners.add(cb); return () => toolListeners.delete(cb); },
176
655
  detach() {
177
656
  if (detached) return;
178
657
  detached = true;
@@ -181,7 +660,7 @@ export function createAnnotateMode(viewer, { stage, getContext, onSend, createCa
181
660
  // teardown below — setEnabled(false) notifies mode listeners and hides
182
661
  // the canvas, both of which still need to be live for this call.
183
662
  setEnabled(false);
184
- offInk();
663
+ offStore();
185
664
  if (!canvas) return;
186
665
  canvas.element.removeEventListener("pointerdown", onPointerDown);
187
666
  canvas.element.removeEventListener("pointermove", onPointerMove);