partforge 0.26.1 → 0.28.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.
@@ -0,0 +1,288 @@
1
+ // Group 4 — the verify block's own well-formedness. Each condition here currently
2
+ // throws from verify() mid-run, AFTER measure has printed and the kernel has booted,
3
+ // which is also the documented reason CLI stdout isn't pure JSON in that case.
4
+ // Catching them statically removes both the wasted boot and the stdout caveat.
5
+ import { err } from "./finding.js";
6
+ import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
7
+ import { PROFILES } from "../../testing/dfm-profiles.js";
8
+ import { parseAssertion } from "../../testing/assert-dsl.js";
9
+ import { suggest } from "../geometry/op-options.js";
10
+
11
+ // Resolve `expect` to a plain object. The function form (p, d) => ({…}) is invoked
12
+ // once with the probe's params so per-preset topology can be linted like any other.
13
+ // Returns { expect, threw }.
14
+ //
15
+ // Exported so `lintContext` (index.js) can memoize a single call per lint pass and
16
+ // share it across every Group 4 rule below — `expect` is user-supplied code, and
17
+ // without memoization a function-form `expect` that throws only on its first call
18
+ // would fire both `verify-expect-throws` (first call) and whatever rule calls it
19
+ // next (second call succeeds), a cascading double-report.
20
+ export function resolveExpect(verify, p, d) {
21
+ if (typeof verify?.expect !== "function") return { expect: verify?.expect, threw: null };
22
+ try { return { expect: verify.expect(p, d), threw: null }; }
23
+ catch (e) { return { expect: null, threw: e?.message || String(e) }; }
24
+ }
25
+
26
+ const isExpectation = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && "expr" in v;
27
+ const exprOf = (v) => (isExpectation(v) ? v.expr : v);
28
+
29
+ // `contacts` (must-touch pairs) and `clearance` (free-fit gaps) live under
30
+ // `verify.expect._view` but are pair-wise checks, not scalar VIEW_METRICS — mirror
31
+ // verify.js's own peel (verify.js:143) so they never hit the scalar-metric /
32
+ // assertion-expression checks below. Validated for real by `verify-bad-pair-check`.
33
+ const peelPairKeys = (metrics) => {
34
+ const { contacts, clearance, ...rest } = metrics;
35
+ return rest;
36
+ };
37
+
38
+ // `JSON.stringify` can itself throw (BigInt, circular refs) — lintPart must never
39
+ // throw, so fall back to `String` for anything that won't serialize.
40
+ const describe = (v) => { try { return JSON.stringify(v); } catch { return String(v); } };
41
+
42
+ // `suggest` calls `.toLowerCase()` on the key; guard against non-string pair names
43
+ // (a runtime `contacts`/`clearance` entry can contain anything).
44
+ const safeSuggest = (key, valid) => (typeof key === "string" ? suggest(key, valid) : null);
45
+
46
+ // `requirePair` (verify.js:41) throws before it even looks at `part.parts` when a
47
+ // pair names the same sub-part twice — a pair describes a relationship between two
48
+ // distinct sub-parts, so self-pairs are never legal.
49
+ function checkSameName(a, b, path) {
50
+ return a === b
51
+ ? [err("verify-bad-pair-check",
52
+ `\`${path}\` names the same sub-part twice ("${a}")`,
53
+ "A pair must name two different sub-parts. Change one side to the other sub-part it should be checked against, or remove the entry if it doesn't describe a real relationship.",
54
+ path)]
55
+ : [];
56
+ }
57
+
58
+ // Both `contacts` pairs and `clearance` keys ultimately name two sub-parts; a name
59
+ // absent from `part.parts` is what the runtime `requirePair` throws for.
60
+ function checkPairNames(a, b, names, path) {
61
+ const out = [];
62
+ for (const n of [a, b]) {
63
+ if (names.includes(n)) continue;
64
+ const hint = safeSuggest(n, names);
65
+ out.push(err("verify-unknown-subpart",
66
+ `\`${path}\` references "${n}", which is not a sub-part`,
67
+ `Use one of the sub-part names (${names.join(", ")})${hint ? ` — did you mean "${hint}"?` : "."}`,
68
+ path));
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // Static analogue of verify.js's `pairGapChecks` contacts handling (verify.js:61-66).
74
+ function checkContacts(contacts, names, path) {
75
+ if (contacts === undefined || contacts === null) return [];
76
+ if (!Array.isArray(contacts)) {
77
+ return [err("verify-bad-pair-check",
78
+ `\`${path}\` must be an array of ["a", "b"] pairs, got ${describe(contacts)}`,
79
+ `Set \`contacts\` to an array of two-element sub-part name pairs, e.g. \`contacts: [["lid", "body"]]\`.`,
80
+ path)];
81
+ }
82
+ const out = [];
83
+ contacts.forEach((pair, i) => {
84
+ const pairPath = `${path}[${i}]`;
85
+ if (!Array.isArray(pair) || pair.length !== 2) {
86
+ out.push(err("verify-bad-pair-check",
87
+ `\`${pairPath}\` must be an ["a", "b"] pair, got ${describe(pair)}`,
88
+ `Each \`contacts\` entry must be a two-element array naming the two sub-parts that should touch, e.g. \`["lid", "body"]\`.`,
89
+ pairPath));
90
+ return;
91
+ }
92
+ out.push(...checkSameName(pair[0], pair[1], pairPath));
93
+ // `requirePair` (verify.js:41) checks the same-name case first and throws
94
+ // before ever looking at `part.parts` — so a same-name pair never reaches the
95
+ // unknown-name check at runtime either. Skip it here too, or an unknown
96
+ // self-paired name would double-report (once per identical position).
97
+ if (pair[0] !== pair[1]) out.push(...checkPairNames(pair[0], pair[1], names, pairPath));
98
+ });
99
+ return out;
100
+ }
101
+
102
+ // Static analogue of verify.js's `pairGapChecks` clearance handling (verify.js:85-87).
103
+ // Note the separator is the multiplication sign "×" (U+00D7), not the letter x.
104
+ function checkClearance(clearance, names, path) {
105
+ if (clearance === undefined || clearance === null || typeof clearance !== "object") return [];
106
+ const out = [];
107
+ for (const key of Object.keys(clearance)) {
108
+ const pairPath = `${path}[${JSON.stringify(key)}]`;
109
+ const pair = key.split("×").map((s) => s.trim());
110
+ if (pair.length !== 2 || !pair[0] || !pair[1]) {
111
+ out.push(err("verify-bad-pair-check",
112
+ `\`${pairPath}\` key must be "a×b" (sub-part names joined by the multiplication sign ×), got ${JSON.stringify(key)}`,
113
+ `Rename the key to \`"a×b"\` using the two sub-part names that should have a declared clearance, e.g. \`"lid×body"\`. The separator is U+00D7 (×), not the letter x.`,
114
+ pairPath));
115
+ continue;
116
+ }
117
+ out.push(...checkSameName(pair[0], pair[1], pairPath));
118
+ // Same short-circuit as `checkContacts` above: a same-name pair never reaches
119
+ // the unknown-name check at runtime (or here), so it can't double-report.
120
+ if (pair[0] !== pair[1]) out.push(...checkPairNames(pair[0], pair[1], names, pairPath));
121
+ }
122
+ return out;
123
+ }
124
+
125
+ // `clearance`'s values genuinely are assertion expressions run through the DSL
126
+ // (verify.js:96), unlike `contacts` (pair arrays with nothing to parse). Mirror
127
+ // verify.js's own normalization — a bare value or an `{ expr, hint }` wrapper,
128
+ // via `normalizeExpectation` (verify.js:91) — using the same `isExpectation` /
129
+ // `exprOf` helpers the scalar-metric loop below uses. Reported under
130
+ // `verify-bad-expr`, not `verify-bad-pair-check`: a malformed key is a pair-naming
131
+ // problem, but a malformed value is an assertion-DSL problem, same as any other
132
+ // metric's expectation.
133
+ function checkClearanceExprs(clearance, path) {
134
+ if (clearance === undefined || clearance === null || typeof clearance !== "object") return [];
135
+ const out = [];
136
+ for (const [key, spec] of Object.entries(clearance)) {
137
+ try { parseAssertion(exprOf(spec)); }
138
+ catch (e) {
139
+ out.push(err("verify-bad-expr",
140
+ `the expectation for _view.clearance["${key}"] is not a valid assertion: ${e?.message || String(e)}`,
141
+ "Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` (with `*` to skip an axis).",
142
+ `${path}[${JSON.stringify(key)}]`));
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // Static walk of `resolveProfile`'s (dfm-profiles.js) `base` chain, mirroring its
149
+ // exact throw conditions at every level: a string not in `PROFILES` ("unknown
150
+ // process profile"), or — recursively — a bad `base` nested inside a `base`
151
+ // object. A falsy `base` resolves to `{}` at runtime (`spec.base ? … : {}`) and
152
+ // is never reached, so it's left alone here too. `seen` records every `base`
153
+ // value already visited so a self-referential or cyclic chain (`base.base ===
154
+ // base`) cannot recurse forever — `lintPart` must always terminate. `depth` is a
155
+ // second, cheap belt-and-suspenders cap for the same reason.
156
+ function checkProcessSpec(spec, path, valid, seen, depth = 0) {
157
+ if (depth > 50) return []; // pathological chain — bail rather than hang
158
+ if (typeof spec === "string") {
159
+ if (valid.includes(spec)) return [];
160
+ const hint = suggest(spec, valid);
161
+ return [err("verify-unknown-process",
162
+ `\`${path}\` names "${spec}", which is not a known DFM profile`,
163
+ `Use one of: ${valid.join(", ")}${hint ? ` — did you mean "${hint}"?` : ""}, or pass an inline profile object such as \`{ bed: [220, 220, 250], minWall: 1.2 }\`.`,
164
+ path)];
165
+ }
166
+ if (spec && typeof spec === "object") {
167
+ if (!spec.base) return []; // no base (or a falsy one) — nothing further to resolve
168
+ if (seen.has(spec.base)) return []; // already visited — self-referential/cyclic, stop
169
+ seen.add(spec.base);
170
+ return checkProcessSpec(spec.base, `${path}.base`, valid, seen, depth + 1);
171
+ }
172
+ // Anything else truthy (number, boolean, …) is what resolveProfile's final
173
+ // branch throws "invalid process profile" for.
174
+ return [err("verify-unknown-process",
175
+ `\`${path}\` is not a valid DFM profile: ${describe(spec)}`,
176
+ `Use one of: ${valid.join(", ")}, or an inline profile object such as \`{ bed: [220, 220, 250], minWall: 1.2 }\`.`,
177
+ path)];
178
+ }
179
+
180
+ export const VERIFY_RULES = [
181
+ {
182
+ id: "verify-expect-throws",
183
+ run: ({ resolveExpectOnce }) => {
184
+ const { threw } = resolveExpectOnce();
185
+ return threw ? [err("verify-expect-throws",
186
+ `\`verify.expect(p, d)\` threw: ${threw}`,
187
+ "The function form of `expect` must return an expectation object for any parameter set. Guard whatever it reads, or switch to the static object form.",
188
+ "verify.expect")] : [];
189
+ },
190
+ },
191
+ {
192
+ id: "verify-unknown-subpart",
193
+ run: ({ part, resolveExpectOnce }) => {
194
+ const { expect } = resolveExpectOnce();
195
+ if (!expect || typeof expect !== "object") return [];
196
+ const names = Object.keys(part?.parts ?? {});
197
+ return Object.keys(expect)
198
+ .filter((key) => key !== "_view" && !names.includes(key))
199
+ .map((key) => {
200
+ const hint = suggest(key, names);
201
+ return err("verify-unknown-subpart",
202
+ `\`verify.expect\` targets "${key}", which is not a sub-part`,
203
+ `Use one of the sub-part names (${names.join(", ")}) or the literal \`_view\` for whole-assembly metrics${hint ? ` — did you mean "${hint}"?` : "."}`,
204
+ `verify.expect.${key}`);
205
+ });
206
+ },
207
+ },
208
+ {
209
+ id: "verify-unknown-metric",
210
+ run: ({ part, resolveExpectOnce }) => {
211
+ const { expect } = resolveExpectOnce();
212
+ if (!expect || typeof expect !== "object") return [];
213
+ const names = Object.keys(part?.parts ?? {});
214
+ const out = [];
215
+ for (const [target, metricsRaw] of Object.entries(expect)) {
216
+ if (target !== "_view" && !names.includes(target)) continue; // reported by verify-unknown-subpart
217
+ if (!metricsRaw || typeof metricsRaw !== "object") continue;
218
+ // contacts/clearance are pair checks, not scalar view metrics — validated
219
+ // separately by verify-bad-pair-check, and excluded here.
220
+ const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
221
+ const registry = target === "_view" ? VIEW_METRICS : SUBPART_METRICS;
222
+ const valid = Object.keys(registry);
223
+ for (const metric of Object.keys(metrics)) {
224
+ if (valid.includes(metric)) continue;
225
+ const hint = suggest(metric, valid);
226
+ out.push(err("verify-unknown-metric",
227
+ `"${metric}" is not a ${target === "_view" ? "view" : "sub-part"} metric`,
228
+ `Valid ${target === "_view" ? "view" : "sub-part"} metrics are: ${valid.join(", ")}${hint ? ` — did you mean "${hint}"?` : "."}`,
229
+ `verify.expect.${target}.${metric}`));
230
+ }
231
+ }
232
+ return out;
233
+ },
234
+ },
235
+ {
236
+ id: "verify-bad-expr",
237
+ run: ({ resolveExpectOnce }) => {
238
+ const { expect } = resolveExpectOnce();
239
+ if (!expect || typeof expect !== "object") return [];
240
+ const out = [];
241
+ for (const [target, metricsRaw] of Object.entries(expect)) {
242
+ if (!metricsRaw || typeof metricsRaw !== "object") continue;
243
+ // `contacts` is pair arrays with no expression to parse, and `clearance`'s
244
+ // values are keyed by pair name rather than metric name — both are peeled
245
+ // from this scalar-metric loop. `clearance`'s values are still assertions
246
+ // though, so they get their own pass (with a pair-shaped path) below.
247
+ const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
248
+ for (const [metric, spec] of Object.entries(metrics)) {
249
+ try { parseAssertion(exprOf(spec)); }
250
+ catch (e) {
251
+ out.push(err("verify-bad-expr",
252
+ `the expectation for ${target}.${metric} is not a valid assertion: ${e?.message || String(e)}`,
253
+ "Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` (with `*` to skip an axis).",
254
+ `verify.expect.${target}.${metric}`));
255
+ }
256
+ }
257
+ }
258
+ out.push(...checkClearanceExprs(expect?._view?.clearance, "verify.expect._view.clearance"));
259
+ return out;
260
+ },
261
+ },
262
+ {
263
+ id: "verify-bad-pair-check",
264
+ run: ({ part, resolveExpectOnce }) => {
265
+ const { expect } = resolveExpectOnce();
266
+ const view = expect?._view;
267
+ if (!view || typeof view !== "object") return [];
268
+ const names = Object.keys(part?.parts ?? {});
269
+ return [
270
+ ...checkContacts(view.contacts, names, "verify.expect._view.contacts"),
271
+ ...checkClearance(view.clearance, names, "verify.expect._view.clearance"),
272
+ ];
273
+ },
274
+ },
275
+ {
276
+ id: "verify-unknown-process",
277
+ run: ({ part }) => {
278
+ const process = part?.verify?.process;
279
+ // verify.js:163-164 — `profileSpec ?? part.verify?.process` then
280
+ // `profileSpec ? resolveProfile(profileSpec) : null` — that second check is
281
+ // truthiness, not a null/undefined check, so ANY falsy `process` (undefined,
282
+ // null, "", 0, false) never reaches `resolveProfile` and can't throw.
283
+ if (!process) return [];
284
+ const valid = Object.keys(PROFILES);
285
+ return checkProcessSpec(process, "verify.process", valid, new Set());
286
+ },
287
+ },
288
+ ];
@@ -3,6 +3,7 @@ import { triggerDownload, downloadParts } from "./download.js";
3
3
  import { createViewer } from "./viewer.js";
4
4
  import { attachViewerControls } from "./viewer-controls.js";
5
5
  import { attachCutawayControls } from "./cutaway-controls.js";
6
+ import { attachRail } from "./rail.js";
6
7
  import { createTooltipPresenter } from "./tooltip.js";
7
8
  import { loadCamera } from "./view-state.js";
8
9
  import { buildControls } from "./controls.js";
@@ -68,6 +69,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
68
69
  const els = {
69
70
  viewer: elements.viewer ?? legacyContainer ?? byId("app"),
70
71
  controls: elements.controls ?? legacyControls ?? byId("controls"),
72
+ rail: elements.rail ?? byId("panel"),
73
+ // No id fallback: attachRail defaults shell to rail.parentElement, which is
74
+ // right for the standard markup (rail is a direct child of .pf-shell). A
75
+ // host that wraps its rail in an extra element (e.g. a React layout div)
76
+ // must pass this explicitly, or the seam ends up positioned against the
77
+ // wrong ancestor. Left undefined (not null) when unsupplied so rail.js's
78
+ // own default still applies.
79
+ shell: elements.shell,
71
80
  status: {
72
81
  status: elements.status?.status ?? byId("status"),
73
82
  busy: elements.status?.busy ?? byId("busy"),
@@ -84,6 +93,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
84
93
  reframe: elements.chrome?.reframe ?? byId("reframe"),
85
94
  theme: elements.chrome?.theme ?? byId("theme"),
86
95
  cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
96
+ railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
87
97
  },
88
98
  };
89
99
 
@@ -97,6 +107,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
97
107
  cutaway: els.chrome.cutaway,
98
108
  }, { tooltip });
99
109
  cleanup.defer(() => cutawayChrome.detach());
110
+ // Resizable/collapsible controls rail. No-ops when the host lays out the
111
+ // framework itself (no #panel / no elements.rail).
112
+ const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell });
113
+ cleanup.defer(() => railChrome.detach());
100
114
  const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
101
115
  cleanup.defer(() => hover.detach());
102
116
  const ui = createStatusUi({ ...els.status, exports: [els.exports.stl, els.exports.step, els.exports.threeMf] });
@@ -0,0 +1,73 @@
1
+ // Pure state for the controls rail: width clamping, the drag state machine
2
+ // (including snap-to-collapsed), and the stored preference. No DOM here on
3
+ // purpose — this is the part worth testing exhaustively, and a pointer drag in a
4
+ // headless DOM proves very little (scripts/check-app.mjs covers that path in
5
+ // real Chromium).
6
+ //
7
+ // Mirror image of partforge-cloud's left-hand chat pane: this rail is on the
8
+ // RIGHT, so callers convert a pointer position into an intended rail WIDTH
9
+ // (shellRect.right - clientX, grab-offset corrected) before calling in. Nothing
10
+ // here ever sees a raw clientX.
11
+ export const RAIL_DEFAULT_WIDTH = 288;
12
+ export const RAIL_MIN_WIDTH = 240; // a slider label + its numeric field, still readable
13
+ export const RAIL_MAX_WIDTH = 560;
14
+ // Two thresholds rather than one: the 60px between them is hysteresis, so a
15
+ // shaky hand at the boundary can't flap the rail open and shut. Kept
16
+ // PROPORTIONAL to RAIL_MIN_WIDTH (58%-83%) rather than copying the cloud's
17
+ // literals, which are sized against its wider 280px floor.
18
+ export const RAIL_COLLAPSE_AT = 140;
19
+ export const RAIL_REOPEN_AT = 200;
20
+ // Below this the rail stacks under the viewer and resize is absent entirely.
21
+ export const RAIL_NARROW_BREAKPOINT = 720;
22
+ export const RAIL_STORAGE_KEY = "partforge:rail";
23
+
24
+ // The rail may never take more than half the shell, so the viewer can't be
25
+ // squeezed narrower than the rail. Floored at RAIL_MIN_WIDTH so the function
26
+ // stays total (and max >= min) for a transient zero-width measurement.
27
+ export function railMaxWidth(shellWidth) {
28
+ const half = Number.isFinite(shellWidth) ? Math.floor(shellWidth / 2) : RAIL_MAX_WIDTH;
29
+ return Math.max(RAIL_MIN_WIDTH, Math.min(RAIL_MAX_WIDTH, half));
30
+ }
31
+
32
+ export function clampRailWidth(width, shellWidth) {
33
+ const w = Number.isFinite(width) ? Math.round(width) : RAIL_DEFAULT_WIDTH;
34
+ return Math.min(railMaxWidth(shellWidth), Math.max(RAIL_MIN_WIDTH, w));
35
+ }
36
+
37
+ // railX is the pointer's intended rail width. Returns the SAME state object
38
+ // when nothing changes, so a caller can cheaply skip redundant DOM writes.
39
+ export function resolveRailDrag(railX, state, shellWidth) {
40
+ const open = () => ({ collapsed: false, width: clampRailWidth(railX, shellWidth) });
41
+ if (state.collapsed) {
42
+ // Reopening takes a deliberate push past the far threshold.
43
+ return railX < RAIL_REOPEN_AT ? state : open();
44
+ }
45
+ // Collapsing keeps the last open width, so the toggle restores it later.
46
+ if (railX < RAIL_COLLAPSE_AT) return { collapsed: true, width: state.width };
47
+ return open();
48
+ }
49
+
50
+ export function readRailPref(storage, shellWidth) {
51
+ const fallback = { width: RAIL_DEFAULT_WIDTH, collapsed: false };
52
+ let raw;
53
+ try { raw = storage.getItem(RAIL_STORAGE_KEY); } catch { return fallback; }
54
+ if (!raw) return fallback;
55
+ let parsed;
56
+ try { parsed = JSON.parse(raw); } catch { return fallback; }
57
+ if (!parsed || typeof parsed !== "object") return fallback;
58
+ return {
59
+ // Re-clamp on read: a width saved on a wide monitor must not leave a laptop
60
+ // with a 560px rail and no room for the viewer.
61
+ width: clampRailWidth(parsed.width, shellWidth),
62
+ collapsed: parsed.collapsed === true,
63
+ };
64
+ }
65
+
66
+ export function writeRailPref(state, storage) {
67
+ try {
68
+ storage.setItem(RAIL_STORAGE_KEY, JSON.stringify({
69
+ width: state.width,
70
+ collapsed: state.collapsed,
71
+ }));
72
+ } catch { /* storage unavailable — no-op, matching view-state.js */ }
73
+ }