partforge 0.87.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.
- package/docs/AUTHORING-PARTS.md +39 -3
- package/package.json +1 -1
- package/src/framework/annotate/annotate-controls.js +15 -57
- package/src/framework/annotate/annotate-mode.js +547 -68
- package/src/framework/annotate/elements.js +464 -0
- package/src/framework/annotate/ink-canvas.js +217 -60
- package/src/framework/annotate/sketch-toolbar.js +202 -0
- package/src/framework/app.css +64 -21
- package/src/framework/chrome.css +4 -2
- package/src/framework/mount.js +30 -7
- package/src/framework/oracle/annotation-ray.js +92 -0
- package/src/oracle.js +4 -0
- package/types/oracle.d.ts +3 -0
- package/types/testing.d.ts +15 -0
- package/src/framework/annotate/ink.js +0 -124
|
@@ -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
|
|
3
|
-
// lifecycle, and payload assembly. The overlay
|
|
4
|
-
// enable and kept across toggles;
|
|
5
|
-
//
|
|
6
|
-
// drawn over (deliberately unlike
|
|
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 {
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
export const ANNOTATION_VERSION =
|
|
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
|
|
78
|
+
const store = createElementStore();
|
|
29
79
|
let canvas = null; // lazy; created on first enable
|
|
30
80
|
let enabled = false;
|
|
31
|
-
let
|
|
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
|
|
88
|
+
const notifyTool = () => { for (const cb of [...toolListeners]) cb(); };
|
|
35
89
|
|
|
36
90
|
const rectOf = () => canvas.element.getBoundingClientRect();
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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)
|
|
369
|
+
// (plain MouseEvent, some test environments) behaves normally.
|
|
44
370
|
const onPointerDown = (event) => {
|
|
45
|
-
if (event.isPrimary === false ||
|
|
371
|
+
if (!enabled || event.isPrimary === false || gesture) return;
|
|
46
372
|
const rect = rectOf();
|
|
47
373
|
if (!rect.width || !rect.height) return;
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
379
|
+
syncScene();
|
|
380
|
+
syncCursorClasses();
|
|
52
381
|
};
|
|
382
|
+
|
|
53
383
|
const onPointerMove = (event) => {
|
|
54
|
-
if (!
|
|
55
|
-
const
|
|
56
|
-
|
|
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 (!
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 ||
|
|
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
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
...(
|
|
147
|
-
screen:
|
|
148
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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: () =>
|
|
171
|
-
clear: () =>
|
|
172
|
-
strokeCount: () =>
|
|
631
|
+
undo: () => store.undo(),
|
|
632
|
+
clear: () => store.clear(),
|
|
633
|
+
strokeCount: () => store.count(),
|
|
173
634
|
send,
|
|
174
|
-
|
|
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
|
-
|
|
663
|
+
offStore();
|
|
185
664
|
if (!canvas) return;
|
|
186
665
|
canvas.element.removeEventListener("pointerdown", onPointerDown);
|
|
187
666
|
canvas.element.removeEventListener("pointermove", onPointerMove);
|