partforge 0.141.0 → 0.143.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.
@@ -1,10 +1,16 @@
1
- // Group 6 — the two place() invariants (docs/AUTHORING-PARTS.md "Display vs
2
- // export placement"), promoted from doc-only conventions to lint because the
3
- // animation system leans on place() for every pose-only track:
4
- // 1. Display placement must not depend on the active view (display meshes
5
- // are cached across views; a view-dependent pose serves stale geometry).
6
- // 2. Display vs export may differ only by a rigid motion (translate/rotate).
7
- // Both checks run the place-scope pose probe: place() alone, on a canonical token,
1
+ // Lint ids here: views-invalid (a sub-part whose views is neither form), place-not-rigid (legacy views-array + place() form), and for the views-map
2
+ // form view-entry-invalid (an entry that is not true or a pose), views-and-place (an author
3
+ // place beside a map) and view-pose-not-rigid (an entry that reshapes instead of moving).
4
+ // Group 6 — the place() invariant (docs/AUTHORING-PARTS.md "Display vs export
5
+ // placement"), promoted from a doc-only convention to lint because the animation
6
+ // system leans on place() for every pose-only track: display vs export may differ
7
+ // only by a rigid motion (translate/rotate). A second rule, view-dependent-display-
8
+ // place, forbade a display pose that varies by view; it was retired in 0.142 — since
9
+ // 0.141 the viewer meshes the canonical build once (the mesh cache stamps the view)
10
+ // and re-poses it on every tab switch, so the stale-geometry hazard it named is gone,
11
+ // and as a lint ERROR it refused to load the one-piece-per-sub-part shape a per-view
12
+ // layout needs (partforge-cloud feedback #161/#162).
13
+ // The check runs the place-scope pose probe: place() alone, on a canonical token,
8
14
  // so build() may query freely without hiding a finding. A READABLE result carries
9
15
  // `baseHash` (CANONICAL for translate/rotate only, another hash when place()
10
16
  // reshapes) and `pose`; a place() the probe cannot read (it queries the solid or
@@ -12,7 +18,9 @@
12
18
  // and stays silent. Readability, not `trusted`, gates the rules: the same reshape
13
19
  // on both purposes is allowed, so a reshaping place() must still be compared.
14
20
  import { err } from "./finding.js";
15
- import { probeSubPartPose } from "../pose-probe-core.js";
21
+ import { probeSubPartPose, CANONICAL } from "../pose-probe-core.js";
22
+ import { isViewsMap, inView, formOf } from "../sub-part-views.js";
23
+ import { isSheetPart } from "../sheet/constants.js";
16
24
 
17
25
  const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
18
26
 
@@ -21,46 +29,21 @@ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArr
21
29
  // imported from jobs.js, which would break the lint purity closure.
22
30
  function viewNames(part, view, p) {
23
31
  return Object.entries(isPlainObject(part?.parts) ? part.parts : {})
24
- .filter(([, sp]) => Array.isArray(sp?.views) && sp.views.includes(view))
32
+ .filter(([, sp]) => inView(sp, view))
25
33
  .filter(([, sp]) => { try { return sp.enabled ? !!sp.enabled(p) : true; } catch { return false; } })
26
34
  .map(([name]) => name);
27
35
  }
28
36
 
29
- const poseKey = (pose) => JSON.stringify(pose);
30
37
  const readable = (probe) => probe.baseHash !== undefined;
31
38
  const probePlace = (sp, ctx) => probeSubPartPose(sp, ctx, { scope: "place" });
32
39
 
33
40
  export const PLACE_RULES = [
34
- {
35
- id: "view-dependent-display-place",
36
- run: ({ part, p, d }) => {
37
- const out = [];
38
- const views = Object.keys(isPlainObject(part?.views) ? part.views : {});
39
- if (views.length < 2) return out;
40
- for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
41
- const inViews = views.filter((v) => viewNames(part, v, p).includes(name));
42
- if (inViews.length < 2) continue;
43
- const probes = inViews.map((view) => probePlace(sp, { view, purpose: "display", p, d }));
44
- if (!probes.every(readable)) continue;
45
- const first = probes[0];
46
- const differs = probes.some((x) => x.baseHash !== first.baseHash || poseKey(x.pose) !== poseKey(first.pose));
47
- if (differs) {
48
- out.push(err("view-dependent-display-place",
49
- `sub-part "${name}" display placement differs between views (${inViews.join(", ")})`,
50
- "Display meshes are built once per sub-part and cached across views, so a view-dependent display pose shows stale geometry after a tab switch. Only `place(..., { purpose: \"export\" })` may vary; keep the display branch view-independent.",
51
- `parts.${name}.place`,
52
- "view-dependent-display-place"));
53
- }
54
- }
55
- return out;
56
- },
57
- },
58
41
  {
59
42
  id: "place-not-rigid",
60
43
  run: ({ part, p, d }) => {
61
44
  const out = [];
62
45
  for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
63
- if (!sp?.place) continue;
46
+ if (!sp?.place || formOf(sp) === "map") continue;
64
47
  for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
65
48
  if (!viewNames(part, view, p).includes(name)) continue;
66
49
  const display = probePlace(sp, { view, purpose: "display", p, d });
@@ -79,4 +62,77 @@ export const PLACE_RULES = [
79
62
  return out;
80
63
  },
81
64
  },
65
+ {
66
+ id: "views-invalid",
67
+ run: ({ part }) => {
68
+ const out = [];
69
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
70
+ const v = sp?.views;
71
+ if (Array.isArray(v) || isPlainObject(v)) continue;
72
+ out.push(err("views-invalid",
73
+ `sub-part "${name}" has no usable \`views\` (it is ${v === undefined ? "missing" : JSON.stringify(v) ?? String(v)})`,
74
+ "Give the sub-part a `views` map — e.g. `views: { assembly: true }` — naming each view it appears in.",
75
+ `parts.${name}.views`,
76
+ "views-invalid"));
77
+ }
78
+ return out;
79
+ },
80
+ },
81
+ {
82
+ id: "view-entry-invalid",
83
+ run: ({ part }) => {
84
+ const out = [];
85
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
86
+ if (!isViewsMap(sp?.views)) continue;
87
+ for (const [view, entry] of Object.entries(sp.views)) {
88
+ if (entry === true || typeof entry === "function") continue;
89
+ out.push(err("view-entry-invalid",
90
+ `sub-part "${name}" has ${JSON.stringify(entry) ?? String(entry)} for view "${view}"`,
91
+ `Each entry in a sub-part's \`views\` map is \`true\` (shown as built) or a pose \`(s, p, d) => s.translate(…)\`. To leave the piece out of "${view}", delete the key.`,
92
+ `parts.${name}.views.${view}`,
93
+ "view-entry-invalid"));
94
+ }
95
+ }
96
+ return out;
97
+ },
98
+ },
99
+ {
100
+ id: "views-and-place",
101
+ run: ({ part }) => {
102
+ const out = [];
103
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
104
+ if (!isViewsMap(sp?.views) || typeof sp.place !== "function") continue;
105
+ const generated = isSheetPart(sp) && sp.place === sp.sheet.generatedPlace && sp.sheet.place == null;
106
+ if (generated) continue;
107
+ out.push(err("views-and-place",
108
+ `sub-part "${name}" has both a \`views\` map and \`place\``,
109
+ "Put each view's pose in its `views` entry and delete `place`. `build()` makes the piece as it prints — that is what exports.",
110
+ `parts.${name}.place`,
111
+ "views-and-place"));
112
+ }
113
+ return out;
114
+ },
115
+ },
116
+ {
117
+ id: "view-pose-not-rigid",
118
+ run: ({ part, p, d }) => {
119
+ const out = [];
120
+ for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
121
+ if (!isViewsMap(sp?.views)) continue;
122
+ for (const [view, entry] of Object.entries(sp.views)) {
123
+ if (typeof entry !== "function") continue;
124
+ // Display only: map poses never apply on export, so there is no second purpose to probe.
125
+ const probe = probePlace(sp, { view, purpose: "display", p, d });
126
+ if (!readable(probe) || probe.baseHash === CANONICAL) continue;
127
+ out.push(err("view-pose-not-rigid",
128
+ `sub-part "${name}"'s pose for view "${view}" reshapes the piece`,
129
+ "A view's pose may only translate or rotate its argument — no `scale`, `mirror` or boolean. Bake a resized or reflected form into `build()` so every view shows the same physical piece.",
130
+ `parts.${name}.views.${view}`,
131
+ "view-pose-not-rigid"));
132
+ break; // one finding per sub-part
133
+ }
134
+ }
135
+ return out;
136
+ },
137
+ },
82
138
  ];
@@ -3,6 +3,7 @@
3
3
  // defaults and build but not `views`, and had already drifted from the eval runner's
4
4
  // separate views check. One source of truth ends that split.
5
5
  import { err, warn } from "./finding.js";
6
+ import { isViewsMap, viewsOf } from "../sub-part-views.js";
6
7
  import { SHEET_DOC_ID } from "../sheet/constants.js";
7
8
 
8
9
  // A sub-part that carries a `sheet` declaration but no build was meant to be a sheet
@@ -70,7 +71,7 @@ export const SHAPE_RULES = [
70
71
  id: "missing-views",
71
72
  run: ({ part }) => (isPlainObject(part?.views) && Object.keys(part.views).length > 0 ? [] : [
72
73
  err("missing-views", "the part has no `views` map",
73
- "Add a top-level `views` object — e.g. `views: { main: { label: \"Main\" } }` — and list each view name in the owning sub-part's `views` array.",
74
+ "Add a top-level `views` object — e.g. `views: { main: { label: \"Main\" } }` — and give each sub-part a `views` map naming the views it appears in.",
74
75
  "views"),
75
76
  ]),
76
77
  },
@@ -81,15 +82,17 @@ export const SHAPE_RULES = [
81
82
  if (known.size === 0) return []; // missing-views already reported it; don't pile on
82
83
  const out = [];
83
84
  for (const [name, sp] of partEntries(part)) {
84
- if (!Array.isArray(sp?.views)) continue;
85
- sp.views.forEach((v, i) => {
85
+ const listed = Array.isArray(sp?.views) ? sp.views.map((v, i) => [v, `parts.${name}.views[${i}]`])
86
+ : isViewsMap(sp?.views) ? Object.keys(sp.views).map((v) => [v, `parts.${name}.views.${v}`])
87
+ : [];
88
+ for (const [v, path] of listed) {
86
89
  if (!known.has(v)) {
87
90
  out.push(err("part-view-unknown",
88
91
  `sub-part "${name}" lists view "${v}", which is not in the \`views\` map`,
89
92
  `Add "${v}" to the top-level \`views\` map, or correct the name to one of: ${[...known].join(", ")}.`,
90
- `parts.${name}.views[${i}]`));
93
+ path));
91
94
  }
92
- });
95
+ }
93
96
  }
94
97
  return out;
95
98
  },
@@ -100,12 +103,12 @@ export const SHAPE_RULES = [
100
103
  if (!isPlainObject(part?.views)) return [];
101
104
  const used = new Set();
102
105
  for (const [, sp] of partEntries(part)) {
103
- if (Array.isArray(sp?.views)) for (const v of sp.views) used.add(v);
106
+ for (const v of viewsOf(sp)) used.add(v);
104
107
  }
105
108
  return Object.keys(part.views)
106
109
  .filter((v) => !used.has(v))
107
110
  .map((v) => warn("view-unused", `view "${v}" is not listed by any sub-part`,
108
- `Either add "${v}" to a sub-part's \`views\` array or remove it from the \`views\` map — as it stands the view renders empty.`,
111
+ `Either add "${v}" to a sub-part's \`views\` or remove it from the \`views\` map — as it stands the view renders empty.`,
109
112
  `views.${v}`));
110
113
  },
111
114
  },
@@ -4,6 +4,7 @@
4
4
  // (lint place-not-rigid), and the geometry-free pose probe reports each as a
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
+ import { placeOf } from "../sub-part-views.js";
7
8
  import { probeSubPartPose } from "../pose-probe-core.js";
8
9
  import { poseDelta, composePose } from "../geometry/pose.js";
9
10
 
@@ -14,7 +15,7 @@ const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
14
15
  // build that queries its solid cannot make it untrusted); a "posed" mesh already carries the
15
16
  // display pose, so the map is E · D⁻¹.
16
17
  export function printFrameMatrix(subPart, { view, p, d, frame = "posed" }) {
17
- if (!subPart?.place) return [...IDENTITY];
18
+ if (!placeOf(subPart)) return [...IDENTITY];
18
19
  if (frame === "canonical") {
19
20
  const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d }, { scope: "place" });
20
21
  return exp.trusted ? composePose(exp.pose) : [...IDENTITY];
@@ -24,3 +25,21 @@ export function printFrameMatrix(subPart, { view, p, d, frame = "posed" }) {
24
25
  if (!disp.trusted || !exp.trusted || disp.baseHash !== exp.baseHash) return [...IDENTITY];
25
26
  return poseDelta(exp.pose, disp.pose);
26
27
  }
28
+
29
+ const isIdentity = (m) => m.every((v, i) => Math.abs(v - IDENTITY[i]) < 1e-12);
30
+
31
+ // The map from a DISPLAY-posed solid to the same piece in its print (export) pose, for
32
+ // the oracle's overhang reading — or null when the two poses are the same (no place(),
33
+ // or a place() that moves nothing between purposes) or cannot be read. Place-scope
34
+ // probes first: both poses are place(build), so E · D⁻¹ needs only place() to be
35
+ // readable, and a build that queries its solid cannot make it untrusted. A place()
36
+ // that only the full-scope probe reads falls back to printFrameMatrix's posed branch.
37
+ export function displayToPrintMatrix(subPart, { view, p, d }) {
38
+ if (!placeOf(subPart)) return null;
39
+ const disp = probeSubPartPose(subPart, { view, purpose: "display", p, d }, { scope: "place" });
40
+ const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d }, { scope: "place" });
41
+ const m = disp.trusted && exp.trusted
42
+ ? poseDelta(exp.pose, disp.pose)
43
+ : printFrameMatrix(subPart, { view, p, d, frame: "posed" });
44
+ return isIdentity(m) ? null : m;
45
+ }
@@ -17,6 +17,7 @@
17
17
  // (resolve.js's default for every other sub-part) but the sheet its stock label names —
18
18
  // realisticDisplay, from the ONE keyword table STOCK_LOOKS. Realistic-only, like the PLA
19
19
  // default: the CAD view, 3MF colours and declaresMaterials read sp.display itself.
20
+ import { isViewsMap } from "../sub-part-views.js";
20
21
  import { isSheetPart, sheetMeta, MARK_DEPTH } from "../sheet/constants.js";
21
22
  import { poseSteps, validatePose } from "../sheet/pose.js";
22
23
  import { composePose, invertRigid } from "../geometry/pose.js";
@@ -145,6 +146,7 @@ export function burnsFor(sp) {
145
146
  if (sp.build !== sp.sheet.generatedBuild) return false; // a custom build is not the canonical sheet
146
147
  if (sp.sheet.place != null) return false; // an author place moves it off its pose
147
148
  if ((sp.place ?? null) !== (sp.sheet.generatedPlace ?? null)) return false; // place replaced after sheetPart
149
+ if (isViewsMap(sp.views) && Object.values(sp.views).some((v) => typeof v === "function")) return false; // a pose entry moves it off its pose
148
150
  try { return lookOf(sp).params.pattern === "wood"; } catch { return false; }
149
151
  }
150
152
 
@@ -1027,6 +1027,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1027
1027
  // normal is enough to char a whole face. A print frame keeps its last value (the
1028
1028
  // worst it can do is run layer lines the wrong way).
1029
1029
  if (!resolved) { delete sheetFrames[n]; continue; }
1030
+ // For a views-map sub-part the export pose is the build itself (plus a sheet pose), so a canonical
1031
+ // delivery's print frame is identity — no special case, the probe reads the compiled placement.
1030
1032
  if (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved, frame });
1031
1033
  if (burning.has(n)) {
1032
1034
  const f = sheetFrameFor(part.parts[n], { ...resolved, frame });
@@ -4,7 +4,8 @@ import { assemblyOverlaps } from "../assembly.js";
4
4
  import { resolveParams, buildPosed } from "../part-model.js";
5
5
  import { newReadSink, expandReads } from "../read-recorder.js";
6
6
  import { probeSubPartPose } from "../pose-probe-core.js";
7
- import { composePose } from "../geometry/pose.js";
7
+ import { composePose, invertRigid, transformPositions } from "../geometry/pose.js";
8
+ import { displayToPrintMatrix } from "../materials/print-frame.js";
8
9
  import { isSheetPart, sheetMeta, SHEET_CHECK_BUDGET_MS, MARK_DEPTH } from "../sheet/constants.js";
9
10
  import { resolveSheet } from "../sheet/resolve.js";
10
11
  import { processFor } from "../process/registry.js";
@@ -128,6 +129,21 @@ const transformPoint = (m, [x, y, z]) => [
128
129
  m[2] * x + m[6] * y + m[10] * z + m[14],
129
130
  ];
130
131
 
132
+ // Overhang of one printed sub-part in its PRINT pose. measure builds the display pose,
133
+ // and a piece shown assembled (a lid closed over its box) is not how it prints: judging
134
+ // its underside there flagged the wrong faces and missed the real ones. The display mesh
135
+ // is carried into the export pose by a rigid matrix — no second build — and the bed is
136
+ // that pose's own lowest Z; `at` comes back in display coordinates, where every other
137
+ // finding of this view sits. An unreadable or identical pose measures as shown.
138
+ function printPoseOverhang(mesh, sp, { view, p, d, maxAngle, displayBedZ }) {
139
+ const m = p ? displayToPrintMatrix(sp, { view, p, d }) : null;
140
+ if (!m) return overhang(mesh, { maxAngle, bedZ: displayBedZ });
141
+ const positions = Float32Array.from(mesh.positions);
142
+ transformPositions(positions, m);
143
+ const out = overhang({ positions, indices: mesh.indices }, { maxAngle });
144
+ return out?.at ? { ...out, at: transformPoint(invertRigid(m), out.at) } : out;
145
+ }
146
+
131
147
  // One sheet row's facts, within `budget.left` ms of 2-D work, which it spends (see
132
148
  // measure()). A 2-D location is lifted through the sub-part's DISPLAY pose, as the geometry-free
133
149
  // probe records it, at mid-thickness; a pose the probe cannot trust (a place() that
@@ -268,6 +284,10 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
268
284
  // declaration order). A view without a sheet part measures exactly as it always has.
269
285
  const sheetView = built.some(({ name }) => isSheetPart(part.parts[name]));
270
286
  const sheetParams = sheetView ? resolveParams(part, params, undefined, reads) : null;
287
+ // The overhang reading poses each piece for print (printPoseOverhang), which reads the
288
+ // resolved params through place() — recorded into the same sink, since the reading
289
+ // depends on them.
290
+ const poseParams = overhangAngle != null ? (sheetParams ?? resolveParams(part, params, undefined, reads)) : null;
271
291
  // The print-pose sizes are read by one check, the process bed (verify passes whether
272
292
  // its profile has one; alone, measure asks the part's own profile), and each costs a
273
293
  // second build of the part — so they are built only for a bed.
@@ -286,14 +306,14 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
286
306
  ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples, band: wallBands[name] ?? null })
287
307
  : null;
288
308
  // One pass over the triangles, no index — cheap enough for every lap. The bed
289
- // is this sub-part's own lowest Z, already in hand from bounds(). A sub-part
290
- // that is never printed (`exportable: false` — a reference ghost, a probe
291
- // slab, a placeholder) is not judged. Judged in the DISPLAY pose, which is
292
- // what measure builds; a part whose export pose differs (a lid that prints
293
- // flat beside its base) is a known gap, stated in the authoring docs.
294
- // A sheet part is laser-cut, never printed: no overhang reading for it either.
309
+ // is this sub-part's own lowest Z in its PRINT pose (printPoseOverhang). A
310
+ // sub-part that is never printed (`exportable: false` — a reference ghost, a
311
+ // probe slab, a placeholder) is not judged. A sheet part is laser-cut, never
312
+ // printed: no overhang reading for it either.
295
313
  const printed = part.parts[name]?.exportable !== false && !sheet;
296
- const oh = overhangAngle != null && printed ? overhang(mesh, { maxAngle: overhangAngle, bedZ: b.min[2] }) : null;
314
+ const oh = overhangAngle != null && printed
315
+ ? printPoseOverhang(mesh, part.parts[name], { view, ...poseParams, maxAngle: overhangAngle, displayBedZ: b.min[2] })
316
+ : null;
297
317
  const vol = solid.volume();
298
318
  // Deviation-from-reference: only for a sub-part that declares `reference:
299
319
  // "<import name>"` (Task 12 — the gate that holds a parametric rebuild to
@@ -290,7 +290,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
290
290
  checks.push({ scope: "subpart", subpart: s.name, metric: "bbox", kind: "gate", expr: `<=[${profile.bed.join(",")}]`,
291
291
  actual: null, status: "skip", pass: null, unevaluated: true, note: PRINT_POSE_NOTE,
292
292
  message: `not measured: its print (export) pose did not build — ${s.printBboxError}`,
293
- hint: "This printed part's place() failed for purpose \"export\", so its print size — and its STL/3MF export — is unavailable. Fix place() for the export pose; the bed check then runs." });
293
+ hint: "build() should make each piece as it prints — a printed piece shown in an assembly gets its assembled pose from its `views` entry, never from build(). Its export pose failed to build, so its print size — and its STL/3MF export — is unavailable. Fix the pose; the bed check then runs." });
294
294
  }
295
295
  const merged = {
296
296
  ...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
@@ -3,6 +3,7 @@
3
3
  import { recorder, expandDerivedReads } from "./read-recorder.js";
4
4
  import { resolveDerivedAttributed } from "./derive.js";
5
5
  import { createProbeKernel } from "./geometry/probe.js";
6
+ import { inView, placeOf } from "./sub-part-views.js";
6
7
  import { byteAwareReplacer } from "./geometry/solid-hash.js";
7
8
  import { viewSubParts } from "./part-model.js";
8
9
 
@@ -21,7 +22,7 @@ export function relevantParamKeys(part, view, params) {
21
22
  // sub-part is currently hidden (on-screen ones are already in `reads`).
22
23
  for (const name of Object.keys(part.parts)) {
23
24
  const sp = part.parts[name];
24
- if (sp.views.includes(view) && sp.enabled) sp.enabled(recorder(params, relevant));
25
+ if (inView(sp, view) && sp.enabled) sp.enabled(recorder(params, relevant));
25
26
  }
26
27
  return relevant;
27
28
  } catch {
@@ -48,7 +49,8 @@ export function subPartReadKeys(part, view, params) {
48
49
  // place() shapes what's on screen too (display pose is baked into the cached
49
50
  // mesh), so its reads count — without this, a param consumed only by place()
50
51
  // would let the mesh cache skip a rebuild and leave the sub-part misplaced.
51
- if (sp.place) sp.place(built, { view, purpose: "display", p: recorder(params, reads), d: recorder(derived, dSeen) });
52
+ const place = placeOf(sp);
53
+ if (place) place(built, { view, purpose: "display", p: recorder(params, reads), d: recorder(derived, dSeen) });
52
54
  map.set(name, expandDerivedReads(reads, dSeen, { depsOf, allInputs }));
53
55
  }
54
56
  return map;
@@ -102,7 +104,7 @@ export function recordedRelevantKeys(part, view, params, readsOf) {
102
104
  }
103
105
  for (const name of Object.keys(part.parts)) {
104
106
  const sp = part.parts[name];
105
- if (sp.views.includes(view) && sp.enabled) sp.enabled(recorder(params, relevant));
107
+ if (inView(sp, view) && sp.enabled) sp.enabled(recorder(params, relevant));
106
108
  }
107
109
  return relevant;
108
110
  } catch {
@@ -11,15 +11,15 @@
11
11
  // oracle depend on the part model without an import cycle back through the job loop.
12
12
  import { resolveDerived, resolveDerivedAttributed } from "./derive.js";
13
13
  import { recorder } from "./read-recorder.js";
14
+ import { inView, placeOf } from "./sub-part-views.js";
14
15
 
15
16
  // Names of the sub-parts a view shows: declared in the view and enabled for these
16
17
  // params. Order follows Object.keys(part.parts) (definition order).
17
18
  export function viewSubParts(part, view, params) {
18
19
  return Object.keys(part.parts).filter((name) => {
19
20
  const sp = part.parts[name];
20
- const inView = sp.views.includes(view);
21
21
  const on = sp.enabled ? !!sp.enabled(params) : true;
22
- return inView && on;
22
+ return inView(sp, view) && on;
23
23
  });
24
24
  }
25
25
 
@@ -70,12 +70,13 @@ export function resolveParams(part, params, sanitize, reads) {
70
70
  return recordedParams(resolved, reads);
71
71
  }
72
72
 
73
- // Build one sub-part and apply its optional place() for the given purpose/view.
73
+ // Build one sub-part and apply its placement (sub-part-views.js placeOf — a legacy place() or the compiled views map) for the given purpose/view.
74
74
  // `p`/`d` come from resolveParams(). This is the SINGLE definition of "a posed
75
75
  // sub-part solid" — the worker, the collision check, and the test harness all call
76
76
  // it, so display/export poses can never drift between the app and its tests.
77
77
  export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress } = {}) {
78
78
  const sp = part.parts[name];
79
79
  const solid = sp.build(kernel, p, d, onProgress);
80
- return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
80
+ const place = placeOf(sp);
81
+ return place ? place(solid, { view, purpose, p, d }) : solid;
81
82
  }
@@ -16,6 +16,7 @@
16
16
  // same source text at every value of `p.z`, so hashing it would hold baseHash
17
17
  // stable while the real geometry changed — precisely the false-positive the fast
18
18
  // path must never make. Untrusted subparts simply take the normal regen path.
19
+ import { placeOf } from "./sub-part-views.js";
19
20
  import { h } from "./geometry/solid-hash.js";
20
21
  import { addSugar } from "./geometry/solid-sugar.js";
21
22
  import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
@@ -127,15 +128,16 @@ const stepsFinite = (steps) => steps.every((st) =>
127
128
  export function probeSubPartPose(sp, { view, purpose = "display", p, d }, { scope = "full" } = {}) {
128
129
  try {
129
130
  const { kernel, state, token } = makeProbeSession(); // fresh op budget + trust per subpart
131
+ const place = placeOf(sp);
130
132
  if (scope === "place") {
131
133
  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 (!place) return { trusted: true, baseHash: CANONICAL, pose: [] };
135
+ s = place(s, { view, purpose, p, d });
134
136
  if (!(s && s.__poseToken) || state.queried || state.unhashable || !stepsFinite(s._pose)) return { trusted: false };
135
137
  return { trusted: s._hash === CANONICAL, baseHash: s._hash, pose: s._pose };
136
138
  }
137
139
  let s = sp.build(kernel, p, d);
138
- if (scope === "full" && sp.place) s = sp.place(s, { view, purpose, p, d });
140
+ if (scope === "full" && place) s = place(s, { view, purpose, p, d });
139
141
  const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
140
142
  return ok ? { trusted: true, baseHash: s._hash, pose: s._pose } : { trusted: false };
141
143
  } catch {
@@ -15,7 +15,11 @@
15
15
  // and `sp.place === sp.sheet.generatedPlace`.
16
16
  //
17
17
  // Placement (decision 10): the pose applies for display AND export, then the author's
18
- // own `place` — so STEP/3MF/STL files of a sheet forge come out assembled.
18
+ // own `place` — so STEP/3MF/STL files of a sheet forge come out assembled. With a views
19
+ // map, the generated place is the BASE of the compiled placement (sub-part-views.js
20
+ // placeOf): the pose applies for display and export, then the view's entry for display
21
+ // only. Sheet entries are taught as `true`; a pose entry is allowed but drops the burn
22
+ // pass (sheet-look.js burnsFor).
19
23
  import { RENAMED_KEYS, RESERVED_KEYS, SHEET_KEYS, SUBPART_PASSTHROUGH_KEYS } from "./constants.js";
20
24
  import { validatePose } from "./pose.js";
21
25
  import { applyPose, resolveSheet, sheetPreview } from "./resolve.js";
@@ -0,0 +1,56 @@
1
+ // One sub-part's views and placement, in either authoring form.
2
+ //
3
+ // map form: views: { assembly: (s, p, d) => s.translate(…), lid: true }
4
+ // legacy form: views: ["assembly", "lid"], place: (s, { view, purpose, p, d }) => s
5
+ //
6
+ // Every reader of a sub-part's views or placement asks here, so the map form compiles to
7
+ // the same place(s, { view, purpose, p, d }) the pose probe, the pose ladder, print frames
8
+ // and the oracle already understand (spec 2026-10-04 "Compilation"). A map entry is `true`
9
+ // (shown as built) or a rigid pose; anything else leaves the piece out of that view and is
10
+ // lint's view-entry-invalid. Export never applies an entry: a map-form piece exports as
11
+ // built. A base `place` on a map-form sub-part can only be sheetPart's generated pose
12
+ // (lint's views-and-place refuses an author's), and it runs first for both purposes, as
13
+ // sheet poses always have.
14
+ //
15
+ // Pure and import-free: lint rules import it (test/lint-purity.test.js).
16
+
17
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
18
+ const isEntry = (v) => v === true || typeof v === "function";
19
+
20
+ export const isViewsMap = (views) => isPlainObject(views);
21
+
22
+ export function viewsOf(sp) {
23
+ const v = sp?.views;
24
+ if (Array.isArray(v)) return v;
25
+ if (isPlainObject(v)) return Object.keys(v).filter((name) => isEntry(v[name]));
26
+ return [];
27
+ }
28
+
29
+ export const inView = (sp, view) => viewsOf(sp).includes(view);
30
+
31
+ export const formOf = (sp) => (isViewsMap(sp?.views) ? "map" : "legacy");
32
+
33
+ // Memo only: never identity-compared across module instances (the cloud worker loads two).
34
+ const compiled = new WeakMap();
35
+
36
+ export function placeOf(sp) {
37
+ if (!sp) return null;
38
+ if (compiled.has(sp)) return compiled.get(sp);
39
+ if (!isViewsMap(sp.views)) {
40
+ // A wrapper, so a method-style place keeps `this` when callers invoke it detached.
41
+ const legacy = typeof sp.place === "function" ? (s, ctx) => sp.place(s, ctx) : null;
42
+ compiled.set(sp, legacy);
43
+ return legacy;
44
+ }
45
+ const base = typeof sp.place === "function" ? sp.place : null;
46
+ const entries = sp.views;
47
+ const anyPose = Object.values(entries).some((v) => typeof v === "function");
48
+ const fn = !base && !anyPose ? null : (s, ctx) => {
49
+ const posed = base ? base(s, ctx) : s;
50
+ if (ctx.purpose === "export") return posed;
51
+ const entry = entries[ctx.view];
52
+ return typeof entry === "function" ? entry(posed, ctx.p, ctx.d) : posed;
53
+ };
54
+ compiled.set(sp, fn);
55
+ return fn;
56
+ }
@@ -1,7 +1,7 @@
1
1
  // Animation reference part — a box with a hinged lid. Worked example for
2
2
  // docs/AUTHORING-PARTS.md "Animations": animations are VIEW-OWNED (declared
3
3
  // under `views.box.animations`, so the transport bar belongs to that view),
4
- // they drive pose-only params (lidAngle, lidLift) through place(), and
4
+ // they drive pose-only params (lidAngle, lidLift) through the lid's `views` entry, and
5
5
  // `assemble` opens with an OPACITY fade that brings the lid in from nothing
6
6
  // before any motion. Also shown: an intro camera + markdown description on
7
7
  // `open`, a looping autoplay `cycle`, and per-step cameras on `assemble`.
@@ -39,7 +39,7 @@ export default {
39
39
  parts: {
40
40
  base: {
41
41
  label: "Base",
42
- views: ["box"],
42
+ views: { box: true },
43
43
  export: { name: "base" },
44
44
  display: { material: "walnut" },
45
45
  build: (k, p) =>
@@ -48,19 +48,18 @@ export default {
48
48
  },
49
49
  lid: {
50
50
  label: "Lid",
51
- views: ["box"],
52
51
  export: { name: "lid" },
53
52
  display: { material: "walnut" },
54
- build: (k, p) => k.box({ min: [0, 0, p.height], max: [p.width, p.depth, p.height + p.wall] }),
55
- // Display: swing about the hinge line (rear top edge, axis +X through
56
- // [0, depth, height]; negative angle opens upward), then the assembly
57
- // lift. Export: the lid prints flat beside the base. Both poses are
58
- // rigid motions of the same solid, and neither reads `view` — the two
59
- // invariants lint's place rules hold every part to.
60
- place: (s, { purpose, p }) =>
61
- purpose === "export"
62
- ? s.translate([p.width + 10, 0, -p.height])
63
- : s.rotate(-p.lidAngle, [0, p.depth, p.height], [1, 0, 0]).translate([0, 0, p.lidLift]),
53
+ // Built as it prints: a flat plate at the origin. The `box` view sets it on the
54
+ // base, swings it about the hinge line (rear top edge, axis +X through
55
+ // [0, depth, height]; negative angle opens upward) and applies the assembly
56
+ // lift. lidAngle and lidLift are read only here, so both animate at frame rate.
57
+ build: (k, p) => k.box({ min: [0, 0, 0], max: [p.width, p.depth, p.wall] }),
58
+ views: {
59
+ box: (s, p) => s.translate([0, 0, p.height])
60
+ .rotate(-p.lidAngle, [0, p.depth, p.height], [1, 0, 0])
61
+ .translate([0, 0, p.lidLift]),
62
+ },
64
63
  },
65
64
  },
66
65
  views: {
@@ -32,17 +32,16 @@ function hinge(k, p, d) {
32
32
  return k.union([fixed, moving]);
33
33
  }
34
34
 
35
- // Display: flat hinge against the back panel's outside face, knuckles above the wall.
36
- // Export: the print pose, untouched — both hinges export the same solid.
37
- const hingePlace = (i) => (s, { purpose, p, d }) => (purpose === "export" ? s
38
- : s.rotateX(90).at([d.hingeX[i], p.depth / 2 + HINGE.leafT, d.axisZ]));
35
+ // Each hinge is built in its PRINT pose (above); the box view stands it up on the back
36
+ // panel. Identical builds, so the kit still prints "hinge" ×2.
37
+ const hingePose = (i) => (s, p, d) => s.rotateX(90).at([d.hingeX[i], p.depth / 2 + HINGE.leafT, d.axisZ]);
39
38
 
40
39
  // Tongue slots for both hinges, cut where the tongues really land (world → sheet).
41
40
  const hingeSlots = (k, p, d, pose, z) => d.hingeX.flatMap((hx) =>
42
41
  [hx - HINGE.tongueX, hx + HINGE.tongueX].map((x) =>
43
42
  k.shape2d(tab(p).slot).translate(worldToSheet(pose, [x, p.depth / 2, z]))));
44
43
 
45
- const PLY = { views: ["box"], display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
44
+ const PLY = { views: { box: true }, display: { material: "plywood" }, material: "birch plywood", thickness: (p) => p.t };
46
45
  const panel = (name, label, extra = {}) => sheetPart({
47
46
  ...PLY, label,
48
47
  profile: (k, p, d) => d.box[name].outline, // drawn as seen from outside
@@ -97,10 +96,10 @@ export default {
97
96
  .cutAll(hingeSlots(k, p, d, d.lidPose, d.axisZ + d.tongueY)),
98
97
  pose: (p, d) => d.lidPose,
99
98
  }),
100
- hingeL: { label: "Hinge (left)", views: ["box"], display: { material: "pla-print" },
101
- export: { name: "hinge" }, build: hinge, place: hingePlace(0) },
102
- hingeR: { label: "Hinge (right)", views: ["box"], display: { material: "pla-print" },
103
- build: hinge, place: hingePlace(1) }, // identical solid: the kit prints "hinge" ×2
99
+ hingeL: { label: "Hinge (left)", views: { box: hingePose(0) }, display: { material: "pla-print" },
100
+ export: { name: "hinge" }, build: hinge },
101
+ hingeR: { label: "Hinge (right)", views: { box: hingePose(1) }, display: { material: "pla-print" },
102
+ build: hinge }, // identical solid: the kit prints "hinge" ×2
104
103
  },
105
104
  views: { box: { label: "Box" } },
106
105
  // `process` = the PRINTED parts' profile; sheet parts get the laser checks instead.