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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.86.0",
3
+ "version": "0.87.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -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
- captureCurrent: (opts) => viewer.captureCurrent(opts),
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 —
@@ -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/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 {