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
package/src/framework/mount.js
CHANGED
|
@@ -30,6 +30,7 @@ import { createMeasureMode } from "./measure/measure-mode.js";
|
|
|
30
30
|
import { attachMeasureControls } from "./measure/measure-controls.js";
|
|
31
31
|
import { createAnnotateMode } from "./annotate/annotate-mode.js";
|
|
32
32
|
import { attachAnnotateControls } from "./annotate/annotate-controls.js";
|
|
33
|
+
import { attachSketchToolbar } from "./annotate/sketch-toolbar.js";
|
|
33
34
|
import { attachViewcubeControls } from "./viewcube/viewcube-controls.js";
|
|
34
35
|
|
|
35
36
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
@@ -245,12 +246,12 @@ function createCleanupStack() {
|
|
|
245
246
|
// // KB of base64 apiece, so a host should not assume this
|
|
246
247
|
// // payload is small, only that it is bounded.
|
|
247
248
|
// annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
|
|
248
|
-
// // Send
|
|
249
|
-
// // "host" drops it
|
|
250
|
-
// //
|
|
251
|
-
// //
|
|
252
|
-
// //
|
|
253
|
-
// //
|
|
249
|
+
// // Send in the sketch toolbar alongside the other tools.
|
|
250
|
+
// // "host" drops it: the host draws its own send control —
|
|
251
|
+
// // e.g. a composer that pairs the sketch with a typed
|
|
252
|
+
// // message — and calls runtime.annotate.send() itself.
|
|
253
|
+
// // Ignored without onAnnotationSend (there is no toolbar
|
|
254
|
+
// // to place it in).
|
|
254
255
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
255
256
|
// exactly once here — submodules take element refs and never query the document.
|
|
256
257
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
@@ -431,8 +432,30 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
431
432
|
}
|
|
432
433
|
const annotateChrome = attachAnnotateControls(viewer, annotateMode, {
|
|
433
434
|
annotate: els.chrome.annotate,
|
|
434
|
-
}, { tooltip, escapeScope: els.viewer
|
|
435
|
+
}, { tooltip, escapeScope: els.viewer });
|
|
435
436
|
cleanup.defer(() => annotateChrome.detach());
|
|
437
|
+
// Sketch owns the top of the stage: the toolbar replaces the viewbar while
|
|
438
|
+
// the mode is on (spec 2026-08-27). Restore honors whatever hidden state
|
|
439
|
+
// the host had set before entering. Attached only when annotateMode
|
|
440
|
+
// exists — a mode-less mount (no onAnnotationSend) has nothing for the
|
|
441
|
+
// toolbar to drive.
|
|
442
|
+
if (annotateMode) {
|
|
443
|
+
const sketchToolbar = attachSketchToolbar(annotateMode, {
|
|
444
|
+
stage: els.viewer, tooltip, send: annotateSend,
|
|
445
|
+
});
|
|
446
|
+
cleanup.defer(() => sketchToolbar.detach());
|
|
447
|
+
const viewbarForSketch = els.viewer.querySelector("#viewbar");
|
|
448
|
+
let viewbarWasHidden = false;
|
|
449
|
+
cleanup.defer(annotateMode.onModeChange(() => {
|
|
450
|
+
if (!viewbarForSketch) return;
|
|
451
|
+
if (annotateMode.isEnabled()) {
|
|
452
|
+
viewbarWasHidden = viewbarForSketch.hidden;
|
|
453
|
+
viewbarForSketch.hidden = true;
|
|
454
|
+
} else {
|
|
455
|
+
viewbarForSketch.hidden = viewbarWasHidden;
|
|
456
|
+
}
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
436
459
|
// Orientation cube + projection toggle. Generated chrome — no host markup
|
|
437
460
|
// declares it, so an embedder gets it for free. Restored BEFORE any framing
|
|
438
461
|
// happens so a reload into ortho frames once instead of framing in
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Reconstruct pick rays from a sketch annotation payload (ANNOTATION_VERSION 3)
|
|
2
|
+
// and intersect them with planes — the consumer-side half of the payload's
|
|
3
|
+
// camera block (spec: docs/superpowers/specs/2026-08-28-annotation-ray-design.md).
|
|
4
|
+
//
|
|
5
|
+
// Pure vector math on arrays: no three, no DOM, no node:, no imports at all
|
|
6
|
+
// (worker-layering holds this folder to that). The math mirrors
|
|
7
|
+
// THREE.Raycaster.setFromCamera exactly, with one deliberate normalization:
|
|
8
|
+
// an orthographic ray's origin sits on the plane through the camera POSITION
|
|
9
|
+
// (three puts it on the near plane) — the same canonicalization annotate-mode
|
|
10
|
+
// applies to the rays it embeds per anchor, so embedded and reconstructed rays
|
|
11
|
+
// are definitionally identical. Two stated caveats: perspective assumes
|
|
12
|
+
// camera zoom 1 (the viewer dollies perspective cameras, never zooms them;
|
|
13
|
+
// orthoHeight already folds zoom in at send time), and payload numbers are
|
|
14
|
+
// rounded to 4 decimals, so reconstruction agrees with the live raycaster to
|
|
15
|
+
// ~1e-4 relative — sub-micrometre at part scale.
|
|
16
|
+
|
|
17
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
18
|
+
const add3 = (a, b, c) => [a[0] + b[0] + c[0], a[1] + b[1] + c[1], a[2] + b[2] + c[2]];
|
|
19
|
+
const scale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
20
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
21
|
+
const cross = (a, b) => [
|
|
22
|
+
a[1] * b[2] - a[2] * b[1],
|
|
23
|
+
a[2] * b[0] - a[0] * b[2],
|
|
24
|
+
a[0] * b[1] - a[1] * b[0],
|
|
25
|
+
];
|
|
26
|
+
const norm = (a) => scale(a, 1 / Math.hypot(a[0], a[1], a[2]));
|
|
27
|
+
|
|
28
|
+
// screen: [sx, sy] in the payload's anchor screen frame (nominally 0..1, y
|
|
29
|
+
// down), or any object carrying such a `screen` array (an anchor passes
|
|
30
|
+
// directly). Off-viewport values (e.g. x = 1.03) are legal: the hand tool can
|
|
31
|
+
// move a committed shape partly off-stage, and the projection math is
|
|
32
|
+
// well-defined outside [0, 1] (three's Raycaster extrapolates the same way).
|
|
33
|
+
export function annotationRay(payload, screen, { frame = "parts" } = {}) {
|
|
34
|
+
if (frame !== "parts" && frame !== "world") {
|
|
35
|
+
throw new Error('annotationRay: frame must be "parts" or "world"');
|
|
36
|
+
}
|
|
37
|
+
const s = Array.isArray(screen) ? screen : screen?.screen;
|
|
38
|
+
if (!Array.isArray(s) || s.length !== 2 || !s.every((v) => Number.isFinite(v))) {
|
|
39
|
+
throw new Error("annotationRay: screen must be [x, y] finite numbers");
|
|
40
|
+
}
|
|
41
|
+
if (frame === "parts" && payload?.camera?.parts === null) {
|
|
42
|
+
throw new Error("annotationRay: payload.camera.parts is null — the sketch was sent with no meshes (use { frame: \"world\" })");
|
|
43
|
+
}
|
|
44
|
+
const cam = payload?.camera?.[frame];
|
|
45
|
+
const aspect = payload?.viewport?.aspect;
|
|
46
|
+
if (!cam?.pos || !cam.target || !cam.up || !Number.isFinite(aspect)) {
|
|
47
|
+
throw new Error("annotationRay: payload has no camera/viewport block");
|
|
48
|
+
}
|
|
49
|
+
// Basis orthonormalized the way three's lookAt does it: `up` is a hint, not
|
|
50
|
+
// trusted to be orthogonal to forward.
|
|
51
|
+
const forward = norm(sub(cam.target, cam.pos));
|
|
52
|
+
const right = norm(cross(forward, cam.up));
|
|
53
|
+
const trueUp = cross(right, forward);
|
|
54
|
+
const nx = 2 * s[0] - 1;
|
|
55
|
+
const ny = 1 - 2 * s[1];
|
|
56
|
+
if (cam.projection === "orthographic") {
|
|
57
|
+
const halfH = cam.orthoHeight / 2;
|
|
58
|
+
return {
|
|
59
|
+
origin: add3(cam.pos, scale(right, nx * halfH * aspect), scale(trueUp, ny * halfH)),
|
|
60
|
+
dir: forward,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const t = Math.tan((cam.fov * Math.PI) / 360); // vertical fov, degrees
|
|
64
|
+
return {
|
|
65
|
+
origin: [cam.pos[0], cam.pos[1], cam.pos[2]],
|
|
66
|
+
dir: norm(add3(forward, scale(right, nx * t * aspect), scale(trueUp, ny * t))),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PLANES = {
|
|
71
|
+
xy: { point: [0, 0, 0], normal: [0, 0, 1] },
|
|
72
|
+
yz: { point: [0, 0, 0], normal: [1, 0, 0] },
|
|
73
|
+
zx: { point: [0, 0, 0], normal: [0, 1, 0] },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Miss semantics match the payload's `hit: null`: parallel rays and
|
|
77
|
+
// intersections at/behind the origin return null rather than throwing. `t` is
|
|
78
|
+
// in units of |dir| (unit for payload/annotationRay rays).
|
|
79
|
+
export function rayPlane(ray, plane) {
|
|
80
|
+
if (!Array.isArray(ray?.origin) || !Array.isArray(ray?.dir)) {
|
|
81
|
+
throw new Error("rayPlane: ray must be {origin, dir}");
|
|
82
|
+
}
|
|
83
|
+
const p = typeof plane === "string" ? PLANES[plane] : plane;
|
|
84
|
+
if (!Array.isArray(p?.point) || !Array.isArray(p?.normal)) {
|
|
85
|
+
throw new Error('rayPlane: plane must be {point, normal} or "xy"|"yz"|"zx"');
|
|
86
|
+
}
|
|
87
|
+
const denom = dot(ray.dir, p.normal);
|
|
88
|
+
if (Math.abs(denom) < 1e-9) return null;
|
|
89
|
+
const t = dot(sub(p.point, ray.origin), p.normal) / denom;
|
|
90
|
+
if (t <= 1e-6) return null;
|
|
91
|
+
return { point: add3(ray.origin, scale(ray.dir, t), [0, 0, 0]), t };
|
|
92
|
+
}
|
package/src/oracle.js
CHANGED
|
@@ -29,3 +29,7 @@ export { parse3MF } from "./framework/geometry/threemf-parse.js";
|
|
|
29
29
|
// these, re-exported so a downstream harness can reproduce a score outside the job loop.
|
|
30
30
|
export { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./framework/oracle/silhouette.js";
|
|
31
31
|
export { matchMasks, matchViews } from "./framework/oracle/match.js";
|
|
32
|
+
// Sketch-annotation ray reconstruction — the consumer-side half of the
|
|
33
|
+
// annotation payload's camera block: rebuild the pick ray for any screen
|
|
34
|
+
// point, intersect it with a plane in parts-frame millimetres.
|
|
35
|
+
export { annotationRay, rayPlane } from "./framework/oracle/annotation-ray.js";
|
package/types/oracle.d.ts
CHANGED
|
@@ -19,4 +19,7 @@ export {
|
|
|
19
19
|
// silhouette match scoring
|
|
20
20
|
MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask, matchMasks, matchViews,
|
|
21
21
|
type SilhouetteMask, type MatchScores, type MatchDelta,
|
|
22
|
+
// sketch-annotation rays
|
|
23
|
+
annotationRay, rayPlane,
|
|
24
|
+
type AnnotationRay, type RayPlaneHit, type PlaneSpec,
|
|
22
25
|
} from "./testing.js";
|
package/types/testing.d.ts
CHANGED
|
@@ -499,3 +499,18 @@ export function renderViews(
|
|
|
499
499
|
opacity?: Record<string, number>;
|
|
500
500
|
},
|
|
501
501
|
): Promise<string[]>;
|
|
502
|
+
|
|
503
|
+
// --- sketch-annotation rays --------------------------------------------------
|
|
504
|
+
export interface AnnotationRay { origin: [number, number, number]; dir: [number, number, number] }
|
|
505
|
+
export interface RayPlaneHit { point: [number, number, number]; t: number }
|
|
506
|
+
export type PlaneSpec =
|
|
507
|
+
| { point: [number, number, number]; normal: [number, number, number] }
|
|
508
|
+
| "xy" | "yz" | "zx";
|
|
509
|
+
/** Rebuild the pick ray for a screen point of an ANNOTATION_VERSION 3 payload. */
|
|
510
|
+
export function annotationRay(
|
|
511
|
+
payload: { camera: unknown; viewport: { aspect: number } },
|
|
512
|
+
screen: [number, number] | { screen: [number, number] },
|
|
513
|
+
opts?: { frame?: "parts" | "world" },
|
|
514
|
+
): AnnotationRay;
|
|
515
|
+
/** Intersect a ray with a plane; null on parallel / behind-origin misses. */
|
|
516
|
+
export function rayPlane(ray: AnnotationRay, plane: PlaneSpec): RayPlaneHit | null;
|
|
@@ -1,124 +0,0 @@
|
|
|
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
|
-
}
|