partforge 0.22.0 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.22.0",
3
+ "version": "0.23.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",
@@ -1,6 +1,8 @@
1
1
  import { meshTo3MF } from "./geometry/threemf.js";
2
2
  import { resolveDerived } from "./derive.js";
3
3
  import { resolveFonts } from "./fonts.js";
4
+ import { measure } from "../testing/measure.js";
5
+ import { verify } from "../testing/verify.js";
4
6
 
5
7
  // Names of the sub-parts a view shows: declared in the view and enabled for these
6
8
  // params. Order follows Object.keys(part.parts) (definition order).
@@ -113,6 +115,18 @@ export async function handle(kernel, part, msg, post) {
113
115
  onProgress("writing 3MF file");
114
116
  const data = meshTo3MF(meshes);
115
117
  post({ type: "download", data, filename: `${msg.view}.3mf`, mime: "model/3mf" }, [bufferOf(data)]);
118
+ } else if (msg.type === "inspect") {
119
+ // Full geometric oracle for the current view: solid facts (volume/genus/
120
+ // watertight), mesh facts, overlaps, and gap distances, plus the part's
121
+ // structural + declared verify gates. Runs against the worker's live kernel
122
+ // — the main thread only has mesh arrays, so this can only happen here.
123
+ // measure/verify build their own solids via buildView and are cleaned up by
124
+ // the `finally` below.
125
+ const report = {
126
+ measure: measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true }),
127
+ verify: verify(kernel, part, { view: msg.view }),
128
+ };
129
+ post({ type: "report", ...report });
116
130
  }
117
131
  } catch (err) {
118
132
  if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
@@ -19,6 +19,12 @@ import { createViewTabs } from "./view-tabs.js";
19
19
  import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
20
20
  import { createPickRequestClient } from "./pick-request/index.js";
21
21
 
22
+ // The mount handle, factored out so its shape is unit-testable without booting
23
+ // the full mount() pipeline (WASM + workers + DOM).
24
+ export function makeHandle({ ready, dispose, viewer }) {
25
+ return { ready, dispose, captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames) };
26
+ }
27
+
22
28
  function createCleanupStack() {
23
29
  const cleanups = [];
24
30
  let disposed = false;
@@ -351,7 +357,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
351
357
  cleanup.dispose();
352
358
  }
353
359
 
354
- return { ready, dispose };
360
+ return makeHandle({ ready, dispose, viewer });
355
361
  } catch (error) {
356
362
  try {
357
363
  cleanup.dispose();
@@ -0,0 +1,33 @@
1
+ // Canonical camera angles for headless/offscreen captures, in the viewer's
2
+ // three.js WORLD space (Y-up). The model is authored Z-up; the viewer's pivot
3
+ // rotates it into Y-up, so these directions are expressed Y-up directly. Kept in
4
+ // one place so the cloud render tool and any future headless capture agree.
5
+ export const CANONICAL_VIEWS = ["iso", "front", "back", "top", "bottom", "left", "right"];
6
+
7
+ // Direction FROM the part centre TOWARD the camera (world Y-up), plus the up vector.
8
+ const DIRS = {
9
+ iso: { dir: [1, 1, 1], up: [0, 1, 0] },
10
+ front: { dir: [0, 0, 1], up: [0, 1, 0] },
11
+ back: { dir: [0, 0, -1], up: [0, 1, 0] },
12
+ top: { dir: [0, 1, 0], up: [0, 0, -1] },
13
+ bottom: { dir: [0, -1, 0], up: [0, 0, 1] },
14
+ left: { dir: [-1, 0, 0], up: [0, 1, 0] },
15
+ right: { dir: [1, 0, 0], up: [0, 1, 0] },
16
+ };
17
+
18
+ const norm = (v) => {
19
+ const l = Math.hypot(v[0], v[1], v[2]) || 1;
20
+ return [v[0] / l, v[1] / l, v[2] / l];
21
+ };
22
+
23
+ export function cameraPoseForView(view, { center, radius }) {
24
+ const a = DIRS[view];
25
+ if (!a) throw new Error(`unknown canonical view "${view}"`);
26
+ const d = norm(a.dir);
27
+ const dist = radius * 2.6 + 6; // matches viewer.frameTo's framing distance
28
+ return {
29
+ position: [center[0] + d[0] * dist, center[1] + d[1] * dist, center[2] + d[2] * dist],
30
+ up: a.up,
31
+ target: [...center],
32
+ };
33
+ }
@@ -6,6 +6,29 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { createCutaway } from "./cutaway.js";
8
8
  import { addViewerLights } from "./viewer-lighting.js";
9
+ import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
10
+
11
+ // Render a set of canonical views without disturbing the live camera/canvas.
12
+ // `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
13
+ // target → readback → JPEG data URL); injected so this is unit-testable without
14
+ // a GL context. The grid is hidden for the whole synchronous pass and restored.
15
+ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds }) {
16
+ const views = (viewNames?.length ? viewNames : ["iso", "front", "top"])
17
+ .filter((v) => CANONICAL_VIEWS.includes(v))
18
+ .slice(0, CANONICAL_VIEWS.length);
19
+ const before = liveCamera.position.clone();
20
+ const gridWasVisible = grid?.visible;
21
+ if (grid) grid.visible = false;
22
+ try {
23
+ return views.map((view) => ({
24
+ view,
25
+ dataUrl: renderer.renderOffscreen(cameraPoseForView(view, bounds)),
26
+ }));
27
+ } finally {
28
+ if (grid) grid.visible = gridWasVisible;
29
+ liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
30
+ }
31
+ }
9
32
 
10
33
  export function createViewer(container, part) {
11
34
  const names = Object.keys(part.parts);
@@ -261,6 +284,53 @@ export function createViewer(container, part) {
261
284
  ro.observe(container);
262
285
  resize();
263
286
 
287
+ // --- offscreen canonical-view capture -------------------------------------
288
+ // Offscreen render of the shared scene from an arbitrary pose → JPEG data URL.
289
+ // A separate WebGLRenderTarget + temp camera means the visible canvas and the
290
+ // live `camera` are never touched. WebGL pixels are bottom-up, so flip on encode.
291
+ const _rtSize = 512;
292
+ let _rt = null;
293
+ function renderOffscreen({ position, up, target }) {
294
+ _rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize);
295
+ const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
296
+ cam.position.set(position[0], position[1], position[2]);
297
+ cam.up.set(up[0], up[1], up[2]);
298
+ cam.lookAt(target[0], target[1], target[2]);
299
+ renderer.setRenderTarget(_rt);
300
+ renderer.render(scene, cam);
301
+ const buf = new Uint8Array(_rtSize * _rtSize * 4);
302
+ renderer.readRenderTargetPixels(_rt, 0, 0, _rtSize, _rtSize, buf);
303
+ renderer.setRenderTarget(null);
304
+ const canvas = document.createElement("canvas");
305
+ canvas.width = _rtSize; canvas.height = _rtSize;
306
+ const ctx = canvas.getContext("2d");
307
+ const img = ctx.createImageData(_rtSize, _rtSize);
308
+ // flip rows (GL origin is bottom-left)
309
+ for (let y = 0; y < _rtSize; y++) {
310
+ const src = (_rtSize - 1 - y) * _rtSize * 4;
311
+ img.data.set(buf.subarray(src, src + _rtSize * 4), y * _rtSize * 4);
312
+ }
313
+ ctx.putImageData(img, 0, 0);
314
+ return canvas.toDataURL("image/jpeg", 0.8);
315
+ }
316
+
317
+ // Render the canonical camera angles offscreen, framed to whatever is visible,
318
+ // without disturbing the user's live view. Returns [{ view, dataUrl }].
319
+ function captureCanonicalViews(viewNames) {
320
+ if (disposed) return [];
321
+ const box = getVisibleWorldBounds();
322
+ if (!box || box.isEmpty()) return [];
323
+ const center = box.getCenter(new THREE.Vector3()).toArray();
324
+ const size = box.getSize(new THREE.Vector3());
325
+ const radius = Math.max(size.x, size.y, size.z) / 2 || 10;
326
+ return captureViewsFromScene(viewNames, {
327
+ renderer: { renderOffscreen },
328
+ liveCamera: camera,
329
+ grid,
330
+ bounds: { center, radius },
331
+ });
332
+ }
333
+
264
334
  // --- render loop ----------------------------------------------------------
265
335
  renderer.setAnimationLoop(() => {
266
336
  controls.update();
@@ -323,6 +393,7 @@ export function createViewer(container, part) {
323
393
  lineMaterial.dispose();
324
394
  grid.geometry.dispose();
325
395
  grid.material.dispose();
396
+ _rt?.dispose();
326
397
  renderer.dispose();
327
398
  renderer.domElement.remove();
328
399
  }
@@ -334,6 +405,7 @@ export function createViewer(container, part) {
334
405
  hasSubMesh,
335
406
  subTriangles,
336
407
  frame,
408
+ captureCanonicalViews,
337
409
  setAutoRotate,
338
410
  setTheme,
339
411
  getCameraState,