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.
- package/README.md +1 -1
- package/docs/AUTHORING-PARTS.md +103 -58
- package/docs/ERROR-PATTERNS.md +25 -1
- package/docs/KERNEL-CONTRACT.md +1 -1
- package/package.json +1 -1
- package/src/framework/default-view.js +2 -1
- package/src/framework/export/bed-layout.js +62 -0
- package/src/framework/geometry/probe.js +24 -3
- package/src/framework/imports.js +11 -1
- package/src/framework/jobs.js +15 -5
- package/src/framework/lint/rules-animations.js +4 -3
- package/src/framework/lint/rules-place.js +81 -3
- package/src/framework/lint/rules-shape.js +10 -7
- package/src/framework/materials/print-frame.js +3 -2
- package/src/framework/materials/sheet-look.js +2 -0
- package/src/framework/mount.js +2 -0
- package/src/framework/oracle/verify.js +1 -1
- package/src/framework/param-deps.js +5 -3
- package/src/framework/part-model.js +5 -4
- package/src/framework/pose-probe-core.js +5 -3
- package/src/framework/sheet/part.js +5 -1
- package/src/framework/sub-part-views.js +56 -0
- package/src/parts/hinged-box.js +12 -13
- package/src/parts/laser-box.js +8 -9
- package/src/parts/lattice-box.js +110 -0
- package/src/parts/mixed-smoke.js +2 -3
- package/types/geometry.d.ts +1 -1
- package/types/part.d.ts +10 -3
|
@@ -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]) =>
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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\`
|
|
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
|
|
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
|
|
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
|
|
package/src/framework/mount.js
CHANGED
|
@@ -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: "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
133
|
-
s =
|
|
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" &&
|
|
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
|
+
}
|
package/src/parts/hinged-box.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
55
|
-
//
|
|
56
|
-
// [0, depth, height]; negative angle opens upward)
|
|
57
|
-
// lift.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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: {
|
package/src/parts/laser-box.js
CHANGED
|
@@ -32,17 +32,16 @@ function hinge(k, p, d) {
|
|
|
32
32
|
return k.union([fixed, moving]);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
const
|
|
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:
|
|
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:
|
|
101
|
-
export: { name: "hinge" }, build: hinge
|
|
102
|
-
hingeR: { label: "Hinge (right)", views:
|
|
103
|
-
build: hinge
|
|
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
|
+
};
|
package/src/parts/mixed-smoke.js
CHANGED
|
@@ -21,7 +21,7 @@ export default {
|
|
|
21
21
|
parts: {
|
|
22
22
|
body: {
|
|
23
23
|
label: "Body",
|
|
24
|
-
views:
|
|
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: [
|
|
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" } },
|
package/types/geometry.d.ts
CHANGED
|
@@ -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;
|