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.
- package/README.md +16 -4
- package/bin/cli.js +57 -2
- package/docs/AUTHORING-PARTS.md +205 -14
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +6 -2
- package/skills/partforge/SKILL.md +7 -1
- package/src/app-bracket.js +5 -0
- package/src/app-demo.js +5 -0
- package/src/app-faceted-vase.js +5 -0
- package/src/app-filleted-box.js +5 -0
- package/src/app-hull-sweep.js +5 -0
- package/src/app-nameplate.js +5 -0
- package/src/app-planter.js +5 -0
- package/src/app-text-smoke.js +5 -0
- package/src/framework/app.css +85 -39
- package/src/framework/chrome.css +180 -0
- package/src/framework/debug-overlay.js +16 -1
- package/src/framework/geometry/op-options.js +3 -1
- package/src/framework/geometry/probe.js +122 -23
- package/src/framework/lint/finding.js +20 -0
- package/src/framework/lint/index.js +114 -0
- package/src/framework/lint/rules-build.js +84 -0
- package/src/framework/lint/rules-schema.js +209 -0
- package/src/framework/lint/rules-shape.js +86 -0
- package/src/framework/lint/rules-verify.js +288 -0
- package/src/framework/mount.js +14 -0
- package/src/framework/rail-state.js +73 -0
- package/src/framework/rail.js +321 -0
- package/src/framework/tokens.css +14 -1
- package/src/framework/verify-metrics.js +51 -0
- package/src/framework/worker.js +9 -0
- package/src/lint.js +3 -0
- package/src/testing/verify.js +4 -45
|
@@ -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
|
+
];
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
+
}
|