partforge 0.141.0 → 0.142.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.
@@ -103,8 +103,8 @@ export default {
103
103
  - `place(solid, ctx)` is an optional escape hatch for parts whose **display pose differs
104
104
  from their export pose** (e.g. positioning a sub-part in an assembly). `ctx.purpose` is
105
105
  `"display"` or `"export"`; `ctx.view` is the active view. Default is identity, so simple
106
- parts omit it. **Display placement must not depend on `view`** — display meshes are built
107
- once per sub-part and cached across views (the viewer re-centres per view).
106
+ parts omit it. Display placement may depend on `view`: the viewer re-poses each
107
+ sub-part when the tab changes, so one sub-part can sit differently in two views.
108
108
  The viewer applies the display pose as a matrix over the canonical mesh, so `build()` may
109
109
  query geometry freely; only `place()` has to stay a rigid motion of its argument, reading
110
110
  `p` and `d`, for a pose-only param to play at frame rate and for layer lines to stay on
@@ -3721,12 +3721,11 @@ affect `ok`, `measure`, or `--strict`.
3721
3721
 
3722
3722
  **Place invariants**, found by running the geometry-free place probe (the same
3723
3723
  one animation's `animation-track-rebuilds` uses) against each sub-part's
3724
- `place()`, even when `build()` queries the solid — `view-dependent-display-place` (display placement must not depend
3725
- on the active view, since display meshes are cached across views) and
3726
- `place-not-rigid` (display vs. export placement may differ only by a rigid
3727
- motion — translate/rotate — never a reshape) (both errors). A `place()` the probe
3728
- cannot read (it queries the solid or passes a function) stays silent for the two
3729
- place rules and earns the `animation-track-rebuilds` note.
3724
+ `place()`, even when `build()` queries the solid — `place-not-rigid` (display vs.
3725
+ export placement may differ only by a rigid motion — translate/rotate — never a
3726
+ reshape; an error). A `place()` the probe cannot read (it queries the solid or
3727
+ passes a function) stays silent for the place rule and earns the
3728
+ `animation-track-rebuilds` note.
3730
3729
 
3731
3730
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3732
3731
  library does not know; the viewer draws it as if it named none — a PLA print
@@ -3994,7 +3993,9 @@ a **min-wall** warning, and — only for a part that also declares
3994
3993
  `orientation: "print"` — an **overhang** warning: `overhangArea` is the mm² of
3995
3994
  downward-facing surface steeper than the profile's `overhang` angle (45° from vertical
3996
3995
  on the FDM profiles; resin carries none, since it prints on supports), measured per
3997
- sub-part with the bed at that sub-part's own lowest Z, and warned past 1 mm². The
3996
+ sub-part in its print (export) pose — not as the view shows it — with the bed at that
3997
+ pose's own lowest Z, and warned past 1 mm²; the reported location is where the face sits
3998
+ in the view. The
3998
3999
  opt-in is deliberate: a profile says what a process can print, the orientation key
3999
4000
  says this part is laid out for it, and a part still being shaped, or one bound for a
4000
4001
  different process, should not be nagged about its underside. Writing your own
@@ -4429,8 +4430,6 @@ symptom first** — it maps error text → cause → fix. The invariants, one li
4429
4430
  232 and a box is 12, and every one is a boolean operand — so prefer instancing one
4430
4431
  union of a row over a chain of per-feature booleans, and drop counts the print
4431
4432
  cannot show.
4432
- - **Display placement is view-independent**; only `place(..., { purpose: "export" })` may
4433
- depend on `view` ([view-dependent-display-place](ERROR-PATTERNS.md#view-dependent-display-place)).
4434
4433
  - **Keep geometry backend-agnostic** (kernel calls only); only STEP requires OCCT
4435
4434
  ([probe-routed-to-occt](ERROR-PATTERNS.md#probe-routed-to-occt),
4436
4435
  [occt-holes-watertight-na](ERROR-PATTERNS.md#occt-holes-watertight-na)).
@@ -138,12 +138,6 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
138
138
  - **Cause:** OCCT and Manifold WASM must not boot in the same process.
139
139
  - **Fix:** Keep OCCT-booting tests in their own files (vitest isolates per file) and boot via `bootOcctKernel()` in a `beforeAll`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Testing a part".
140
140
 
141
- ## view-dependent-display-place
142
-
143
- - **Symptom:** A sub-part renders correctly in one view but appears misplaced (usually in its other-view pose) after switching views.
144
- - **Cause:** A `place` that depends on `ctx.view` for `purpose: "display"` — display meshes are built once per sub-part and cached across views.
145
- - **Fix:** Make display placement view-independent; only `place(..., { purpose: "export" })` may branch on `view`. The rule sees a `place()` behind any `build()`, including one that queries the solid; a `place()` it cannot read stays silent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
146
-
147
141
  ## place-not-rigid
148
142
 
149
143
  - **Symptom:** The exported/printed part is a mirror image of — or a different size than — the same part shown in the assembly/display view. Nothing throws: the preview looks right and only the STL/STEP is wrong, or vice-versa.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.141.0",
3
+ "version": "0.142.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,10 +1,13 @@
1
- // Group 6 — the two place() invariants (docs/AUTHORING-PARTS.md "Display vs
2
- // export placement"), promoted from doc-only conventions to lint because the
3
- // animation system leans on place() for every pose-only track:
4
- // 1. Display placement must not depend on the active view (display meshes
5
- // are cached across views; a view-dependent pose serves stale geometry).
6
- // 2. Display vs export may differ only by a rigid motion (translate/rotate).
7
- // Both checks run the place-scope pose probe: place() alone, on a canonical token,
1
+ // Group 6 — the place() invariant (docs/AUTHORING-PARTS.md "Display vs export
2
+ // placement"), promoted from a doc-only convention to lint because the animation
3
+ // system leans on place() for every pose-only track: display vs export may differ
4
+ // only by a rigid motion (translate/rotate). A second rule, view-dependent-display-
5
+ // place, forbade a display pose that varies by view; it was retired in 0.142 — since
6
+ // 0.141 the viewer meshes the canonical build once (the mesh cache stamps the view)
7
+ // and re-poses it on every tab switch, so the stale-geometry hazard it named is gone,
8
+ // and as a lint ERROR it refused to load the one-piece-per-sub-part shape a per-view
9
+ // layout needs (partforge-cloud feedback #161/#162).
10
+ // The check runs the place-scope pose probe: place() alone, on a canonical token,
8
11
  // so build() may query freely without hiding a finding. A READABLE result carries
9
12
  // `baseHash` (CANONICAL for translate/rotate only, another hash when place()
10
13
  // reshapes) and `pose`; a place() the probe cannot read (it queries the solid or
@@ -26,35 +29,10 @@ function viewNames(part, view, p) {
26
29
  .map(([name]) => name);
27
30
  }
28
31
 
29
- const poseKey = (pose) => JSON.stringify(pose);
30
32
  const readable = (probe) => probe.baseHash !== undefined;
31
33
  const probePlace = (sp, ctx) => probeSubPartPose(sp, ctx, { scope: "place" });
32
34
 
33
35
  export const PLACE_RULES = [
34
- {
35
- id: "view-dependent-display-place",
36
- run: ({ part, p, d }) => {
37
- const out = [];
38
- const views = Object.keys(isPlainObject(part?.views) ? part.views : {});
39
- if (views.length < 2) return out;
40
- for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
41
- const inViews = views.filter((v) => viewNames(part, v, p).includes(name));
42
- if (inViews.length < 2) continue;
43
- const probes = inViews.map((view) => probePlace(sp, { view, purpose: "display", p, d }));
44
- if (!probes.every(readable)) continue;
45
- const first = probes[0];
46
- const differs = probes.some((x) => x.baseHash !== first.baseHash || poseKey(x.pose) !== poseKey(first.pose));
47
- if (differs) {
48
- out.push(err("view-dependent-display-place",
49
- `sub-part "${name}" display placement differs between views (${inViews.join(", ")})`,
50
- "Display meshes are built once per sub-part and cached across views, so a view-dependent display pose shows stale geometry after a tab switch. Only `place(..., { purpose: \"export\" })` may vary; keep the display branch view-independent.",
51
- `parts.${name}.place`,
52
- "view-dependent-display-place"));
53
- }
54
- }
55
- return out;
56
- },
57
- },
58
36
  {
59
37
  id: "place-not-rigid",
60
38
  run: ({ part, p, d }) => {
@@ -24,3 +24,21 @@ export function printFrameMatrix(subPart, { view, p, d, frame = "posed" }) {
24
24
  if (!disp.trusted || !exp.trusted || disp.baseHash !== exp.baseHash) return [...IDENTITY];
25
25
  return poseDelta(exp.pose, disp.pose);
26
26
  }
27
+
28
+ const isIdentity = (m) => m.every((v, i) => Math.abs(v - IDENTITY[i]) < 1e-12);
29
+
30
+ // The map from a DISPLAY-posed solid to the same piece in its print (export) pose, for
31
+ // the oracle's overhang reading — or null when the two poses are the same (no place(),
32
+ // or a place() that moves nothing between purposes) or cannot be read. Place-scope
33
+ // probes first: both poses are place(build), so E · D⁻¹ needs only place() to be
34
+ // readable, and a build that queries its solid cannot make it untrusted. A place()
35
+ // that only the full-scope probe reads falls back to printFrameMatrix's posed branch.
36
+ export function displayToPrintMatrix(subPart, { view, p, d }) {
37
+ if (!subPart?.place) return null;
38
+ const disp = probeSubPartPose(subPart, { view, purpose: "display", p, d }, { scope: "place" });
39
+ const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d }, { scope: "place" });
40
+ const m = disp.trusted && exp.trusted
41
+ ? poseDelta(exp.pose, disp.pose)
42
+ : printFrameMatrix(subPart, { view, p, d, frame: "posed" });
43
+ return isIdentity(m) ? null : m;
44
+ }
@@ -4,7 +4,8 @@ import { assemblyOverlaps } from "../assembly.js";
4
4
  import { resolveParams, buildPosed } from "../part-model.js";
5
5
  import { newReadSink, expandReads } from "../read-recorder.js";
6
6
  import { probeSubPartPose } from "../pose-probe-core.js";
7
- import { composePose } from "../geometry/pose.js";
7
+ import { composePose, invertRigid, transformPositions } from "../geometry/pose.js";
8
+ import { displayToPrintMatrix } from "../materials/print-frame.js";
8
9
  import { isSheetPart, sheetMeta, SHEET_CHECK_BUDGET_MS, MARK_DEPTH } from "../sheet/constants.js";
9
10
  import { resolveSheet } from "../sheet/resolve.js";
10
11
  import { processFor } from "../process/registry.js";
@@ -128,6 +129,21 @@ const transformPoint = (m, [x, y, z]) => [
128
129
  m[2] * x + m[6] * y + m[10] * z + m[14],
129
130
  ];
130
131
 
132
+ // Overhang of one printed sub-part in its PRINT pose. measure builds the display pose,
133
+ // and a piece shown assembled (a lid closed over its box) is not how it prints: judging
134
+ // its underside there flagged the wrong faces and missed the real ones. The display mesh
135
+ // is carried into the export pose by a rigid matrix — no second build — and the bed is
136
+ // that pose's own lowest Z; `at` comes back in display coordinates, where every other
137
+ // finding of this view sits. An unreadable or identical pose measures as shown.
138
+ function printPoseOverhang(mesh, sp, { view, p, d, maxAngle, displayBedZ }) {
139
+ const m = p ? displayToPrintMatrix(sp, { view, p, d }) : null;
140
+ if (!m) return overhang(mesh, { maxAngle, bedZ: displayBedZ });
141
+ const positions = Float32Array.from(mesh.positions);
142
+ transformPositions(positions, m);
143
+ const out = overhang({ positions, indices: mesh.indices }, { maxAngle });
144
+ return out?.at ? { ...out, at: transformPoint(invertRigid(m), out.at) } : out;
145
+ }
146
+
131
147
  // One sheet row's facts, within `budget.left` ms of 2-D work, which it spends (see
132
148
  // measure()). A 2-D location is lifted through the sub-part's DISPLAY pose, as the geometry-free
133
149
  // probe records it, at mid-thickness; a pose the probe cannot trust (a place() that
@@ -268,6 +284,10 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
268
284
  // declaration order). A view without a sheet part measures exactly as it always has.
269
285
  const sheetView = built.some(({ name }) => isSheetPart(part.parts[name]));
270
286
  const sheetParams = sheetView ? resolveParams(part, params, undefined, reads) : null;
287
+ // The overhang reading poses each piece for print (printPoseOverhang), which reads the
288
+ // resolved params through place() — recorded into the same sink, since the reading
289
+ // depends on them.
290
+ const poseParams = overhangAngle != null ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
271
291
  // The print-pose sizes are read by one check, the process bed (verify passes whether
272
292
  // its profile has one; alone, measure asks the part's own profile), and each costs a
273
293
  // second build of the part — so they are built only for a bed.
@@ -286,14 +306,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
286
306
  ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples, band: wallBands[name] ?? null })
287
307
  : null;
288
308
  // One pass over the triangles, no index — cheap enough for every lap. The bed
289
- // is this sub-part's own lowest Z, already in hand from bounds(). A sub-part
290
- // that is never printed (`exportable: false` — a reference ghost, a probe
291
- // slab, a placeholder) is not judged. Judged in the DISPLAY pose, which is
292
- // what measure builds; a part whose export pose differs (a lid that prints
293
- // flat beside its base) is a known gap, stated in the authoring docs.
294
- // A sheet part is laser-cut, never printed: no overhang reading for it either.
309
+ // is this sub-part's own lowest Z in its PRINT pose (printPoseOverhang). A
310
+ // sub-part that is never printed (`exportable: false` — a reference ghost, a
311
+ // probe slab, a placeholder) is not judged. A sheet part is laser-cut, never
312
+ // printed: no overhang reading for it either.
295
313
  const printed = part.parts[name]?.exportable !== false && !sheet;
296
- const oh = overhangAngle != null && printed ? overhang(mesh, { maxAngle: overhangAngle, bedZ: b.min[2] }) : null;
314
+ const oh = overhangAngle != null && printed
315
+ ? printPoseOverhang(mesh, part.parts[name], { view, ...poseParams, maxAngle: overhangAngle, displayBedZ: b.min[2] })
316
+ : null;
297
317
  const vol = solid.volume();
298
318
  // Deviation-from-reference: only for a sub-part that declares `reference:
299
319
  // "<import name>"` (Task 12 — the gate that holds a parametric rebuild to