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.
- package/docs/AUTHORING-PARTS.md +50 -4
- 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/capture-frame.js +118 -0
- package/src/framework/chrome.css +4 -2
- package/src/framework/mount.js +39 -8
- package/src/framework/oracle/annotation-ray.js +92 -0
- package/src/framework/viewer.js +39 -20
- package/src/oracle.js +4 -0
- package/types/index.d.ts +8 -0
- package/types/oracle.d.ts +3 -0
- package/types/testing.d.ts +15 -0
- package/src/framework/annotate/ink.js +0 -124
|
@@ -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
|
+
}
|
package/src/framework/chrome.css
CHANGED
|
@@ -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.
|
|
473
|
-
the
|
|
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;
|
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
|
|
@@ -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
|
-
|
|
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
|
|
241
|
-
// // "host" drops it
|
|
242
|
-
// //
|
|
243
|
-
// //
|
|
244
|
-
// //
|
|
245
|
-
// //
|
|
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
|
|
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
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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";
|
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
|
-
}
|