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.
- package/docs/AUTHORING-PARTS.md +21 -13
- package/docs/ERROR-PATTERNS.md +4 -4
- package/package.json +1 -1
- package/src/framework/debug-overlay.js +4 -3
- package/src/framework/geometry/pose.js +3 -2
- package/src/framework/jobs.js +16 -7
- package/src/framework/lint/rules-animations.js +47 -18
- package/src/framework/lint/rules-place.js +14 -8
- package/src/framework/materials/print-frame.js +10 -2
- package/src/framework/materials/sheet-look.js +4 -2
- package/src/framework/mesh-cache.js +6 -3
- package/src/framework/mount.js +47 -14
- package/src/framework/pose-fast-path.js +133 -33
- package/src/framework/pose-probe-core.js +25 -10
- package/src/framework/pose-probe.js +5 -3
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
3710
|
-
best-effort rather than at frame
|
|
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
|
|
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).
|
|
3719
|
-
(
|
|
3720
|
-
|
|
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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
|
|
489
|
-
- **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track. Restructure so the param
|
|
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
|
@@ -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
|
-
|
|
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++)
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
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
|
|
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 } =
|
|
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 {
|
|
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
|
|
471
|
-
|
|
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
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
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
|
|
528
|
+
const ends = [];
|
|
517
529
|
for (const v of [v0, v1]) {
|
|
518
530
|
const pv = { ...p, [key]: v };
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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) =>
|
|
38
|
-
if (probes.
|
|
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 =
|
|
61
|
-
const exportP =
|
|
62
|
-
if (!display
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/framework/mount.js
CHANGED
|
@@ -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.
|
|
246
|
-
//
|
|
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();
|
|
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
|
-
|
|
995
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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;
|
|
20
|
-
return
|
|
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
|
|
25
|
-
//
|
|
26
|
-
// live params are the ones the
|
|
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
|
-
|
|
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
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
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 (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
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
|
|
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;
|