partforge 0.86.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ // Framing math for the showcase capture (viewer.js captureCurrentFromScene):
2
+ // where the visible geometry lands in the rendered frame, and the centred
3
+ // sub-window that puts it in the middle. Pure three.js math, no GL — the
4
+ // renderer builds its camera through makeCaptureCamera too, so what this
5
+ // module projects through is exactly what renderOffscreen draws with.
6
+ //
7
+ // Why vertices and not bounding boxes: the projection of a triangle mesh is a
8
+ // union of projected triangles, and a triangle's projected extremes are its
9
+ // corners — so the min/max over projected VERTICES is the exact 2-D extent of
10
+ // the rendered silhouette at any angle, where a projected 3-D bounding box
11
+ // over-reports by up to the box's slack at diagonal views.
12
+ //
13
+ // Why a view offset and not a pixel crop: three's setViewOffset renders a
14
+ // sub-window of a larger virtual frame with the same projection, so the
15
+ // recentred image is a pixel-exact crop of what the user framed — at the full
16
+ // requested resolution and with no second JPEG encode.
17
+ import * as THREE from "three";
18
+
19
+ const NEAR = 0.1;
20
+ const FAR = 1000;
21
+
22
+ // The temp camera an offscreen capture renders with. `aspect` is the FULL
23
+ // frame's aspect; a recentred sub-window is applied afterwards by the caller
24
+ // via setViewOffset, which (for a PerspectiveCamera) keeps this aspect as the
25
+ // virtual full frame's. Matrices are updated so a caller can project through
26
+ // matrixWorldInverse without a render having happened first.
27
+ export function makeCaptureCamera(
28
+ { position, up, target },
29
+ { aspect = 1, fov = 45, projection = "perspective", orthoHalfH = 1 } = {},
30
+ ) {
31
+ const cam = projection === "orthographic"
32
+ ? new THREE.OrthographicCamera(-orthoHalfH * aspect, orthoHalfH * aspect, orthoHalfH, -orthoHalfH, NEAR, FAR)
33
+ : new THREE.PerspectiveCamera(fov, aspect, NEAR, FAR);
34
+ cam.position.set(position[0], position[1], position[2]);
35
+ cam.up.set(up[0], up[1], up[2]);
36
+ cam.lookAt(target[0], target[1], target[2]);
37
+ cam.updateMatrixWorld(true);
38
+ return cam;
39
+ }
40
+
41
+ // Exact 2-D extent of the meshes' projected vertices, as fractions of the
42
+ // frame with a top-left origin ({ left, top, right, bottom }; values outside
43
+ // [0, 1] mean the geometry runs past that edge). Null when there is nothing
44
+ // to project, or when ANY vertex would be clipped by the frustum's near/far
45
+ // planes or sits behind the camera — such a vertex is not in the picture, so
46
+ // no honest extent exists and the caller should leave the framing alone.
47
+ export function projectedExtent(camera, meshes) {
48
+ const toClip = new THREE.Matrix4();
49
+ const v = new THREE.Vector4();
50
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
51
+ let any = false;
52
+ for (const mesh of meshes ?? []) {
53
+ const pos = mesh?.geometry?.attributes?.position;
54
+ if (!pos || !pos.count) continue;
55
+ mesh.updateWorldMatrix(true, false);
56
+ toClip.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse).multiply(mesh.matrixWorld);
57
+ for (let i = 0; i < pos.count; i++) {
58
+ v.set(pos.getX(i), pos.getY(i), pos.getZ(i), 1).applyMatrix4(toClip);
59
+ const w = v.w;
60
+ if (!(w > 0) || v.z < -w || v.z > w) return null;
61
+ const x = v.x / w, y = v.y / w;
62
+ if (x < minX) minX = x;
63
+ if (x > maxX) maxX = x;
64
+ if (y < minY) minY = y;
65
+ if (y > maxY) maxY = y;
66
+ any = true;
67
+ }
68
+ }
69
+ if (!any) return null;
70
+ return { left: (minX + 1) / 2, right: (maxX + 1) / 2, top: (1 - maxY) / 2, bottom: (1 - minY) / 2 };
71
+ }
72
+
73
+ // The largest sub-window centred on the extent's centre that still fits the
74
+ // frame, as { x, y, width, height } fractions. Centring on the extent with
75
+ // maximal half-extents min(c, 1 - c) gives equal margins on both sides of each
76
+ // axis and is guaranteed to contain the extent (which always lies within
77
+ // [0, 2c] and [2c - 1, 1]). Null — leave the framing alone — when the extent
78
+ // runs past any edge (the user zoomed in on purpose), when it already fills the
79
+ // frame symmetrically (nothing to do), or when there is no usable extent.
80
+ // `eps` forgives the ~1 px feature-edge line that can overhang a vertex.
81
+ export function centeredCropView(extent, { eps = 0.002 } = {}) {
82
+ if (!extent) return null;
83
+ const { left, top, right, bottom } = extent;
84
+ if (![left, top, right, bottom].every(Number.isFinite)) return null;
85
+ if (left < -eps || top < -eps || right > 1 + eps || bottom > 1 + eps) return null;
86
+ if (!(right > left) || !(bottom > top)) return null;
87
+ const cx = (left + right) / 2, cy = (top + bottom) / 2;
88
+ const hw = Math.min(cx, 1 - cx), hh = Math.min(cy, 1 - cy);
89
+ if (hw >= 0.5 - eps && hh >= 0.5 - eps) return null;
90
+ return { x: Math.max(0, cx - hw), y: Math.max(0, cy - hh), width: 2 * hw, height: 2 * hh };
91
+ }
92
+
93
+ // Turn a fractional crop of a frame with the given aspect into what
94
+ // renderOffscreen needs: the output size (crop rendered at `long` px on its
95
+ // long edge, so recentring costs no resolution) and the setViewOffset
96
+ // arguments describing it as a sub-window of a larger virtual frame.
97
+ export function cropRenderFrame(crop, { aspect, long }) {
98
+ const cropAspect = (crop.width * aspect) / crop.height;
99
+ const width = cropAspect >= 1 ? long : Math.max(1, Math.round(long * cropAspect));
100
+ const height = cropAspect >= 1 ? Math.max(1, Math.round(long / cropAspect)) : long;
101
+ const fullWidth = width / crop.width;
102
+ const fullHeight = height / crop.height;
103
+ return {
104
+ width, height,
105
+ viewOffset: { fullWidth, fullHeight, x: crop.x * fullWidth, y: crop.y * fullHeight },
106
+ };
107
+ }
108
+
109
+ // The whole pipeline for captureCurrentFromScene: the render frame that puts
110
+ // the visible geometry in the middle, or null when the current framing should
111
+ // be kept as-is (part cropped by the viewport, already centred, or nothing to
112
+ // measure). `meshes` are the visible sub-part meshes; the camera parameters
113
+ // must be the same ones the render will use.
114
+ export function recenteredView(pose, { aspect, fov, projection, orthoHalfH, meshes, long }) {
115
+ const camera = makeCaptureCamera(pose, { aspect, fov, projection, orthoHalfH });
116
+ const crop = centeredCropView(projectedExtent(camera, meshes));
117
+ return crop ? cropRenderFrame(crop, { aspect, long }) : null;
118
+ }
@@ -469,8 +469,10 @@
469
469
 
470
470
  /* ---- annotation ink layer: a transparent 2D canvas over the viewer --------
471
471
  Shown only while annotation mode is on. It deliberately owns pointer events
472
- while visible — that is what freezes orbit/pan/zoom during drawing. Below
473
- the viewbar (z 15) so Undo/Clear/Send stay clickable. */
472
+ while visible — that is what freezes orbit/pan/zoom during drawing. #viewbar
473
+ itself is hidden for the duration (mount.js toggles both), replaced by
474
+ .pf-sketch-toolbar (app.css, z 20) which now holds Undo/Clear/Send; this
475
+ layer sits below that at z 10 so the toolbar stays clickable over it. */
474
476
  .pf-ink-canvas {
475
477
  position: absolute;
476
478
  inset: 0;
@@ -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
@@ -75,7 +76,15 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
75
76
  // Offscreen render of a named view (default when omitted, or on an unknown name).
76
77
  captureView,
77
78
  captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
78
- captureCurrent: (opts) => viewer.captureCurrent(opts),
79
+ // `recenter` reads the sub-part geometry only, so with measurement pins on
80
+ // screen the dimension labels — which sit beside the part, not on it —
81
+ // could land outside the centred window. A dimensioned capture therefore
82
+ // keeps the user's exact framing; a host wanting both re-frames first.
83
+ captureCurrent: (opts) => viewer.captureCurrent(
84
+ opts?.recenter && (measure ?? NOOP_MEASURE).isEnabled() && (measure ?? NOOP_MEASURE).pinCount() > 0
85
+ ? { ...opts, recenter: false }
86
+ : opts,
87
+ ),
79
88
  // Park/unpark the viewer: stops the render loop and frees the drawing
80
89
  // buffer and the cached capture target. For an embedder that hides the
81
90
  // canvas without unmounting it — `visibility: hidden`, an off-screen tab —
@@ -237,12 +246,12 @@ function createCleanupStack() {
237
246
  // // KB of base64 apiece, so a host should not assume this
238
247
  // // payload is small, only that it is bounded.
239
248
  // annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
240
- // // Send beside Undo/Clear in the annotate actions row.
241
- // // "host" drops it and leaves Undo/Clear: the host draws
242
- // // its own send control — e.g. a composer that pairs the
243
- // // sketch with a typed message — and calls
244
- // // runtime.annotate.send() itself. Ignored without
245
- // // 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).
246
255
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
247
256
  // exactly once here — submodules take element refs and never query the document.
248
257
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
@@ -423,8 +432,30 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
423
432
  }
424
433
  const annotateChrome = attachAnnotateControls(viewer, annotateMode, {
425
434
  annotate: els.chrome.annotate,
426
- }, { tooltip, escapeScope: els.viewer, send: annotateSend });
435
+ }, { tooltip, escapeScope: els.viewer });
427
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
+ }
428
459
  // Orientation cube + projection toggle. Generated chrome — no host markup
429
460
  // declares it, so an embedder gets it for free. Restored BEFORE any framing
430
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
+ }
@@ -9,6 +9,7 @@ import { createCameraTween } from "./camera-tween.js";
9
9
  import { orbitPose } from "./camera-orbit.js";
10
10
  import { orthoFrustum, perspectiveDistance } from "./projection.js";
11
11
  import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
12
+ import { makeCaptureCamera, recenteredView } from "./capture-frame.js";
12
13
  import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
13
14
 
14
15
  // three renders into a render target in the LINEAR working colour space: as of r184
@@ -98,9 +99,17 @@ export function thumbnailBackground(background = THUMBNAIL_BG) {
98
99
  // context: pose comes from the live camera (never a canonical pose), the output
99
100
  // long edge is `size` clamped into [256, maxTextureSize], and the short edge
100
101
  // follows the live camera's aspect so the capture matches what the user framed.
102
+ //
103
+ // `recenter: true` (opt-in; the default keeps the exact viewport framing) renders
104
+ // the largest centred sub-window that still holds every visible vertex — a
105
+ // showcase image with the part in the middle and equal margins — and leaves the
106
+ // framing alone when the geometry runs off the frame, since a user who zoomed
107
+ // past the part's edge framed that crop on purpose. The extent is projected
108
+ // through the same camera the render uses (capture-frame.js), so it is exact;
109
+ // `meshes` are the visible sub-part meshes it reads.
101
110
  export function captureCurrentFromScene(
102
- { size = 2048, hideGrid = true, quality = 0.9 } = {},
103
- { renderer, liveCamera, target, grid, maxTextureSize, projection = "perspective", orthoHalfH },
111
+ { size = 2048, hideGrid = true, quality = 0.9, recenter = false } = {},
112
+ { renderer, liveCamera, target, grid, maxTextureSize, projection = "perspective", orthoHalfH, meshes },
104
113
  ) {
105
114
  const MIN_SIZE = 256;
106
115
  // WebGL2 guarantees MAX_TEXTURE_SIZE >= 2048; only trust a larger reported cap.
@@ -115,17 +124,20 @@ export function captureCurrentFromScene(
115
124
  || 1;
116
125
  const width = aspect >= 1 ? long : Math.max(1, Math.round(long * aspect));
117
126
  const height = aspect >= 1 ? Math.max(1, Math.round(long / aspect)) : long;
127
+ const pose = { position: liveCamera.position.toArray(), up: liveCamera.up.toArray(), target };
128
+ // fov is meaningless under an ortho camera; orthoHalfH replaces it. The
129
+ // CANONICAL capture path deliberately never passes either — agent-facing
130
+ // renders stay perspective regardless of what the user is looking at.
131
+ const fov = liveCamera.fov ?? 45;
132
+ // Null means "keep the viewport framing": part cropped by the viewport,
133
+ // already centred, or nothing to measure.
134
+ const frame = (recenter && recenteredView(pose, { aspect, fov, projection, orthoHalfH, meshes, long }))
135
+ || { width, height };
118
136
  const before = liveCamera.position.clone();
119
137
  const gridWasVisible = grid?.visible;
120
138
  if (grid && hideGrid) grid.visible = false;
121
139
  try {
122
- return renderer.renderOffscreen(
123
- { position: liveCamera.position.toArray(), up: liveCamera.up.toArray(), target },
124
- // fov is meaningless under an ortho camera; orthoHalfH replaces it. The
125
- // CANONICAL capture path deliberately never passes either — agent-facing
126
- // renders stay perspective regardless of what the user is looking at.
127
- { width, height, fov: liveCamera.fov ?? 45, quality, projection, orthoHalfH },
128
- );
140
+ return renderer.renderOffscreen(pose, { ...frame, fov, quality, projection, orthoHalfH });
129
141
  } finally {
130
142
  if (grid && hideGrid) grid.visible = gridWasVisible;
131
143
  liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
@@ -822,9 +834,15 @@ export function createViewer(container, part) {
822
834
  // the mask silently no-ops and every cap floods its whole plane with hatch —
823
835
  // no error, live view unaffected, wrong only in the capture.
824
836
  const RT_OPTIONS = { samples: 4, stencilBuffer: true };
825
- function renderOffscreen({ position, up, target },
837
+ //
838
+ // `viewOffset` ({ fullWidth, fullHeight, x, y }) renders the width×height
839
+ // output as that sub-window of a larger virtual frame — the recentred
840
+ // showcase capture (captureCurrentFromScene). The camera's aspect is then the
841
+ // VIRTUAL frame's, so the projection is exactly the un-offset one and the
842
+ // output is a pixel-exact crop of what the user framed.
843
+ function renderOffscreen(pose,
826
844
  { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9,
827
- projection = "perspective", orthoHalfH = 1 } = {},
845
+ projection = "perspective", orthoHalfH = 1, viewOffset } = {},
828
846
  renderScene = scene) {
829
847
  const cachedSize = width === _rtSize && height === _rtSize;
830
848
  const rt = cachedSize
@@ -832,15 +850,13 @@ export function createViewer(container, part) {
832
850
  : new THREE.WebGLRenderTarget(width, height, RT_OPTIONS);
833
851
  _capLights = _capLights ?? createCaptureLights();
834
852
  // Canonical captures never pass `projection`, so agent-facing renders and
835
- // the CLI stay perspective no matter what the user is looking at.
836
- const cam = projection === "orthographic"
837
- ? new THREE.OrthographicCamera(
838
- -orthoHalfH * (width / height), orthoHalfH * (width / height),
839
- orthoHalfH, -orthoHalfH, 0.1, 1000)
840
- : new THREE.PerspectiveCamera(fov, width / height, 0.1, 1000);
841
- cam.position.set(position[0], position[1], position[2]);
842
- cam.up.set(up[0], up[1], up[2]);
843
- cam.lookAt(target[0], target[1], target[2]);
853
+ // the CLI stay perspective no matter what the user is looking at. Built
854
+ // through the same helper the recentring math projects through, so the two
855
+ // can never disagree about where a vertex lands.
856
+ const aspect = viewOffset ? viewOffset.fullWidth / viewOffset.fullHeight : width / height;
857
+ const cam = makeCaptureCamera(pose, { aspect, fov, projection, orthoHalfH });
858
+ if (viewOffset) cam.setViewOffset(viewOffset.fullWidth, viewOffset.fullHeight, viewOffset.x, viewOffset.y, width, height);
859
+ const { position, up, target } = pose;
844
860
  const buf = new Uint8Array(width * height * 4);
845
861
  // Swap the world-fixed key/fill for the camera-relative pair, for this one render
846
862
  // only. A DirectionalLight aims at its `target`, whose matrixWorld only updates
@@ -923,6 +939,9 @@ export function createViewer(container, part) {
923
939
  target: controls.target.toArray(),
924
940
  grid,
925
941
  maxTextureSize: renderer.capabilities?.maxTextureSize,
942
+ // For `recenter`: the geometry that is actually in the picture. Sub-part
943
+ // meshes only — dimension labels and section caps are overlays on them.
944
+ meshes: Object.values(subMesh).filter((m) => m.visible),
926
945
  projection: projectionMode,
927
946
  // Divided by zoom, because OrbitControls dollies an ortho camera with
928
947
  // `zoom` and leaves the frustum alone: the raw frustum is the un-dollied
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/index.d.ts CHANGED
@@ -146,6 +146,14 @@ export interface CaptureCurrentOptions {
146
146
  hideGrid?: boolean;
147
147
  /** JPEG quality, 0..1. */
148
148
  quality?: number;
149
+ /**
150
+ * Centre the visible geometry: render the largest centred sub-window of the
151
+ * current framing that still holds every visible vertex (equal margins, full
152
+ * `size` resolution). When the geometry runs past a frame edge — the user
153
+ * zoomed in on purpose — or dimensions are pinned, the framing is kept as-is.
154
+ * Default false.
155
+ */
156
+ recenter?: boolean;
149
157
  }
150
158
 
151
159
  export interface CaptureViewOptions {
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
- }