partforge 0.85.4 → 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/bin/cli.js CHANGED
@@ -26,25 +26,7 @@ import { lintPart } from "../src/lint.js";
26
26
 
27
27
  const die = (msg) => { console.error(msg); process.exit(1); };
28
28
 
29
- // The semantic mesh oracle (`describe`) lives in a separate, closed package — this
30
- // CLI names it only here, and resolves it at CALL time so every other verb works
31
- // without it installed. PARTFORGE_ORACLE overrides the specifier (a path or module
32
- // name) — how the oracle package's own repo points this CLI at its working tree.
33
- const ORACLE_PACKAGE = "@pixiteapps/partforge-oracle";
34
- async function loadOracle() {
35
- const spec = process.env.PARTFORGE_ORACLE ?? ORACLE_PACKAGE;
36
- const target = spec.startsWith(".") || spec.startsWith("/")
37
- ? pathToFileURL(resolve(process.cwd(), spec)).href
38
- : spec;
39
- try {
40
- return await import(target);
41
- } catch (e) {
42
- die(`describe needs the mesh-oracle package (${ORACLE_PACKAGE}), which is not installed.\n` +
43
- `Install it from the private registry (or set PARTFORGE_ORACLE to a local path) and retry.\n` +
44
- `(import of ${spec} failed: ${e?.message ?? e})`);
45
- }
46
- }
47
- const USAGE = "usage: partforge <lint|measure|render|describe|pick-serve|pick> …";
29
+ const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
48
30
 
49
31
  // Crash contract (issue #27): with --json, a thrown error becomes structured
50
32
  // stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
@@ -205,52 +187,6 @@ const commands = {
205
187
  }
206
188
  },
207
189
 
208
- async describe(args) {
209
- const usage = "usage: partforge describe <part-module#importName> [--surfaces] [--json] [--budget N] [--out <file>]";
210
- const { values: flags, positionals: [target] } = parse(args, {
211
- surfaces: { type: "boolean" },
212
- json: { type: "boolean" },
213
- budget: { type: "string" },
214
- out: { type: "string" },
215
- }, usage);
216
- if (!target) die(usage);
217
- // `part.js#importName` — describe reads a FILE, and a file only reaches the kernel
218
- // through a part's `imports` declaration, so the part is how we find it. A bare mesh
219
- // path is deliberately not accepted in v1: it would need its own resolver, its own
220
- // format sniffing, and its own unit assumptions, all of which the import pipeline
221
- // already owns.
222
- const [partPath, importName] = target.split("#");
223
- if (!importName) die(`describe needs an import name: <part-module>#<importName>\n${usage}`);
224
- try {
225
- const part = await loadPart(partPath, usage);
226
- if (!part.imports?.[importName]) {
227
- die(`describe: "${importName}" is not a declared import of ${partPath} ` +
228
- `(have: ${Object.keys(part.imports ?? {}).join(", ") || "none"})`);
229
- }
230
- const { describe: describeMesh, compactDescribe } = await loadOracle();
231
- const kernel = await bootKernel(part);
232
- const solid = kernel.import(importName);
233
- const report = describeMesh(kernel, solid, {
234
- name: importName,
235
- digest: kernel._importDigest?.(importName) ?? null,
236
- budget: flags.budget ? Number(flags.budget) : undefined,
237
- });
238
- if (flags.out) {
239
- mkdirSync(dirname(resolve(flags.out)), { recursive: true });
240
- writeFileSync(flags.out, JSON.stringify(report, null, 2));
241
- }
242
- if (flags.json) console.log(JSON.stringify(report, null, 2));
243
- else printDescribe(report, { surfaces: !!flags.surfaces }, compactDescribe);
244
- if (flags.out) console.log(`\nwrote ${flags.out}`);
245
- // A closed-set error exits non-zero; LOW COVERAGE does not. Coverage is a finding
246
- // the caller must be able to read, and an exit code that conflated the two would
247
- // train an agent to discard exactly the reports it most needs to look at.
248
- process.exit(report.error ? 1 : 0);
249
- } catch (e) {
250
- crash("describe", e, !!flags.json);
251
- }
252
- },
253
-
254
190
  async render(args) {
255
191
  const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>] " +
256
192
  "[--params <json>] [--animation <name>] [--at <t[,t…]>] [--step <index|label>]";
@@ -488,83 +424,6 @@ function printVerify(v) {
488
424
  console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
489
425
  }
490
426
 
491
- // Human/agent-readable summary. Features, patterns, symmetry, score, residual — the
492
- // compact shape (spec §3.4), because a 24k-triangle part yields hundreds of surfaces and
493
- // dumping them buries the reader in the noise the oracle exists to remove. `--surfaces`
494
- // opts back in; `--json` always has everything. Reads the SAME compactDescribe() output
495
- // a model reads (not a hand-rolled subset), so what a human sees here and what an agent
496
- // sees over `--json` cannot drift apart.
497
- function printDescribe(report, { surfaces }, compactDescribe) {
498
- if (report.error) {
499
- console.error(`describe: ${report.error}${report.detail ? ` — ${report.detail}` : ""}`);
500
- return;
501
- }
502
- const c = compactDescribe(report);
503
- if (c.warning) console.log(`\n!! ${c.warning}\n`);
504
- console.log(`${c.source.name ?? "mesh"} — ${c.source.triangles} triangles, ` +
505
- `${c.bounds.size.map((v) => v.toFixed(2)).join(" x ")} mm, ${c.frame.up} up`);
506
- // The `share` hint lives HERE, at the point of use, not several lines below beside
507
- // Score (fix round 2, IMPORTANT 1 — a prior version printed the full `score.note`
508
- // paragraph under Score and it dominated the report: measured at 36-43% of a typical
509
- // run's line count, the single largest visual element, bigger than the feature list,
510
- // banners, and score line combined — burying findings instead of clarifying them). One
511
- // line, next to the column it explains; the full note is still in `--json` unabridged
512
- // (`buildScore` in report.js attaches it unconditionally — nothing lost there).
513
- console.log(`\nFeatures (${c.features.length}):` +
514
- (c.features.length ? ` share = fraction of part volume this feature ` +
515
- `accounts for — a size measure, not certainty` : ""));
516
- // `volumeShareReason` (fix round 2, IMPORTANT 2): `volumeShare: null` alone doesn't
517
- // say whether this feature type is never proposed at all, was proposed but never
518
- // reached before the search ran out of budget, or was reached and built but simply
519
- // didn't win — three different signals to a rebuilder deciding what to do next. See
520
- // describe.js's own comment on the field for the exact three-way split.
521
- const REASON_LABEL = { "not-proposed": "not proposed", budget: "budget", rejected: "rejected" };
522
- for (const f of c.features) {
523
- const dim = f.diameter ?? f.radius ?? f.width ?? f.thickness ?? f.depth;
524
- const snap = f.snapped?.diameter?.note ? ` [${f.snapped.diameter.note}]` : "";
525
- const share = f.volumeShare != null
526
- ? `${(100 * f.volumeShare).toFixed(1)}%`
527
- : `n/a (${REASON_LABEL[f.volumeShareReason] ?? f.volumeShareReason ?? "unknown"})`;
528
- console.log(` ${f.id.padEnd(5)} ${f.type.padEnd(14)} ` +
529
- `${dim != null ? dim.toFixed(3) : ""}`.padEnd(10) +
530
- `share ${share}${snap}`);
531
- }
532
- if (c.patterns.length) {
533
- console.log(`\nPatterns (${c.patterns.length}):`);
534
- for (const p of c.patterns) {
535
- console.log(` ${p.id.padEnd(5)} ${p.type.padEnd(9)} x${p.counts.join("x")} ` +
536
- `pitch ${p.pitch.map((v) => v.toFixed(2)).join(", ")} [${p.members.length} members]`);
537
- }
538
- }
539
- if (c.symmetry.length) {
540
- console.log(`\nSymmetry:`);
541
- for (const s of c.symmetry) console.log(` ${s.type} coverage ${s.coverage}`);
542
- }
543
- if (surfaces) {
544
- console.log(`\nSurfaces (${report.surfaces.length}):`);
545
- for (const s of report.surfaces) {
546
- console.log(` ${s.id.padEnd(5)} ${s.type.padEnd(9)} area ${s.area.toFixed(2)}`.padEnd(40) +
547
- `rms ${s.rms.toExponential(2)}`);
548
- }
549
- }
550
- // TWO DIFFERENT NUMBERS, printed as two: explainedArea is how much of the mesh's
551
- // SURFACE segmentation fitted to some primitive; explainedVolumeFraction is how much
552
- // of the part's actual SHAPE the accepted features reconstruct. They can diverge
553
- // totally — a dome segments to ~100% area and 0% volume, since a sphere is not a
554
- // candidate-eligible feature type — so printing only one (or a blended "xor%") would
555
- // hide exactly the gap the LOW COVERAGE banner above exists to catch. The wording here
556
- // ("surface area explained" vs. "volume reconstructed") already carries that
557
- // distinction; `score.note`'s full prose (this point, plus the volumeShare aside now
558
- // covered at the Features header instead) stays JSON-only — fix round 2, IMPORTANT 1.
559
- console.log(`\nScore: ${(100 * c.score.explainedArea).toFixed(1)}% surface area explained, ` +
560
- `${(100 * c.score.explainedVolumeFraction).toFixed(1)}% volume reconstructed ` +
561
- `(residual xor ${(100 * c.score.xorFraction).toFixed(2)}% of volume)`);
562
- console.log(`Residual: ${(100 * c.residual.areaFraction).toFixed(2)}% of area in ` +
563
- `${c.residual.regions.length} region(s)`);
564
- const truncated = Object.entries(report.truncated ?? {}).filter(([, v]) => v).map(([k]) => k);
565
- if (truncated.length) console.log(`Truncated (caps hit): ${truncated.join(", ")}`);
566
- }
567
-
568
427
  function printLint(r) {
569
428
  const all = [...r.errors, ...r.warnings, ...(r.notes ?? [])];
570
429
  if (all.length === 0) { console.log("lint: clean"); return; }
@@ -1559,30 +1559,26 @@ build: (k, p, d) => {
1559
1559
 
1560
1560
  **CLI:** `partforge measure|render|lint` work on an importing part exactly as on any other — the `imports` field resolves in the CLI's Node boot the same way `fonts` does, no extra flags.
1561
1561
 
1562
- ## Describing an imported mesh
1563
-
1564
- `npx partforge describe <part-module>#<importName>` reads an already-declared import
1565
- and emits a semantic feature report holes, bosses, pockets, extrusions, patterns,
1566
- symmetry rather than a triangle soup, so an agent can rebuild an STL parametrically.
1567
-
1568
- The engine behind it the semantic mesh oracle — is **not part of this package**: it
1569
- ships as a separate, closed package (`@pixiteapps/partforge-oracle`) whose docs carry
1570
- the full report contract (the two report shapes, feature vocabulary, `volumeShare`
1571
- semantics, coverage scores, budget behavior, and the closed error set). This repo
1572
- keeps only the seams:
1573
-
1574
- - **CLI** `partforge describe` resolves the oracle package at call time and prints
1575
- an install pointer when it is absent (`PARTFORGE_ORACLE` overrides the module
1576
- specifier, which is how the oracle's own repo points this CLI at its working tree).
1577
- - **Worker** the `describe` job runs whatever `runWorker(part, { loadOracle })`
1578
- injected; without a loader it answers a structured
1579
- `{error: "oracle-unavailable"}` report (see
1580
- [ERROR-PATTERNS.md#describe-oracle-unavailable](ERROR-PATTERNS.md#describe-oracle-unavailable)),
1581
- never a stall. The loader resolves the oracle barrel: `describe`, `describeMemo`,
1582
- `compactDescribe`.
1583
- - **Helpers** — the oracle package peer-depends on this one and consumes
1584
- `partforge/oracle`'s mesh/BVH helpers and file parsers (`bounds`, `meshArea`,
1585
- `meshTriangles`, `parseStl`, `parse3MF`); those exports are part of its contract.
1562
+ ## Host jobs: extending the worker
1563
+
1564
+ The worker's job loop handles a closed set of message types (`generate`, the exports,
1565
+ `inspect`, …). A host app can add its own: `runWorker(part, { jobs: { <type>:
1566
+ handler } })`. A message whose `type` matches no built-in is handed to the matching
1567
+ handler as `handler(kernel, part, msg, post, { isStale })` — the live kernel (so the
1568
+ handler can `kernel.import(name)` a declared import, or read `kernel._importDigest`),
1569
+ the part current when the message arrived, the message, and the poster for results
1570
+ (`post(msg, transferables?)`). A throw is posted as the ordinary `{type: "error",
1571
+ message, jobId}`; built-in types cannot be overridden; a type with no handler is
1572
+ ignored.
1573
+
1574
+ This is the seam through which a host adds a capability this open framework does not
1575
+ ship. The semantic mesh oracle imported mesh feature report, for rebuilding an
1576
+ STL parametrically is one: it is a separate, closed package with its own CLI, and
1577
+ the app that installs it registers its job here. This repo carries nothing
1578
+ oracle-shaped: no verb, no message types, no error codes. The one direction that
1579
+ does exist is the oracle peer-depending on this package for `partforge/oracle`'s
1580
+ mesh/BVH helpers and file parsers (`bounds`, `meshArea`, `meshTriangles`, `parseStl`,
1581
+ `parse3MF`); those exports are part of its contract.
1586
1582
 
1587
1583
 
1588
1584
  ## Probes: measuring geometry into the report
@@ -1774,7 +1770,7 @@ framing offscreen at a resolution independent of the window size and devicePixel
1774
1770
  for gallery/preview images, where grabbing the live canvas would be capped at the viewer
1775
1771
  pane's pixel size:
1776
1772
 
1777
- - `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` —
1778
1774
  one offscreen render from the live camera's pose (position, up, and orbit target — not a
1779
1775
  canonical pose) with the live viewport's aspect ratio, `size` px on the long edge
1780
1776
  (clamped into `[256, maxTextureSize]`). Renders with 4× MSAA and the same
@@ -1787,6 +1783,16 @@ pane's pixel size:
1787
1783
  in the scene, so a dimensioned capture needs no special handling — enable measure
1788
1784
  mode (`runtime.measure.setEnabled(true)`) and call `captureCurrent()`; the dims are
1789
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).
1790
1796
  - `runtime.captureViews(viewNames) → [{ view, dataUrl }]` — the canonical-angle
1791
1797
  counterpart (fixed poses, framed to the visible assembly, 1024², grid hidden). Sized
1792
1798
  for feeding a vision model, not for display; use `captureCurrent` for showcase images.
@@ -1947,8 +1953,8 @@ access. It's harmless to leave in when partforge is a normal install.)
1947
1953
  ## Testing a part
1948
1954
 
1949
1955
  Tests run under **Node 24** (`nvm use` first; the default shell Node is too old) via
1950
- `npx vitest run`. The oracle half of this surface — `measure`, `verify`,
1951
- `describe`, gaps, match scoring — is also published on its own as
1956
+ `npx vitest run`. The oracle half of this surface — `measure`, `verify`, gaps,
1957
+ match scoring — is also published on its own as
1952
1958
  `partforge/oracle` (browser-safe import closure); `partforge/testing` re-exports
1953
1959
  it, so either import works. Build geometry directly off your part with a Manifold
1954
1960
  kernel:
@@ -652,42 +652,6 @@ between the Manifold preview and the OCCT STEP export.
652
652
 
653
653
  The same channel carries every other degrade in a build: an `extrude` rim bevel reduced or skipped (`extrude bevel <b> …`), a `roundedBox` rim radius clamped to `round.side`, and the `Shape2D` corner-op clamps in the two entries above. A build result's `warnings` is the complete list of what the part asked for and did not get.
654
654
 
655
- ## describe-oracle-unavailable
656
-
657
- - **Symptom:** A `describe` job answers `{"error": "oracle-unavailable"}`, or `partforge describe` exits with "describe needs the mesh-oracle package".
658
- - **Cause:** The semantic mesh oracle is a separate, closed package (`@pixiteapps/partforge-oracle`), and this app or shell doesn't have it: the worker was started without `runWorker(part, { loadOracle })`, or the CLI could not import the package.
659
- - **Fix:** Install the oracle package from the private registry and inject it — `runWorker(part, { loadOracle: () => import("@pixiteapps/partforge-oracle") })` in the app's worker file, or plain `npm install` for the CLI. In the oracle package's own repo, set `PARTFORGE_ORACLE` to a local path instead. Every other job (generate, export, inspect) works without it.
660
-
661
- ## describe-not-manifold
662
-
663
- - **Symptom:** `describe` returns `{"error": "not-manifold"}`.
664
- - **Cause:** The mesh still has open edges after vertex-merge and winding repair, so it does not bound a solid and acceptance cannot diff against it.
665
- - **Fix:** Repair the mesh before describing it — Meshmixer, `meshlabserver`, or the slicer's own repair. `describe` will not repair geometry it was asked to report on; silently sealing a hole would make the report a description of a mesh the user does not have.
666
-
667
- ## describe-too-large
668
-
669
- - **Symptom:** `{"error": "too-large"}` naming a triangle count.
670
- - **Cause:** Above 400,000 triangles the segmentation pass stops being usable in an interactive loop.
671
- - **Fix:** Decimate first. A CAD-exported STL re-exported at a coarser chord tolerance loses nothing the describer uses; the feature vocabulary reads surfaces, not facets.
672
-
673
- ## describe-empty
674
-
675
- - **Symptom:** `{"error": "empty"}`.
676
- - **Cause:** The mesh has zero triangles — usually an import that resolved to an empty file, or a `k.import` name that registered as an error entry.
677
- - **Fix:** Check the `imports` source actually resolves. See [import-unknown-name](#import-unknown-name).
678
-
679
- ## describe-budget-exceeded
680
-
681
- - **Symptom:** The report carries `warning: "budget-exceeded"` and `score.xorFraction` is higher than expected.
682
- - **Cause:** The acceptance loop hit its boolean budget before the residual converged. The report is partial but honestly scored — not wrong, just incomplete.
683
- - **Fix:** Raise `--budget`, or accept the partial description. A part needing far more than the default is usually one where a residual region is being attacked by many near-identical candidates; check `residual.regions` first.
684
-
685
- ## describe-unreadable
686
-
687
- - **Symptom:** `{"error": "unreadable"}`.
688
- - **Cause:** `solid.toMesh()` threw rather than returning geometry — a corrupt or otherwise unrepresentable solid.
689
- - **Fix:** Confirm the source file parses cleanly upstream (`k.import` itself throws separately for an unparseable STL/3MF/STEP — see [import-unknown-name](#import-unknown-name)); re-export the file if the kernel cannot read back what it just built. STL is assumed to be in millimetres (the format carries no unit metadata) — see the import section of AUTHORING-PARTS.md.
690
-
691
655
  ## control-default-not-literal
692
656
 
693
657
  - **Symptom:** A control works live — the slider moves, the geometry updates — but the user's panel edits are gone when the part is reopened. Nothing throws anywhere.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.85.4",
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
+ }
@@ -26,22 +26,16 @@ const loadInspect = () => Promise.all([
26
26
  import("./oracle/verify.js"),
27
27
  ]);
28
28
 
29
- // The DESCRIBE stack is not part of this package at all: the semantic mesh oracle
30
- // lives in a separate, closed package, and this open framework never names it. A
31
- // host that has it INJECTS a loader `runWorker(part, { loadOracle })`, threaded
32
- // here as `opts.loadOracle`, resolving to the oracle package's barrel (describe,
33
- // describeMemo, compactDescribe). Injection rather than a bare import specifier is
34
- // deliberate: a literal `import("@scope/pkg")` in open source would fail every
35
- // downstream Vite build where the package isn't installed, while an injected thunk
36
- // is simply absent and an absent oracle answers the job with the structured
37
- // `oracle-unavailable` report below instead of stalling or throwing.
38
-
39
- // One describe memo for the life of this worker, created alongside the stack's first
40
- // load. Deliberately NOT swept on setPart the way solid-cache is: describe is pure in
41
- // the mesh bytes (spec §4.1), so an edit can never invalidate it, and dropping it on
42
- // rebind would throw away the single most expensive thing this worker computes for no
43
- // reason at all. Keyed by content digest, so a genuinely changed file misses correctly.
44
- let DESCRIBE_MEMO = null;
29
+ // HOST JOBS the extension seam. A host registers its own job types with
30
+ // `runWorker(part, { jobs: { <type>: handler } })`, threaded here as `opts.jobs`, and
31
+ // a message whose type matches no built-in below is handed to that handler with the
32
+ // live kernel, the current part, the message and the poster. This is how a host adds
33
+ // a capability the open framework does not ship partforge-cloud's semantic
34
+ // mesh-oracle describe job lives entirely in its own closed package and arrives here
35
+ // as one of these without this repo naming it, importing it, or knowing its message
36
+ // shapes. Built-ins win a name clash (a host cannot redefine `generate`); a type with
37
+ // no handler at all is ignored, as unknown types always were. A handler's throw is
38
+ // posted as the ordinary `{type: "error"}` any failed job posts.
45
39
 
46
40
  // Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
47
41
  // Backend-agnostic and part-agnostic: every part specific comes through `part`.
@@ -414,47 +408,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
414
408
  const match = await scoreMatchTargets(built, msg.matchTargets, onProgress);
415
409
  if (match) report.match = match;
416
410
  post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
417
- } else if (msg.type === "describe") {
418
- // Semantic description of an IMPORTED mesh — not of the built part. The two are
419
- // different questions: `inspect` asks "what did this source build?", `describe`
420
- // asks "what is this file?". describe never touches the part's own geometry, which
421
- // is why it takes an import name rather than a view.
422
- //
423
- // Manifold only, and not by choice on this path: mesh imports on OCCT are never
424
- // attempted, so a describe job posted to an OCCT worker is a routing bug, not a
425
- // fallback opportunity. It surfaces as an ordinary error rather than a reroute.
426
- if (!opts.loadOracle) {
427
- // Same closed-set, returned-not-thrown error contract describe itself keeps
428
- // (its errors are findings, not crashes) — shaped as the structured triple the
429
- // whole repo emits, so a caller can act on the code. ERROR-PATTERNS.md#describe-
430
- // oracle-unavailable carries the fix.
431
- post({ type: "describe-report", report: {
432
- error: "oracle-unavailable",
433
- detail: "this app was built without the mesh oracle package",
434
- diagnostic: {
435
- cause: "the describe job needs the closed oracle package, and no loadOracle loader was injected into runWorker",
436
- location: `describe "${msg.importName}"`,
437
- correctiveAction: "install the oracle package and pass runWorker(part, { loadOracle: () => import(...) }); see ERROR-PATTERNS.md#describe-oracle-unavailable",
438
- },
439
- source: { name: msg.importName, digest: null },
440
- } });
441
- return;
442
- }
443
- const { describe: describeMesh, describeMemo, compactDescribe } = await opts.loadOracle();
444
- const solid = kernel.import(msg.importName); // throws on an unknown name
445
- // `_importDigest` is the backend's existing underscore side-channel (KERNEL-CONTRACT
446
- // "Conformance classes") — the same digest already folded into every import cache key.
447
- const digest = kernel._importDigest?.(msg.importName) ?? null;
448
- DESCRIBE_MEMO ??= describeMemo();
449
- const full = describeMesh(kernel, solid, {
450
- name: msg.importName,
451
- digest,
452
- budget: msg.budget,
453
- memo: DESCRIBE_MEMO,
454
- });
455
- // The compact shape is derived, never memoised separately: one memo entry per
456
- // mesh, two views of it, no way for the two to drift.
457
- post({ type: "describe-report", report: msg.compact ? compactDescribe(full) : full });
411
+ } else if (opts.jobs && Object.hasOwn(opts.jobs, msg.type)) {
412
+ await opts.jobs[msg.type](kernel, part, msg, post, { isStale: opts.isStale });
458
413
  }
459
414
  } catch (err) {
460
415
  // `subparts` (generate jobs only) tells the reroute policy which sub-parts the
@@ -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
@@ -36,9 +36,9 @@ async function occtKernel() {
36
36
  return createOcctKernel(replicad);
37
37
  }
38
38
 
39
- // `opts.loadOracle` — the injection seam for the closed mesh-oracle package (see
40
- // jobs.js's describe branch): a thunk resolving to the oracle barrel. Apps without
41
- // the package simply omit it and describe jobs answer `oracle-unavailable`.
39
+ // `opts.jobs` — host-registered job handlers, `{ <type>: (kernel, part, msg, post,
40
+ // ctx) => … }` (see jobs.js's HOST JOBS comment). A message type no built-in claims
41
+ // goes to the matching handler; apps with nothing to add simply omit it.
42
42
  export function runWorker(part, opts = {}) {
43
43
  const backend = self.name === "occt" ? "occt" : "manifold";
44
44
  let manifold = null; // { preview, print }
@@ -102,7 +102,7 @@ export function runWorker(part, opts = {}) {
102
102
  const kernel = await kernelFor(job.data);
103
103
  // handle() declares each message's transferables (the big binary buffers).
104
104
  const post = (m, transfer = []) => postMessage(m, transfer);
105
- if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes, loadOracle: opts.loadOracle }); continue; }
105
+ if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes, jobs: opts.jobs }); continue; }
106
106
  const isStale = () => job.epoch !== epoch;
107
107
  // Post gate. The boundary check cannot catch a generate that goes stale during
108
108
  // its FINAL sub-part — there is no boundary after it — nor a single-sub-part
@@ -111,7 +111,7 @@ export function runWorker(part, opts = {}) {
111
111
  // contract simple: a `meshes` post is current as of the moment it is posted.
112
112
  const gated = (m, transfer = []) =>
113
113
  (m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
114
- await handle(kernel, job.part, job.data, gated, { isStale, importMeshes, loadOracle: opts.loadOracle });
114
+ await handle(kernel, job.part, job.data, gated, { isStale, importMeshes, jobs: opts.jobs });
115
115
  } catch (err) {
116
116
  // Same shape jobs.js posts for a failed build, so hosts need no new branch.
117
117
  // Carry the job's jobId when it has one (capture/export are correlated by it):
package/src/oracle.js CHANGED
@@ -9,11 +9,11 @@
9
9
  // test/oracle-entry.test.js walks the closure and holds that, so the entry stays
10
10
  // importable from a worker, a browser, or Node alike.
11
11
  //
12
- // The SEMANTIC MESH ORACLE (`describe`) is NOT here: it lives in its own closed
13
- // package, which peer-depends on this one and consumes exactly this entry the
14
- // mesh/BVH helpers and file parsers below are exported for it. The framework
15
- // reaches it only through injection (`runWorker(part, { loadOracle })`, jobs.js)
16
- // and the CLI resolves it at call time; neither ever bundles it.
12
+ // The SEMANTIC MESH ORACLE (imported mesh -> feature report) is NOT here, and this
13
+ // framework has no verb, job or import for it: it is its own closed package, which
14
+ // peer-depends on this one and consumes exactly this entry the mesh/BVH helpers
15
+ // and file parsers below are exported for it. A host that installs it registers its
16
+ // job through the generic `runWorker(part, { jobs })` seam (jobs.js).
17
17
  export { assemblyGaps, meshGaps } from "./framework/oracle/gaps.js";
18
18
  export { meshVolume, bboxSize, bounds, meshArea } from "./framework/oracle/mesh.js";
19
19
  export { buildView } from "./framework/oracle/build.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/worker.d.ts CHANGED
@@ -17,13 +17,29 @@ export interface WorkerHandle {
17
17
  setPart(newPart: PartDefinition): void;
18
18
  }
19
19
 
20
+ /**
21
+ * A host-registered job handler (`runWorker`'s `opts.jobs`): receives the live
22
+ * kernel, the part current when the message arrived, the message itself, the
23
+ * poster for results (`post(msg, transferables?)`), and a context with `isStale`
24
+ * (set for jobs that can be superseded by a rebind). A throw is posted as the
25
+ * ordinary `{type: "error", message, jobId}`.
26
+ */
27
+ export type HostJob = (
28
+ kernel: unknown,
29
+ part: PartDefinition,
30
+ msg: { type: string; jobId?: number; [key: string]: unknown },
31
+ post: (msg: object, transfer?: Transferable[]) => void,
32
+ ctx: { isStale?: () => boolean },
33
+ ) => void | Promise<void>;
34
+
20
35
  /**
21
36
  * Run the worker job loop for `part`. Call once, at worker module top level.
22
- * `opts.loadOracle` injects the closed semantic-mesh-oracle package (a thunk
23
- * resolving its barrel: `describe`, `describeMemo`, `compactDescribe`); omitted,
24
- * describe jobs answer with a structured `oracle-unavailable` report.
37
+ * `opts.jobs` registers host job types by message `type`: a message no built-in
38
+ * job claims is handed to the matching handler; built-ins are not overridable and
39
+ * a type with no handler is ignored. This is how a host adds capabilities the open
40
+ * framework does not ship.
25
41
  */
26
42
  export function runWorker(
27
43
  part: PartDefinition,
28
- opts?: { loadOracle?: () => Promise<{ describe: Function; describeMemo: () => Map<string, unknown>; compactDescribe: Function }> },
44
+ opts?: { jobs?: Record<string, HostJob> },
29
45
  ): WorkerHandle;