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.
@@ -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 beside Undo/Clear in the annotate actions row.
249
- // // "host" drops it and leaves Undo/Clear: the host draws
250
- // // its own send control — e.g. a composer that pairs the
251
- // // sketch with a typed message — and calls
252
- // // runtime.annotate.send() itself. Ignored without
253
- // // onAnnotationSend (there is no button to place).
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, send: annotateSend });
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";
@@ -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
- }