partforge 0.140.0 → 0.141.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.
@@ -105,6 +105,10 @@ export default {
105
105
  `"display"` or `"export"`; `ctx.view` is the active view. Default is identity, so simple
106
106
  parts omit it. **Display placement must not depend on `view`** — display meshes are built
107
107
  once per sub-part and cached across views (the viewer re-centres per view).
108
+ The viewer applies the display pose as a matrix over the canonical mesh, so `build()` may
109
+ query geometry freely; only `place()` has to stay a rigid motion of its argument, reading
110
+ `p` and `d`, for a pose-only param to play at frame rate and for layer lines to stay on
111
+ the part.
108
112
  **Any difference between the display and export pose must be a rigid motion** —
109
113
  `translate`/`rotate`/`rotateAbout`/`along`/`at` only. Never put a `mirror` or a
110
114
  non-identity `scale` on one purpose but not the other: the exported (printed) part is the
@@ -113,6 +117,8 @@ export default {
113
117
  ([place-not-rigid](ERROR-PATTERNS.md#place-not-rigid)). If a part genuinely needs a
114
118
  reflected or resized form (e.g. a block that seats flipped), bake that into `build` so
115
119
  both purposes share one canonical solid, then pose it rigidly.
120
+ Branch on `purpose` so the export pose is the print orientation: an export pose that
121
+ follows an animated param pins the layer lines to the pose at the last rebuild.
116
122
  - `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
117
123
  - A view's sub-parts are derived, never hard-coded: those whose `views` include the view
118
124
  and whose `enabled(p)` is true.
@@ -285,10 +291,11 @@ Rules (all lint-enforced):
285
291
  tracks: { lidOpen: [[0, 0], [1, 100]] } },
286
292
  ]
287
293
  ```
288
- - Playback drives params through the real param pipeline: a **pose-only**
289
- param (feeds only rigid placement — see "Caching & determinism" below) plays
290
- at frame rate; anything else rebuilds best-effort at worker cadence. `lint`
291
- prints a note per track that can't take the fast path.
294
+ - Playback drives params through the real param pipeline. A param read only
295
+ inside `place()` plays at frame rate whatever `build()` does. A param
296
+ `build()` reads rebuilds at worker cadence — except through a trailing
297
+ translate/rotate, which the viewer still re-poses by delta. `lint` notes a track whose
298
+ `place()` it cannot read (a query on the solid, a function argument).
292
299
  - Playback pauses when the user edits any control; Reset restores the values
293
300
  the animation found. Because animated values are real params, exporting
294
301
  while paused exports the posed state — by design.
@@ -788,7 +795,8 @@ lid's open angle, an exploded-view offset) therefore re-drags in ~0 ms even on t
788
795
  slow exact kernel — keep such transforms as the last ops in `build` (or in `place`)
789
796
  rather than baking them into the geometry earlier. In the app, such pose-only edits
790
797
  skip the worker entirely — the viewer re-poses the cached mesh — so they stay smooth
791
- even at animation rates (see `runtime.setParams`).
798
+ even at animation rates (see `runtime.setParams`). `place()` needs no geometry: the viewer
799
+ reads its pose off a geometry-free probe of `place()` alone, so `build()` may query freely.
792
800
 
793
801
  ---
794
802
 
@@ -3706,19 +3714,19 @@ key, which the runtime ignores), `animations-not-object`,
3706
3714
  `animation-autoplay-invalid` (all errors). One
3707
3715
  more rule does execute `build`, geometry-free: `animation-track-rebuilds` probes
3708
3716
  each track's endpoint values and emits a **note** when the animated param feeds
3709
- real geometry (or the probe can't be trusted), because such a track plays
3710
- best-effort rather than at frame rate. Notes are informational — they never
3717
+ real geometry, or when its `place()` can't be read (it queries the solid or
3718
+ passes a function), because such a track plays best-effort rather than at frame
3719
+ rate. Notes are informational — they never
3711
3720
  affect `ok`, `measure`, or `--strict`.
3712
3721
 
3713
- **Place invariants**, found by running the geometry-free pose probe (the same
3722
+ **Place invariants**, found by running the geometry-free place probe (the same
3714
3723
  one animation's `animation-track-rebuilds` uses) against each sub-part's
3715
- `place()` — `view-dependent-display-place` (display placement must not depend
3724
+ `place()`, even when `build()` queries the solid — `view-dependent-display-place` (display placement must not depend
3716
3725
  on the active view, since display meshes are cached across views) and
3717
3726
  `place-not-rigid` (display vs. export placement may differ only by a rigid
3718
- motion — translate/rotate — never a reshape) (both errors). An untrusted probe
3719
- (a query op or function selector reached during `build`/`place`) proves
3720
- nothing either way and stays silent, matching `animation-track-rebuilds`'s own
3721
- trust handling.
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.
3722
3730
 
3723
3731
  **Appearance** (all warnings) — `unknown-material` (a `display.material` the
3724
3732
  library does not know; the viewer draws it as if it named none — a PLA print
@@ -142,13 +142,13 @@ Variant literals under this entry: `extrude: unknown bevel option`, `extrude: be
142
142
 
143
143
  - **Symptom:** A sub-part renders correctly in one view but appears misplaced (usually in its other-view pose) after switching views.
144
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`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
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
146
 
147
147
  ## place-not-rigid
148
148
 
149
149
  - **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.
150
150
  - **Cause:** A `place` whose `purpose: "display"` and `"export"` branches differ by a non-rigid transform — `mirror` (flips handedness) or a non-identity `scale` (changes size) — so display and export are no longer the same solid, only its reflection/resize.
151
- - **Fix:** Keep the display-vs-export `place` difference a rigid motion (`translate`/`rotate`/`rotateAbout`/`along`/`at`) only. If the part genuinely needs a reflected or resized form, bake that into `build` so both purposes share one canonical solid and pose it rigidly. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "The `PartDefinition` contract".
151
+ - **Fix:** Keep the display-vs-export `place` difference a rigid motion (`translate`/`rotate`/`rotateAbout`/`along`/`at`) only. If the part genuinely needs a reflected or resized form, bake that into `build` so both purposes share one canonical solid and pose it rigidly. 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".
152
152
 
153
153
  ## wrong-node-version
154
154
 
@@ -485,8 +485,8 @@ Variant literal for a curve-adjacent corner: `filletProfile: corner <i> at (<x>,
485
485
  ## animation-plays-choppy
486
486
 
487
487
  - **Symptom:** An animation stutters or updates a few times a second instead of smoothly; `?debug` shows `rebuilt` counts climbing during playback.
488
- - **Cause:** A track drives a param that feeds real geometry (or a build the pose probe can't trust — a query op or function selector), so every frame is a worker rebuild instead of a pose repair.
489
- - **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track. Restructure so the param only feeds rigid placement (`place()` or a trailing translate/rotate in `build`), or accept best-effort playback if geometry morphing is the intent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Animations".
488
+ - **Cause:** A track drives a param `build()` reads (that rebuilds at worker cadence — except through a trailing translate/rotate, which the viewer still re-poses by delta), or a `place()` the probe cannot read (it queries the solid or passes a function), so every frame is a worker rebuild instead of a matrix pose.
489
+ - **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track and says which case it is. Restructure so the param is read only by `place()` (a rigid translate/rotate of its argument, reading `p`/`d`), or accept best-effort playback if geometry morphing is the intent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Animations".
490
490
 
491
491
  ## phantom-edges-on-curved-surface
492
492
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.140.0",
3
+ "version": "0.141.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",
@@ -47,17 +47,18 @@ export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
47
47
  // figure doesn't blank the others: a pose-only edit ran no build and no L2 ops,
48
48
  // and would otherwise wipe the last build's timing back to "—" on its way to
49
49
  // showing a posed count.
50
- const shown = { ms: null, hits: 0, misses: 0, skipped: 0, rebuilt: 0, posed: 0 };
50
+ const shown = { ms: null, hits: 0, misses: 0, skipped: 0, rebuilt: 0, posed: 0, frame: null };
51
51
 
52
52
  return {
53
53
  update(counts = {}) {
54
54
  Object.assign(shown, counts);
55
- const { ms, hits, misses, skipped, rebuilt, posed } = shown;
55
+ const { ms, hits, misses, skipped, rebuilt, posed, frame } = shown;
56
56
  const l2 = cb.checked ? `${hits} hit / ${misses} miss` : "off";
57
57
  readout.textContent =
58
58
  `build: ${ms != null ? Math.round(ms) + " ms" : "—"}\n` +
59
59
  `L2 ops: ${l2}\n` +
60
- `L1 parts: ${skipped} skipped / ${rebuilt} rebuilt / ${posed} posed`;
60
+ `L1 parts: ${skipped} skipped / ${rebuilt} rebuilt / ${posed} posed` +
61
+ (frame ? ` · ${frame}` : ""); // delivery frames of the in-view meshes
61
62
  },
62
63
  detach: () => box.remove(),
63
64
  };
@@ -11,8 +11,9 @@
11
11
 
12
12
  const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
13
13
 
14
- // column-major 4x4 product: (A·B)[c][r] = Σk A[k][r]·B[c][k]
15
- function mulMat4(A, B) {
14
+ // column-major 4x4 product: (A·B)[c][r] = Σk A[k][r]·B[c][k]. Exported for the
15
+ // pose fast path, which composes a place pose over a build delta.
16
+ export function mulMat4(A, B) {
16
17
  const o = new Array(16);
17
18
  for (let c = 0; c < 4; c++)
18
19
  for (let r = 0; r < 4; r++)
@@ -16,6 +16,7 @@ import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./ve
16
16
  import { vectorsFor, ensureVectors } from "./vectors.js";
17
17
  import { exportSubParts, resolveParamsAttributed, recordedParams, buildPosed } from "./part-model.js";
18
18
  import { newReadSink, expandReads } from "./read-recorder.js";
19
+ import { probeSubPartPose } from "./pose-probe-core.js";
19
20
 
20
21
  export const FONT_SOURCE_CACHE_MAX = 4;
21
22
 
@@ -397,15 +398,23 @@ export async function handle(kernel, part, msg, post, opts = {}) {
397
398
  await ensureVectors(kernel, declaredVectors);
398
399
  // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
399
400
  const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
400
- // A display build plus the sorted raw keys it read (spec §1): its own p/d
401
- // reads, derive-expanded, plus the job's asset-declaration reads.
402
- const posedWithReads = (name) => {
401
+ // Canonical delivery (spec 2026-10-03 place-only pose, §2): when place() can be read
402
+ // off a token — rigid, no query, no function — the mesh is built WITHOUT it and
403
+ // stamped with build reads alone; the viewer applies the display pose as a matrix.
404
+ // Otherwise the pose is baked and both halves' reads are recorded, as before.
405
+ // The probe runs on the plain p/d, never the recorders, so place reads cannot
406
+ // land in a canonical stamp.
407
+ const displayWithReads = (name) => {
408
+ const sp = part.parts[name];
403
409
  const sink = newReadSink();
404
410
  sink.attribution = attribution;
405
411
  const rec = recordedParams({ p, d }, sink);
406
- const solid = buildPosed(kernel, part, name, { purpose: "display", view: msg.view, p: rec.p, d: rec.d });
412
+ const canonical = probeSubPartPose(sp, { view: msg.view, purpose: "display", p, d }, { scope: "place" }).trusted === true;
413
+ const solid = canonical
414
+ ? sp.build(kernel, rec.p, rec.d)
415
+ : buildPosed(kernel, part, name, { purpose: "display", view: msg.view, p: rec.p, d: rec.d });
407
416
  for (const key of assetSink.raw) sink.raw.add(key);
408
- return { solid, reads: expandReads(sink) };
417
+ return { solid, reads: expandReads(sink), frame: canonical ? "canonical" : "posed" };
409
418
  };
410
419
  // Explicit selection (headless exportParts) overrides view-derived selection.
411
420
  const selected = () =>
@@ -429,9 +438,9 @@ export async function handle(kernel, part, msg, post, opts = {}) {
429
438
  for (const [i, name] of msg.subparts.entries()) {
430
439
  if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
431
440
  try {
432
- const { solid, reads } = posedWithReads(name);
441
+ const { solid, reads, frame } = displayWithReads(name);
433
442
  const m = solid.toMesh({ quality: "preview" });
434
- meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features, reads });
443
+ meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features, reads, frame });
435
444
  } finally {
436
445
  for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
437
446
  if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
@@ -14,7 +14,8 @@ import { err, note } from "./finding.js";
14
14
  import { EASINGS } from "../animation.js";
15
15
  import { CANONICAL_VIEWS, CAMERA_CUE_VIEWS } from "../view-angles.js";
16
16
  import { probeSubPartPose } from "../pose-probe-core.js";
17
- import { resolveDerived } from "../derive.js";
17
+ import { resolveDerivedAttributed } from "../derive.js";
18
+ import { recorder, expandDerivedReads } from "../read-recorder.js";
18
19
  import { desugar } from "../panel/legacy.js";
19
20
  import { controlNodes } from "../panel/model.js";
20
21
 
@@ -467,8 +468,10 @@ export const ANIMATION_RULES = [
467
468
  out.push(note("animation-track-rebuilds",
468
469
  cls === "rebuild"
469
470
  ? `animation "${name}" track "${key}" rebuilds geometry — playback is best-effort, not frame-rate`
470
- : `animation "${name}" track "${key}" cannot use the pose fast path (untrusted probe) — playback is best-effort`,
471
- "Frame-rate playback needs the param to feed only rigid placement (translate/rotate in `place()` or at the end of `build`). If that's the intent, restructure so the param never feeds a geometry op, a query, or a function selector; if geometry morphing is the intent, this is expected.",
471
+ : `animation "${name}" track "${key}" — \`place()\` cannot be probed (it queries the solid or passes a function), so playback is best-effort`,
472
+ cls === "rebuild"
473
+ ? "Frame-rate playback needs the param to be read only by `place()` (or by a trailing translate/rotate in `build`) — if geometry morphing is the intent, this note is expected."
474
+ : "Keep `place()` to translate/rotate of its argument, reading `p`/`d` only; `build()` may query freely.",
472
475
  base));
473
476
  }
474
477
  }
@@ -502,28 +505,54 @@ export const ANIMATION_RULES = [
502
505
  },
503
506
  ];
504
507
 
505
- // Classify one animated param by probing every sub-part the OWNING view can
506
- // show, at the track's two endpoint values: identical trusted baseHashes at
507
- // both ends → the param only re-poses ("pose"); differing hashes → real
508
- // geometry ("rebuild"); any untrusted probe → "untrusted" (the fast path will
509
- // decline it at runtime too). Mirrors the runtime trust model in
510
- // pose-probe-core.js. Sub-parts outside the owning view cannot be moved by this
511
- // animation, so probing them would only manufacture false notes.
508
+ // Classify one animated param per sub-part the OWNING view can show, at the
509
+ // track's two endpoint values (spec 2026-10-03-place-only-pose §8):
510
+ // 1. place-scope probe unreadable at either end → "untrusted" — the only way
511
+ // the untrusted note fires: the viewer cannot pose that mesh without a rebuild.
512
+ // 2. build-scope probe trusted at both ends with different baseHash → "rebuild".
513
+ // 3. build-scope probe untrusted (build() queries) → "rebuild" when the build
514
+ // READ the key, recorded by the probe run itself and derive-expanded as
515
+ // subPartReadKeys does. A prediction: a read behind a dummy query branch is
516
+ // missed, which costs a missing note, never a wrong behaviour.
517
+ // 4. otherwise → "pose".
518
+ // A place() that is readable but RESHAPES (scale/mirror/boolean, so its baseHash
519
+ // is not CANONICAL) is delivered posed and played by the full-scope delta fast
520
+ // path, so it is classified as before that path changed: the full probe trusted
521
+ // with equal hashes → "pose", anything else → "rebuild" (the runtime rebuilds it).
522
+ // Sub-parts outside the owning view cannot be moved by this animation, so probing
523
+ // them would only manufacture false notes.
512
524
  function classifyTrack(part, p, key, v0, v1, view) {
513
525
  let result = "pose";
514
526
  for (const sp of Object.values(isPlainObject(part?.parts) ? part.parts : {})) {
515
527
  if (!Array.isArray(sp?.views) || !sp.views.includes(view)) continue;
516
- const probes = [];
528
+ const ends = [];
517
529
  for (const v of [v0, v1]) {
518
530
  const pv = { ...p, [key]: v };
519
- let dv;
520
- try { dv = resolveDerived(part, pv) ?? {}; } catch { return "untrusted"; }
521
- try { if (sp.enabled && !sp.enabled(pv)) { probes.push(null); continue; } } catch { return "untrusted"; }
522
- probes.push(probeSubPartPose(sp, { view, purpose: "display", p: pv, d: dv }));
531
+ // An endpoint whose derive() or enabled() throws cannot be shown to be
532
+ // pose-only; "best-effort" is the honest claim, and the untrusted note names
533
+ // place() specifically, so it would mislead here.
534
+ let attributed;
535
+ try { attributed = resolveDerivedAttributed(part, pv); } catch { return "rebuild"; }
536
+ const dv = attributed.d ?? {};
537
+ try { if (sp.enabled && !sp.enabled(pv)) { ends.push(null); continue; } } catch { return "rebuild"; }
538
+ const ctx = { view, purpose: "display", p: pv, d: dv };
539
+ const place = probeSubPartPose(sp, ctx, { scope: "place" });
540
+ if (place.baseHash === undefined) return "untrusted";
541
+ if (!place.trusted) { ends.push({ full: probeSubPartPose(sp, ctx) }); continue; }
542
+ const reads = new Set();
543
+ const dSeen = new Set();
544
+ const build = probeSubPartPose(sp, { ...ctx, p: recorder(pv, reads), d: recorder(dv, dSeen) }, { scope: "build" });
545
+ ends.push({ build, reads: expandDerivedReads(reads, dSeen, attributed) });
546
+ }
547
+ const [a, b] = ends;
548
+ if (!a || !b) continue;
549
+ if (a.full || b.full) {
550
+ if (!a.full?.trusted || !b.full?.trusted || a.full.baseHash !== b.full.baseHash) result = "rebuild";
551
+ } else if (a.build.trusted && b.build.trusted) {
552
+ if (a.build.baseHash !== b.build.baseHash) result = "rebuild";
553
+ } else if (a.reads.has(key) || b.reads.has(key)) {
554
+ result = "rebuild";
523
555
  }
524
- if (probes.some((x) => x && !x.trusted)) return "untrusted";
525
- const [a, b] = probes;
526
- if (a && b && a.baseHash !== b.baseHash) result = "rebuild";
527
556
  }
528
557
  return result;
529
558
  }
@@ -4,9 +4,13 @@
4
4
  // 1. Display placement must not depend on the active view (display meshes
5
5
  // are cached across views; a view-dependent pose serves stale geometry).
6
6
  // 2. Display vs export may differ only by a rigid motion (translate/rotate).
7
- // Both checks run the geometry-free pose probe; an untrusted probe (query op /
8
- // function selector in build or place) proves nothing and stays silent — the
9
- // runtime declines the fast path for those sub-parts anyway.
7
+ // Both checks run the place-scope pose probe: place() alone, on a canonical token,
8
+ // so build() may query freely without hiding a finding. A READABLE result carries
9
+ // `baseHash` (CANONICAL for translate/rotate only, another hash when place()
10
+ // reshapes) and `pose`; a place() the probe cannot read (it queries the solid or
11
+ // passes a function) returns `{ trusted: false }` with no baseHash, proves nothing,
12
+ // and stays silent. Readability, not `trusted`, gates the rules: the same reshape
13
+ // on both purposes is allowed, so a reshaping place() must still be compared.
10
14
  import { err } from "./finding.js";
11
15
  import { probeSubPartPose } from "../pose-probe-core.js";
12
16
 
@@ -23,6 +27,8 @@ function viewNames(part, view, p) {
23
27
  }
24
28
 
25
29
  const poseKey = (pose) => JSON.stringify(pose);
30
+ const readable = (probe) => probe.baseHash !== undefined;
31
+ const probePlace = (sp, ctx) => probeSubPartPose(sp, ctx, { scope: "place" });
26
32
 
27
33
  export const PLACE_RULES = [
28
34
  {
@@ -34,8 +40,8 @@ export const PLACE_RULES = [
34
40
  for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
35
41
  const inViews = views.filter((v) => viewNames(part, v, p).includes(name));
36
42
  if (inViews.length < 2) continue;
37
- const probes = inViews.map((view) => probeSubPartPose(sp, { view, purpose: "display", p, d }));
38
- if (probes.some((x) => !x.trusted)) continue;
43
+ const probes = inViews.map((view) => probePlace(sp, { view, purpose: "display", p, d }));
44
+ if (!probes.every(readable)) continue;
39
45
  const first = probes[0];
40
46
  const differs = probes.some((x) => x.baseHash !== first.baseHash || poseKey(x.pose) !== poseKey(first.pose));
41
47
  if (differs) {
@@ -57,9 +63,9 @@ export const PLACE_RULES = [
57
63
  if (!sp?.place) continue;
58
64
  for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
59
65
  if (!viewNames(part, view, p).includes(name)) continue;
60
- const display = probeSubPartPose(sp, { view, purpose: "display", p, d });
61
- const exportP = probeSubPartPose(sp, { view, purpose: "export", p, d });
62
- if (!display.trusted || !exportP.trusted) continue;
66
+ const display = probePlace(sp, { view, purpose: "display", p, d });
67
+ const exportP = probePlace(sp, { view, purpose: "export", p, d });
68
+ if (!readable(display) || !readable(exportP)) continue;
63
69
  if (display.baseHash !== exportP.baseHash) {
64
70
  out.push(err("place-not-rigid",
65
71
  `sub-part "${name}" display and export placements differ by more than a rigid motion (view "${view}")`,
@@ -5,12 +5,20 @@
5
5
  // list of rigid steps — so the display→export map is E · D⁻¹, with no geometry.
6
6
  // Three-free and DOM-free: the pose probe and pose.js are pure.
7
7
  import { probeSubPartPose } from "../pose-probe-core.js";
8
- import { poseDelta } from "../geometry/pose.js";
8
+ import { poseDelta, composePose } from "../geometry/pose.js";
9
9
 
10
10
  const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
11
11
 
12
- export function printFrameMatrix(subPart, { view, p, d }) {
12
+ // `frame` is the delivery's frame. A "canonical" mesh is the build before place(), so its
13
+ // map to the printed part is the export pose itself (place-scope: no geometry is read, so a
14
+ // build that queries its solid cannot make it untrusted); a "posed" mesh already carries the
15
+ // display pose, so the map is E · D⁻¹.
16
+ export function printFrameMatrix(subPart, { view, p, d, frame = "posed" }) {
13
17
  if (!subPart?.place) return [...IDENTITY];
18
+ if (frame === "canonical") {
19
+ const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d }, { scope: "place" });
20
+ return exp.trusted ? composePose(exp.pose) : [...IDENTITY];
21
+ }
14
22
  const disp = probeSubPartPose(subPart, { view, purpose: "display", p, d });
15
23
  const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d });
16
24
  if (!disp.trusted || !exp.trusted || disp.baseHash !== exp.baseHash) return [...IDENTITY];
@@ -152,7 +152,9 @@ export function burnsFor(sp) {
152
152
  // object space into the canonical sheet frame, the sheet's thickness, and how many plies
153
153
  // its cut walls show (0 unless its preset is `laminated`). null when burnsFor says no or
154
154
  // the pose or thickness cannot be read (the build reports those).
155
- export function sheetFrameFor(sp, { p, d } = {}) {
155
+ // `frame` "canonical": the delivered mesh is the sheet's own canonical solid (place() not
156
+ // applied), so no frame maps it — identity, same t and plies.
157
+ export function sheetFrameFor(sp, { p, d, frame: delivered = "posed" } = {}) {
156
158
  if (!burnsFor(sp)) return null;
157
159
  const meta = sheetMeta(sp, p, d);
158
160
  if (!meta) return null;
@@ -160,7 +162,7 @@ export function sheetFrameFor(sp, { p, d } = {}) {
160
162
  let pose;
161
163
  try { pose = typeof sp.sheet.pose === "function" ? sp.sheet.pose(p, d) : sp.sheet.pose; } catch { return null; }
162
164
  if (pose != null && validatePose(pose)) return null;
163
- const frame = pose == null ? [...IDENTITY] : invertRigid(composePose(poseSteps(pose, t)));
165
+ const frame = delivered === "canonical" || pose == null ? [...IDENTITY] : invertRigid(composePose(poseSteps(pose, t)));
164
166
  return { frame, t, plies: lookOf(sp).preset.laminated ? plyCount(t) : 0 };
165
167
  }
166
168
 
@@ -8,7 +8,7 @@ import { relevanceHash } from "./param-deps.js";
8
8
  // path (spec 2026-10-03). Nothing is predicted; a sub-part with no stamp is not
9
9
  // current. `params` is a stable object mutated in place; the rest are getters.
10
10
  export function createMeshCache(viewer, { params, getView, getParamsVersion, isCaching }) {
11
- const stamps = {}; // name -> { keys: string[] | null (null = every param), hash, view }
11
+ const stamps = {}; // name -> { keys: string[] | null (null = every param), hash, view, frame: "canonical" | "posed" }
12
12
 
13
13
  const hashOf = (keys) => {
14
14
  if (!isCaching()) return `v${getParamsVersion()}`; // caching off: any edit invalidates
@@ -24,14 +24,17 @@ export function createMeshCache(viewer, { params, getView, getParamsVersion, isC
24
24
  // fast path's in-place repair). `reads` undefined = unknown → every param.
25
25
  // `view` is the view the build was DISPATCHED for: a tab switch doesn't bump
26
26
  // the params version, so a reply can land fresh after the live view moved.
27
- record: (name, reads, view = getView()) => {
27
+ // `frame` is the delivery frame: "canonical" for place-only rasterization,
28
+ // "posed" for posed delivery (default, for older worker replies).
29
+ record: (name, reads, view = getView(), frame = "posed") => {
28
30
  const keys = reads == null ? null : [...new Set(reads)].sort();
29
- stamps[name] = { keys, hash: hashOf(keys), view };
31
+ stamps[name] = { keys, hash: hashOf(keys), view, frame };
30
32
  },
31
33
  // The recorded keys, or null for BOTH "no stamp" and "every-param stamp" —
32
34
  // the pose fast path relies on null meaning unknown. hasStamp tells them apart.
33
35
  readsOf: (name) => (stamps[name]?.keys ? new Set(stamps[name].keys) : null),
34
36
  hasStamp: (name) => stamps[name] != null,
37
+ frameOf: (name) => stamps[name]?.frame ?? null,
35
38
  forget: (name) => { delete stamps[name]; },
36
39
  };
37
40
  }
@@ -242,8 +242,8 @@ function createCleanupStack() {
242
242
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
243
243
  // so Vite can bundle the worker (see geometry-service.js).
244
244
  //
245
- // Embedding contract (0.136.0: the font control commits once per picker
246
- // session, on close, not per pick):
245
+ // Embedding contract (0.141.0: a place()-able sub-part is delivered in its
246
+ // canonical frame and posed by the viewer; pose-only edits never rebuild):
247
247
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
248
248
  // await runtime.ready; // first successful build of the default view
249
249
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -576,7 +576,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
576
576
  // that reads params (refreshView / updateRelevance / the loop kick) must
577
577
  // run after it. autoplayKick stays LAST — it starts the new view's own.
578
578
  animCtl?.viewChanged();
579
- pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick();
579
+ pendingPosed.clear(); cutawayChrome.reset(); refreshView();
580
+ // place() may read `view`: re-pose the incoming view's canonical meshes
581
+ // (and clear the posed ones' matrices) before anything else reads them.
582
+ fastPath.apply(viewSubParts(part, view(), params));
583
+ updateRelevance(); loop.kick(); animCtl?.autoplayKick();
580
584
  onViewChange?.(name);
581
585
  },
582
586
  });
@@ -906,6 +910,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
906
910
  params, getView: view, getParamsVersion: () => loop.version(),
907
911
  });
908
912
 
913
+ // ?debug: how the in-view meshes were delivered, e.g. "3 canonical / 1 posed".
914
+ const frameSummary = () => {
915
+ const n = { canonical: 0, posed: 0 };
916
+ for (const name of viewSubParts(part, view(), params)) {
917
+ const f = cache.frameOf(name);
918
+ if (f in n) n[f]++;
919
+ }
920
+ return `${n.canonical} canonical / ${n.posed} posed`;
921
+ };
922
+
909
923
  // paramsVersion of the most recent animation-frame apply. It is what lets
910
924
  // the meshes handler tell "stale because playback moved on" (show it — that
911
925
  // IS best-effort playback) from "stale because the user edited" (discard).
@@ -990,16 +1004,20 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
990
1004
  // snapshot keeps a late computation describing the mesh actually delivered.
991
1005
  const printFrames = {};
992
1006
  const sheetFrames = {};
993
- const undrawnFrames = new Map(); // sub-part -> { view, params } its delivered mesh was built at
994
- function recordFrames(names) {
995
- const wanted = names.filter((n) => layerLined.has(n) || burning.has(n));
1007
+ const undrawnFrames = new Map(); // sub-part -> { at: { view, params } its delivered mesh was built at, frame }
1008
+ // `meshes` are the reply's deliveries: each one's `frame` ("canonical" | "posed"; an older
1009
+ // worker sends none, so posed) rides beside the view/params so the frames describe it.
1010
+ function recordFrames(meshes) {
1011
+ const wanted = meshes.filter((m) => layerLined.has(m.name) || burning.has(m.name));
996
1012
  if (!wanted.length) return;
997
- for (const n of wanted) undrawnFrames.set(n, dispatched.get(n) ?? null); // null: built at params nobody recorded
1013
+ for (const m of wanted) {
1014
+ undrawnFrames.set(m.name, { at: dispatched.get(m.name) ?? null, frame: m.frame ?? "posed" }); // at null: built at params nobody recorded
1015
+ }
998
1016
  viewer.invalidateFrames?.();
999
1017
  }
1000
1018
  function computeFrames() {
1001
1019
  const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
1002
- for (const [n, at] of undrawnFrames) {
1020
+ for (const [n, { at, frame }] of undrawnFrames) {
1003
1021
  if (at && !resolvedFor.has(at)) {
1004
1022
  try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
1005
1023
  }
@@ -1009,9 +1027,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1009
1027
  // normal is enough to char a whole face. A print frame keeps its last value (the
1010
1028
  // worst it can do is run layer lines the wrong way).
1011
1029
  if (!resolved) { delete sheetFrames[n]; continue; }
1012
- if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved });
1030
+ if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved, frame });
1013
1031
  if (burning.has(n)) {
1014
- const f = sheetFrameFor(part.parts[n], resolved);
1032
+ const f = sheetFrameFor(part.parts[n], { ...resolved, frame });
1015
1033
  if (f) sheetFrames[n] = f; else delete sheetFrames[n];
1016
1034
  }
1017
1035
  }
@@ -1068,7 +1086,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1068
1086
  if (fresh) { // stale results (params changed mid-build) are discarded
1069
1087
  for (const m of data.meshes) {
1070
1088
  viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
1071
- cache.record(m.name, m.reads, dispatched.get(m.name)?.view ?? view()); // the view its job was SENT for
1089
+ // The view its job was SENT for, and the frame the worker delivered
1090
+ // in: "canonical" (place() not applied — mount poses it below) or
1091
+ // "posed". An older worker sends no frame; its meshes are posed.
1092
+ cache.record(m.name, m.reads, dispatched.get(m.name)?.view ?? view(), m.frame ?? "posed");
1072
1093
  // A sub-part that built into NOTHING. Manifold booleans return an
1073
1094
  // empty solid rather than throwing (a bore wider than its body),
1074
1095
  // and typed values may sit outside the authored range, so a
@@ -1081,7 +1102,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1081
1102
  // delivered, which buildDone() true guarantees is at the live params.
1082
1103
  fastPath.recordDelivered(m.name);
1083
1104
  }
1084
- recordFrames(data.meshes.map((m) => m.name));
1105
+ // setSubGeometry reset every delivered mesh's matrix, so each one gets
1106
+ // its display pose now, after its geometry landed: a canonical mesh
1107
+ // place() at the live params, a posed one a cleared matrix. `fresh`:
1108
+ // an untrusted live place() here clears the pose but keeps the stamp
1109
+ // (host and worker probes agree by construction at these params;
1110
+ // forgetting on a disagreement would rebuild at the same params forever).
1111
+ fastPath.apply(data.meshes.map((m) => m.name), undefined, { fresh: true });
1112
+ recordFrames(data.meshes);
1085
1113
  // A split dispatch answers in two meshes replies; the busy spinner
1086
1114
  // stays up until the view has everything (the other worker's job may
1087
1115
  // still be running — often OCCT, the slow one).
@@ -1098,7 +1126,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1098
1126
  const tris = viewSubParts(part, view(), params).reduce((s, n) => s + viewer.subTriangles(n), 0);
1099
1127
  console.debug(`partforge: built ${tris.toLocaleString()} triangles in ${(data.ms / 1000).toFixed(1)} s`);
1100
1128
  }
1101
- dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
1129
+ dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed, frame: frameSummary() });
1102
1130
  onBuild?.({ status: "success", ms: data.ms });
1103
1131
  // ready/autoplay wait for the WHOLE view: a split dispatch delivers in
1104
1132
  // two replies, and a host acting on `ready` (screenshotting, measuring)
@@ -1129,7 +1157,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1129
1157
  fastPath.forget(m.name);
1130
1158
  cache.forget(m.name); // the stamp described the last FRESH delivery; this mesh was built at other params, so no stamp may describe it (spec §2)
1131
1159
  }
1132
- recordFrames(data.meshes.map((m) => m.name));
1160
+ // With the stamps gone the frame comes from the reply: a canonical
1161
+ // mesh is posed by place() at the LIVE params (a rigid place() needs
1162
+ // no geometry at those params), or cleared when the probe is untrusted.
1163
+ fastPath.apply(data.meshes.map((m) => m.name),
1164
+ Object.fromEntries(data.meshes.map((m) => [m.name, m.frame ?? "posed"])));
1165
+ recordFrames(data.meshes);
1133
1166
  ui.hideBusy();
1134
1167
  refreshView();
1135
1168
  }
@@ -1,31 +1,104 @@
1
- // Decision layer of the pose fast path (no DOM, no three.js — viewer/cache are
2
- // injected). At mesh delivery each subpart is STAMPED with its probe result at
3
- // the delivered params; on a later edit, a stale subpart whose baseHash is
4
- // unchanged gets its delivered mesh re-posed in the viewer (delta vs. the
5
- // delivered pose) and is re-stamped current — no worker job. Everything else
6
- // falls through to the normal regen loop.
1
+ // The pose ladder: the decision layer that poses delivered meshes in the viewer
2
+ // without a worker job (no DOM, no three.js — viewer/cache are injected). Every
3
+ // matrix it applies is ABSOLUTE relative to the delivered mesh, so nothing
4
+ // accumulates across frames and setSubGeometry's matrix reset stays correct.
5
+ //
6
+ // A mesh's delivery frame (the mesh cache's stamp, spec §3–4) picks the rung:
7
+ // The applied matrix is place(now) · delta (canonical) or delta (posed), where
8
+ // delta is the stamp's stored build/full delta: identity at every delivery (and
9
+ // after forget), set only by a rung-2 or posed repair. Storing it is what keeps a
10
+ // later rung-1 pose or apply() (an unrelated param, a view switch) from snapping
11
+ // a delta-repaired mesh back to its delivered pose while the cache says current.
12
+ //
13
+ // - canonical + current — rung 1: posed by place() at the live params
14
+ // (place-scope probe) over the stored delta. An untrusted live place() clears
15
+ // the pose and forgets the cache stamp so the regen loop rebuilds it (posed, at
16
+ // those params) — except on mount's fresh-delivery apply ({ fresh: true }),
17
+ // where the mesh was just built at these params: there the pose is cleared and
18
+ // the stamp kept, so a host/worker probe disagreement cannot loop
19
+ // forget→rebuild. A matrix equal to the one last applied is never re-sent to
20
+ // the viewer (setSubPose restarts the contact shadow), and repair() reports a
21
+ // rung-1 name only when its matrix actually CHANGED, so the debug `posed`
22
+ // count ("produced a pose and no job") stays honest during playback: a
23
+ // sub-part whose place() ignores the animated param is not counted.
24
+ // - canonical + stale — rung 2: a trailing transform INSIDE build. When the live
25
+ // build-scope probe is trusted with the delivered baseHash, the delta becomes
26
+ // delta(build now, build delivered), the matrix place(now) · delta, and the
27
+ // mesh is re-stamped with the build's reads alone (place() reads are rung 1's).
28
+ // An untrusted live place() refuses the rung (Ruling F): the regen loop rebuilds.
29
+ // - posed (the worker baked place() in): today's full-scope delta; apply()
30
+ // re-applies the stored delta.
31
+ // Anything else falls through to the normal regen loop.
7
32
  import { viewSubParts } from "./part-model.js";
8
33
  import { probePoses } from "./pose-probe.js";
9
- import { poseDelta } from "./geometry/pose.js";
34
+ import { composePose, poseDelta, mulMat4 } from "./geometry/pose.js";
10
35
 
11
36
  export function createPoseFastPath(part, viewer, cache, { params, getView, getParamsVersion }) {
12
- const stamps = {}; // name -> probe entry captured when that subpart's mesh was delivered
37
+ // name -> { frame, entry, delta }: the delivery frame; at the delivered params,
38
+ // the build-scope probe entry (canonical — rung 2's baseline) or the
39
+ // full-scope one (posed); and the delta the last repair applied (null =
40
+ // identity, as delivered).
41
+ const stamps = {};
13
42
 
14
- // Memoize the probe per (paramsVersion, view), so it runs once per change
15
- // rather than per sub-part; params is the live in-place-mutated object.
16
- let probeKey = null, probeMap = null;
17
- const probeFor = () => {
43
+ // Three probe maps memoized per (paramsVersion, view), each computed lazily on
44
+ // first use — a part with no canonical sub-parts never runs the place or build
45
+ // probe, and vice versa. params is the live in-place-mutated object.
46
+ let probeKey = null, probeMaps = {};
47
+ const probe = (scope) => {
18
48
  const key = `${getParamsVersion()}|${getView()}`;
19
- if (probeKey !== key) { probeKey = key; probeMap = probePoses(part, getView(), params); }
20
- return probeMap;
49
+ if (probeKey !== key) { probeKey = key; probeMaps = {}; }
50
+ return (probeMaps[scope] ??= probePoses(part, getView(), params, { scope }));
21
51
  };
22
52
 
53
+ // The matrix this module last applied per name (null = cleared, absent =
54
+ // never applied: unknown). An unchanged matrix never reaches the viewer:
55
+ // setSubPose bumps the match generation and restarts the realistic contact
56
+ // shadow, and rung 1 re-poses every current canonical sub-part on every param
57
+ // change. recordDelivered/forget set null because setSubGeometry reset the
58
+ // matrix, so a delivery's first non-identity pose always lands. Returns true
59
+ // only when a non-null matrix actually changed (repair's rung-1 count).
60
+ const shown = {};
61
+ const same = (a, b) => a === b || (a != null && b != null && a.every((v, i) => v === b[i]));
62
+ const setPose = (name, m) => {
63
+ if (name in shown && same(shown[name], m)) return false;
64
+ shown[name] = m;
65
+ viewer.setSubPose(name, m);
66
+ return m != null;
67
+ };
68
+
69
+ // Pose each in-view, meshed name from its frame (an override wins over the
70
+ // cache stamp's — mount's stale-shown branch forgets the stamp first).
71
+ // `fresh` (mount's fresh-delivery call only): an untrusted live place() clears
72
+ // the pose but keeps the cache stamp — the mesh was just built at these very
73
+ // params, so forgetting would rebuild at identical params and disagree again.
74
+ // Returns the names given a CHANGED place pose (used by repair's count).
75
+ function poseByFrame(names, frames, { fresh = false } = {}) {
76
+ const moved = [];
77
+ const inView = new Set(viewSubParts(part, getView(), params));
78
+ for (const name of names) {
79
+ if (!inView.has(name) || !viewer.hasSubMesh(name)) continue;
80
+ const frame = frames?.[name] ?? cache.frameOf(name);
81
+ const delta = stamps[name]?.delta ?? null; // no stamp (stale-shown): as delivered
82
+ if (frame === "posed") { setPose(name, delta); continue; }
83
+ if (frame !== "canonical") continue; // unknown frame: nothing to say about the mesh
84
+ const now = probe("place").get(name);
85
+ if (!now?.trusted) { if (!fresh) cache.forget(name); setPose(name, null); continue; }
86
+ const place = composePose(now.pose);
87
+ if (setPose(name, delta ? mulMat4(place, delta) : place)) moved.push(name);
88
+ }
89
+ return moved;
90
+ }
91
+ const apply = (names, frames, opts) => { poseByFrame(names, frames, opts); };
92
+
23
93
  return {
24
- // Stamp a freshly delivered mesh with its probe result at the current params.
25
- // (The caller only records on non-stale builds — buildDone() guarantees the
26
- // live params are the ones the worker built with.)
94
+ // Stamp a freshly delivered mesh with its probe baseline at the current
95
+ // params, by the frame the cache recorded for it. (The caller only records on
96
+ // non-stale builds — buildDone() guarantees the live params are the ones the
97
+ // worker built with.)
27
98
  recordDelivered(name) {
28
- stamps[name] = probeFor().get(name);
99
+ const frame = cache.frameOf(name) ?? "posed";
100
+ stamps[name] = { frame, entry: probe(frame === "canonical" ? "build" : "full").get(name), delta: null };
101
+ shown[name] = null; // setSubGeometry just reset the mesh's matrix
29
102
  },
30
103
 
31
104
  // Drop a subpart's stamp: the mesh in the viewer is no longer known to
@@ -37,28 +110,55 @@ export function createPoseFastPath(part, viewer, cache, { params, getView, getPa
37
110
  // apply a delta measured against geometry that is no longer on screen.
38
111
  forget(name) {
39
112
  delete stamps[name];
113
+ shown[name] = null; // the shown-unrecorded mesh arrived with its matrix reset
40
114
  },
41
115
 
42
- // Re-pose every visible stale subpart whose base geometry is unchanged.
43
- // Returns the NAMES repaired (empty = nothing pose-only to do). Names, not a
44
- // count: a slider drag repairs the same subpart on every input event, so only
45
- // the caller's set union across a drag is a meaningful "how many were posed".
116
+ // Pose names right after a delivery or a view switch. See the header.
117
+ apply,
118
+
119
+ // One call per param change. Re-poses every visible current canonical
120
+ // subpart from place() (rung 1) and every visible stale subpart whose base
121
+ // geometry is unchanged (rung 2, posed delta). Returns the NAMES that change
122
+ // posed without a job — rung 2 and posed repairs always, rung 1 only when
123
+ // its matrix moved (empty = nothing pose-only to do). Names, not a count: a
124
+ // slider drag poses the same subpart on every input event, so only the
125
+ // caller's set union across a drag is meaningful.
46
126
  repair() {
47
- const posed = [];
48
- const poses = probeFor();
127
+ const posed = [], placeOnly = [];
49
128
  for (const name of viewSubParts(part, getView(), params)) {
50
- if (cache.isCurrent(name) || !viewer.hasSubMesh(name)) continue;
51
- const now = poses.get(name), was = stamps[name];
52
- if (!now?.trusted || !was?.trusted || now.baseHash !== was.baseHash) continue;
53
- viewer.setSubPose(name, poseDelta(now.pose, was.pose));
54
- // Same geometry, new pose: the delivered build's reads plus whatever
55
- // place() read at THIS pose, re-hashed at the live params (spec §2).
56
- // An unknown stamp stays unknown.
129
+ if (!viewer.hasSubMesh(name)) continue;
130
+ if (cache.isCurrent(name)) {
131
+ if (cache.frameOf(name) === "canonical") placeOnly.push(name);
132
+ continue;
133
+ }
134
+ const was = stamps[name];
135
+ if (!was?.entry?.trusted) continue;
136
+ let matrix, delta, now;
137
+ if (was.frame === "canonical") {
138
+ now = probe("build").get(name);
139
+ if (!now?.trusted || now.baseHash !== was.entry.baseHash) continue;
140
+ const place = probe("place").get(name);
141
+ if (!place?.trusted) continue;
142
+ delta = poseDelta(now.pose, was.entry.pose);
143
+ matrix = mulMat4(composePose(place.pose), delta);
144
+ } else {
145
+ now = probe("full").get(name);
146
+ if (!now?.trusted || now.baseHash !== was.entry.baseHash) continue;
147
+ matrix = delta = poseDelta(now.pose, was.entry.pose);
148
+ }
149
+ was.delta = delta;
150
+ setPose(name, matrix);
151
+ // Same geometry, new pose: the delivered build's reads plus whatever the
152
+ // probe read at THIS pose, re-hashed at the live params (spec §2), keeping
153
+ // the delivery frame. A canonical stamp takes the build-scope probe's
154
+ // reads only — place() reads stay out, since rung 1 owns them (spec §3:
155
+ // recorded keys are the build's); a posed one the full scope's. An
156
+ // unknown stamp stays unknown.
57
157
  const had = cache.readsOf(name);
58
- cache.record(name, had ? [...had, ...(now.reads ?? [])] : undefined); // current again — regen loop sees nothing missing
158
+ cache.record(name, had ? [...had, ...(now.reads ?? [])] : undefined, getView(), was.frame); // current again — regen loop sees nothing missing
59
159
  posed.push(name);
60
160
  }
61
- return posed;
161
+ return [...poseByFrame(placeOnly), ...posed];
62
162
  },
63
163
  };
64
164
  }
@@ -5,7 +5,9 @@
5
5
  // are never compared to backend hashes, so they only need to be stable and to
6
6
  // fold every geometry-affecting argument.
7
7
  //
8
- // Trust model: any query op (boundingBox/volume/…) during a build marks that
8
+ // Trust model (applies PER SCOPE — "full" = build+place, "build" = build alone,
9
+ // "place" = place() alone on a canonical token whose rigidity test is its hash
10
+ // staying CANONICAL): any query op (boundingBox/volume/…) during a build marks that
9
11
  // subpart untrusted — a query result could feed geometry OR pose, and the probe
10
12
  // returns dummies, so neither hash stability nor pose values can be believed.
11
13
  // A FUNCTION passed as (or nested inside) an op argument is untrusted for the
@@ -19,6 +21,9 @@ import { addSugar } from "./geometry/solid-sugar.js";
19
21
  import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
20
22
  import { MAX_PROBE_OPS, ProbeRunawayError } from "./geometry/probe.js";
21
23
 
24
+ // The fixed hash a place-scope probe starts from; a rigid place() leaves it unchanged.
25
+ export const CANONICAL = h("canonical");
26
+
22
27
  const NAN3 = () => [NaN, NaN, NaN];
23
28
 
24
29
  function makeProbeSession() {
@@ -102,7 +107,7 @@ function makeProbeSession() {
102
107
  },
103
108
  });
104
109
 
105
- return { kernel, state };
110
+ return { kernel, state, token };
106
111
  }
107
112
 
108
113
  const finiteVec = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
@@ -111,16 +116,26 @@ const stepsFinite = (steps) => steps.every((st) =>
111
116
  ? finiteVec(st.v)
112
117
  : Number.isFinite(st.deg) && finiteVec(st.center) && finiteVec(st.axis));
113
118
 
114
- // Probe ONE sub-part's build+place at explicit params/purpose. The shared
115
- // primitive under probePoses (pose-probe.js) and the lint place/animation
116
- // rules — kept free of jobs.js imports so the lint import closure stays pure
117
- // (test/lint-purity.test.js). Never throws; a failing/queried/weird sub-part
118
- // yields { trusted: false }.
119
- export function probeSubPartPose(sp, { view, purpose = "display", p, d }) {
119
+ // Probe ONE sub-part at explicit params/purpose. The shared primitive under
120
+ // probePoses (pose-probe.js) and the lint place/animation rules — kept free of
121
+ // jobs.js imports so the lint import closure stays pure (test/lint-purity.test.js).
122
+ // Never throws; a failing/queried/weird sub-part yields { trusted: false }.
123
+ // scope: "full" (build then place), "build" (build only), "place" (place() on a
124
+ // canonical token; no build runs, so a querying build is irrelevant). In place
125
+ // scope a readable but reshaping place() (scale/mirror/union) returns
126
+ // { trusted: false, baseHash, pose } with baseHash !== CANONICAL — see Ruling A.
127
+ export function probeSubPartPose(sp, { view, purpose = "display", p, d }, { scope = "full" } = {}) {
120
128
  try {
121
- const { kernel, state } = makeProbeSession(); // fresh op budget + trust per subpart
129
+ const { kernel, state, token } = makeProbeSession(); // fresh op budget + trust per subpart
130
+ if (scope === "place") {
131
+ let s = token(CANONICAL, []);
132
+ if (!sp.place) return { trusted: true, baseHash: CANONICAL, pose: [] };
133
+ s = sp.place(s, { view, purpose, p, d });
134
+ if (!(s && s.__poseToken) || state.queried || state.unhashable || !stepsFinite(s._pose)) return { trusted: false };
135
+ return { trusted: s._hash === CANONICAL, baseHash: s._hash, pose: s._pose };
136
+ }
122
137
  let s = sp.build(kernel, p, d);
123
- if (sp.place) s = sp.place(s, { view, purpose, p, d });
138
+ if (scope === "full" && sp.place) s = sp.place(s, { view, purpose, p, d });
124
139
  const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
125
140
  return ok ? { trusted: true, baseHash: s._hash, pose: s._pose } : { trusted: false };
126
141
  } catch {
@@ -27,7 +27,7 @@ function assetSources(part, p) {
27
27
 
28
28
  // Probe every subpart the view shows. Never throws; a failing/queried/weird
29
29
  // subpart yields { trusted: false } and the others still probe.
30
- export function probePoses(part, view, params) {
30
+ export function probePoses(part, view, params, { scope = "full", purpose = "display" } = {}) {
31
31
  const out = new Map();
32
32
  let resolved, assets;
33
33
  try {
@@ -41,9 +41,11 @@ export function probePoses(part, view, params) {
41
41
  const sink = newReadSink();
42
42
  sink.attribution = resolved.attribution;
43
43
  const rec = recordedParams(resolved, sink);
44
- const entry = probeSubPartPose(part.parts[name], { view, purpose: "display", p: rec.p, d: rec.d });
44
+ const entry = probeSubPartPose(part.parts[name], { view, purpose, p: rec.p, d: rec.d }, { scope });
45
+ // Assets fold in only outside place scope: a place probe hashes nothing
46
+ // geometry-dependent, and the mesh cache's asset reads cover the font case.
45
47
  out.set(name, entry.trusted
46
- ? { ...entry, baseHash: h("with-assets", entry.baseHash, assets), reads: expandReads(sink) }
48
+ ? { ...entry, baseHash: scope === "place" ? entry.baseHash : h("with-assets", entry.baseHash, assets), reads: expandReads(sink) }
47
49
  : entry);
48
50
  }
49
51
  return out;