partforge 0.67.4 → 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 +1 -1
- package/src/app-demo.js +4 -0
- package/src/framework/annotate/annotate-controls.js +134 -0
- package/src/framework/annotate/annotate-mode.js +165 -0
- package/src/framework/annotate/ink-canvas.js +129 -0
- package/src/framework/annotate/ink.js +124 -0
- package/src/framework/app.css +20 -2
- package/src/framework/chrome.css +18 -0
- package/src/framework/mount.js +84 -9
- package/types/index.d.ts +76 -0
package/package.json
CHANGED
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
|
+
}
|
package/src/framework/app.css
CHANGED
|
@@ -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 -----------------------------------------------------
|
package/src/framework/chrome.css
CHANGED
|
@@ -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; }
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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`. */
|