partforge 0.142.0 → 0.143.1

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,3 +1,6 @@
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).
1
4
  // Group 6 — the place() invariant (docs/AUTHORING-PARTS.md "Display vs export
2
5
  // placement"), promoted from a doc-only convention to lint because the animation
3
6
  // system leans on place() for every pose-only track: display vs export may differ
@@ -15,7 +18,9 @@
15
18
  // and stays silent. Readability, not `trusted`, gates the rules: the same reshape
16
19
  // on both purposes is allowed, so a reshaping place() must still be compared.
17
20
  import { err } from "./finding.js";
18
- 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";
19
24
 
20
25
  const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
21
26
 
@@ -24,7 +29,7 @@ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArr
24
29
  // imported from jobs.js, which would break the lint purity closure.
25
30
  function viewNames(part, view, p) {
26
31
  return Object.entries(isPlainObject(part?.parts) ? part.parts : {})
27
- .filter(([, sp]) => Array.isArray(sp?.views) && sp.views.includes(view))
32
+ .filter(([, sp]) => inView(sp, view))
28
33
  .filter(([, sp]) => { try { return sp.enabled ? !!sp.enabled(p) : true; } catch { return false; } })
29
34
  .map(([name]) => name);
30
35
  }
@@ -38,7 +43,7 @@ export const PLACE_RULES = [
38
43
  run: ({ part, p, d }) => {
39
44
  const out = [];
40
45
  for (const [name, sp] of Object.entries(isPlainObject(part?.parts) ? part.parts : {})) {
41
- if (!sp?.place) continue;
46
+ if (!sp?.place || formOf(sp) === "map") continue;
42
47
  for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
43
48
  if (!viewNames(part, view, p).includes(name)) continue;
44
49
  const display = probePlace(sp, { view, purpose: "display", p, d });
@@ -57,4 +62,77 @@ export const PLACE_RULES = [
57
62
  return out;
58
63
  },
59
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
+ },
60
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];
@@ -34,7 +35,7 @@ const isIdentity = (m) => m.every((v, i) => Math.abs(v - IDENTITY[i]) < 1e-12);
34
35
  // readable, and a build that queries its solid cannot make it untrusted. A place()
35
36
  // that only the full-scope probe reads falls back to printFrameMatrix's posed branch.
36
37
  export function displayToPrintMatrix(subPart, { view, p, d }) {
37
- if (!subPart?.place) return null;
38
+ if (!placeOf(subPart)) return null;
38
39
  const disp = probeSubPartPose(subPart, { view, purpose: "display", p, d }, { scope: "place" });
39
40
  const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d }, { scope: "place" });
40
41
  const m = disp.trusted && exp.trusted
@@ -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 });
@@ -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.
@@ -0,0 +1,110 @@
1
+ // Worked example for the views map (docs/AUTHORING-PARTS.md "The PartDefinition
2
+ // contract"): a storage box with a hinged lid, a lattice insert in the lid window, a
3
+ // nameplate on the front and a filament-offcut hinge pin. Every piece is ONE sub-part,
4
+ // built as it prints; each view entry only moves it — `assembly` puts it together and
5
+ // swings the lid, the piece tabs show it as built, `print` spreads the printed pieces
6
+ // on the bed. lidAngle is read only in view entries, so the Open lid animation never
7
+ // rebuilds geometry, and layer lines follow each piece's print orientation.
8
+
9
+ // The one hinge every piece that rides on the lid swings about.
10
+ const swing = (s, p, d) => s.rotate(-p.lidAngle, d.hinge, [1, 0, 0]);
11
+
12
+ export default {
13
+ meta: { title: "Lattice Box", units: "mm" },
14
+ parameters: [
15
+ {
16
+ id: "box", title: "Box",
17
+ advanced: [
18
+ { key: "w", label: "Width", unit: "mm", min: 50, max: 140, step: 1 },
19
+ { key: "d", label: "Depth", unit: "mm", min: 40, max: 120, step: 1 },
20
+ { key: "h", label: "Height", unit: "mm", min: 20, max: 80, step: 1 },
21
+ ],
22
+ },
23
+ {
24
+ id: "pose", title: "Pose",
25
+ advanced: [{ key: "lidAngle", label: "Lid angle", unit: "°", min: 0, max: 110, step: 1 }],
26
+ },
27
+ ],
28
+ defaults: { w: 80, d: 60, h: 40, wall: 2, t: 2.4, lidAngle: 0 },
29
+ derive: (p) => ({
30
+ hinge: [0, p.d + 1, p.h], // just behind the rear top edge, along X
31
+ win: { x: 12, y: 12, w: p.w - 24, d: p.d - 24 }, // the lid's window
32
+ fit: 0.2,
33
+ gap: 10, // spacing in the print view
34
+ }),
35
+ parts: {
36
+ body: {
37
+ label: "Body",
38
+ display: { color: 0x1e88e5 },
39
+ // Open-top box; prints open side up, as built.
40
+ build: (k, p) => k.box({ min: [0, 0, 0], max: [p.w, p.d, p.h] })
41
+ .cut(k.box({ min: [p.wall, p.wall, p.wall], max: [p.w - p.wall, p.d - p.wall, p.h + 1] })),
42
+ views: { assembly: true, body: true, print: true },
43
+ },
44
+ lid: {
45
+ label: "Lid",
46
+ display: { color: 0xe53935 },
47
+ // A flat plate with the lattice window, printed flat.
48
+ build: (k, p, d) => k.box({ min: [0, 0, 0], max: [p.w, p.d, p.t] })
49
+ .cut(k.box({ min: [d.win.x, d.win.y, -1], max: [d.win.x + d.win.w, d.win.y + d.win.d, p.t + 1] })),
50
+ views: {
51
+ assembly: (s, p, d) => swing(s.translate([0, 0, p.h]), p, d),
52
+ lid: true,
53
+ print: (s, p, d) => s.translate([p.w + d.gap, 0, 0]),
54
+ },
55
+ },
56
+ mesh: {
57
+ label: "Lattice insert",
58
+ display: { color: 0x222222 },
59
+ // Fills the window less the fit, with a staggered grid of round holes; printed flat.
60
+ build: (k, p, d) => {
61
+ const W = d.win.w - 2 * d.fit, D = d.win.d - 2 * d.fit;
62
+ let plate = k.box({ min: [0, 0, 0], max: [W, D, p.t] });
63
+ let row = 0;
64
+ for (let y = 4; y <= D - 4; y += 6, row++) {
65
+ for (let x = 4 + (row % 2) * 3; x <= W - 4; x += 6) {
66
+ plate = plate.cut(k.cylinder({ r: 2.2, h: p.t + 2 }).translate([x, y, -1]));
67
+ }
68
+ }
69
+ return plate;
70
+ },
71
+ views: {
72
+ assembly: (s, p, d) => swing(s.translate([d.win.x + d.fit, d.win.y + d.fit, p.h]), p, d),
73
+ mesh: true,
74
+ print: (s, p, d) => s.translate([p.w + d.gap, p.d + d.gap, 0]),
75
+ },
76
+ },
77
+ plate: {
78
+ label: "Nameplate",
79
+ display: { color: 0xffd400 },
80
+ // A thin plate, printed flat; stands just off the front of the box (0.2 mm clear) in the assembly.
81
+ build: (k) => k.box({ min: [0, 0, 0], max: [40, 12, 1.6] }),
82
+ views: {
83
+ assembly: (s, p) => s.rotateX(90).translate([(p.w - 40) / 2, -0.2, p.h / 2 - 6]),
84
+ plate: true,
85
+ print: (s, p, d) => s.translate([0, p.d + d.gap, 0]),
86
+ },
87
+ },
88
+ pin: {
89
+ label: "Hinge pin (1.75 mm filament)",
90
+ exportable: false,
91
+ display: { color: 0x9e9e9e },
92
+ build: (k, p) => k.cylinder({ r: 0.875, h: p.w }).rotate(90, [0, 0, 0], [0, 1, 0]),
93
+ views: { assembly: (s, p, d) => s.translate(d.hinge) },
94
+ },
95
+ },
96
+ views: {
97
+ assembly: {
98
+ label: "Assembly",
99
+ animations: {
100
+ open: { label: "Open lid", duration: 1.2, tracks: { lidAngle: [[0, 0], [1, 110]] } },
101
+ },
102
+ },
103
+ body: { label: "Body" },
104
+ lid: { label: "Lid" },
105
+ mesh: { label: "Lattice insert" },
106
+ plate: { label: "Nameplate" },
107
+ print: { label: "Print bed" },
108
+ },
109
+ verify: { process: "fdm-pla", expect: { _view: { overlaps: 0 } } },
110
+ };
@@ -21,7 +21,7 @@ export default {
21
21
  parts: {
22
22
  body: {
23
23
  label: "Body",
24
- views: ["assembly"],
24
+ views: { assembly: true },
25
25
  export: { name: "body" },
26
26
  // The t > 0 branch is what drives the routing: the probe re-runs with live
27
27
  // params, so the shell call is only seen (and OCCT only engaged) when the
@@ -33,10 +33,9 @@ export default {
33
33
  },
34
34
  lid: {
35
35
  label: "Lid",
36
- views: ["assembly"],
36
+ views: { assembly: (s) => s.at([0, 0, 12]) },
37
37
  export: { name: "lid" },
38
38
  build: (k, p) => k.box({ min: [0, 0, 0], max: [p.w, p.w, 2] }),
39
- place: (s) => s.at([0, 0, 12]),
40
39
  },
41
40
  },
42
41
  views: { assembly: { label: "Assembly" } },
@@ -317,7 +317,7 @@ export interface SheetPartSpec<P = ResolvedParams, D = Derived> {
317
317
  pose?: SheetPose | ((p: P, d: D) => SheetPose | null) | null;
318
318
  process?: "laser";
319
319
  label?: string;
320
- views: string[];
320
+ views: string[] | Record<string, true | ((s: Solid, p: P, d: D) => Solid)>;
321
321
  display?: SubPartDefinition<P, D>["display"];
322
322
  export?: { name: string };
323
323
  enabled?: (p: P) => unknown;