partforge 0.86.0 → 0.87.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 +11 -1
- package/package.json +1 -1
- package/src/framework/capture-frame.js +118 -0
- package/src/framework/mount.js +9 -1
- package/src/framework/viewer.js +39 -20
- package/types/index.d.ts +8 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -1770,7 +1770,7 @@ framing offscreen at a resolution independent of the window size and devicePixel
|
|
|
1770
1770
|
for gallery/preview images, where grabbing the live canvas would be capped at the viewer
|
|
1771
1771
|
pane's pixel size:
|
|
1772
1772
|
|
|
1773
|
-
- `runtime.captureCurrent({ size = 2048, hideGrid = true, quality = 0.9 } = {}) → string | null` —
|
|
1773
|
+
- `runtime.captureCurrent({ size = 2048, hideGrid = true, quality = 0.9, recenter = false } = {}) → string | null` —
|
|
1774
1774
|
one offscreen render from the live camera's pose (position, up, and orbit target — not a
|
|
1775
1775
|
canonical pose) with the live viewport's aspect ratio, `size` px on the long edge
|
|
1776
1776
|
(clamped into `[256, maxTextureSize]`). Renders with 4× MSAA and the same
|
|
@@ -1783,6 +1783,16 @@ pane's pixel size:
|
|
|
1783
1783
|
in the scene, so a dimensioned capture needs no special handling — enable measure
|
|
1784
1784
|
mode (`runtime.measure.setEnabled(true)`) and call `captureCurrent()`; the dims are
|
|
1785
1785
|
just part of the rendered frame.
|
|
1786
|
+
`recenter: true` centres the part: the capture becomes the largest centred
|
|
1787
|
+
sub-window of the current framing that still holds every visible vertex (equal
|
|
1788
|
+
margins on both axes, rendered at the full `size` resolution through a view
|
|
1789
|
+
offset, so it is a pixel-exact crop of what the user framed — same
|
|
1790
|
+
perspective, no re-encode). The extent is the projection of the actual mesh
|
|
1791
|
+
vertices, not a bounding box, so it is exact at any angle. The framing is
|
|
1792
|
+
kept as-is when the geometry runs past any frame edge (a user who zoomed in
|
|
1793
|
+
past the part's silhouette cropped it on purpose), when it is already centred,
|
|
1794
|
+
or when measurement dimensions are pinned (their labels sit beside the part and
|
|
1795
|
+
could otherwise be cut off).
|
|
1786
1796
|
- `runtime.captureViews(viewNames) → [{ view, dataUrl }]` — the canonical-angle
|
|
1787
1797
|
counterpart (fixed poses, framed to the visible assembly, 1024², grid hidden). Sized
|
|
1788
1798
|
for feeding a vision model, not for display; use `captureCurrent` for showcase images.
|
package/package.json
CHANGED
|
@@ -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/mount.js
CHANGED
|
@@ -75,7 +75,15 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
75
75
|
// Offscreen render of a named view (default when omitted, or on an unknown name).
|
|
76
76
|
captureView,
|
|
77
77
|
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
78
|
-
|
|
78
|
+
// `recenter` reads the sub-part geometry only, so with measurement pins on
|
|
79
|
+
// screen the dimension labels — which sit beside the part, not on it —
|
|
80
|
+
// could land outside the centred window. A dimensioned capture therefore
|
|
81
|
+
// keeps the user's exact framing; a host wanting both re-frames first.
|
|
82
|
+
captureCurrent: (opts) => viewer.captureCurrent(
|
|
83
|
+
opts?.recenter && (measure ?? NOOP_MEASURE).isEnabled() && (measure ?? NOOP_MEASURE).pinCount() > 0
|
|
84
|
+
? { ...opts, recenter: false }
|
|
85
|
+
: opts,
|
|
86
|
+
),
|
|
79
87
|
// Park/unpark the viewer: stops the render loop and frees the drawing
|
|
80
88
|
// buffer and the cached capture target. For an embedder that hides the
|
|
81
89
|
// canvas without unmounting it — `visibility: hidden`, an off-screen tab —
|
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/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 {
|