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,321 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RAIL_DEFAULT_WIDTH, RAIL_MIN_WIDTH, RAIL_NARROW_BREAKPOINT,
|
|
3
|
+
clampRailWidth, railMaxWidth, readRailPref, resolveRailDrag, writeRailPref,
|
|
4
|
+
} from "./rail-state.js";
|
|
5
|
+
|
|
6
|
+
const KEY_STEP = 16;
|
|
7
|
+
const KEY_STEP_SHIFT = 64;
|
|
8
|
+
// A held arrow-key repeat must not animate either, so the suppression flag
|
|
9
|
+
// covers the whole repeat window rather than just the instant of a keydown.
|
|
10
|
+
const KEY_SETTLE_MS = 200;
|
|
11
|
+
|
|
12
|
+
// lucide v1.25.0 `panel-right-close` / `panel-right-open` node data (ISC
|
|
13
|
+
// licence) - inlined rather than adding an icon-library dependency for two
|
|
14
|
+
// paths. Both icons share the same 18x18 rounded rect and vertical divider at
|
|
15
|
+
// x=15; they differ only in the chevron, so the SVG is built once (below) and
|
|
16
|
+
// only this `d` is swapped on state change.
|
|
17
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
18
|
+
// panel-right-close: rail OPEN, chevron points right (toward the divider) -
|
|
19
|
+
// clicking pushes the rail shut.
|
|
20
|
+
const CHEVRON_RAIL_OPEN = "m8 9 3 3-3 3";
|
|
21
|
+
// panel-right-open: rail COLLAPSED, chevron points left (away from the
|
|
22
|
+
// divider) - clicking pulls the rail back open.
|
|
23
|
+
const CHEVRON_RAIL_COLLAPSED = "m10 15-3-3 3-3";
|
|
24
|
+
|
|
25
|
+
// Builds the toggle's icon once; apply() swaps the returned chevron path's
|
|
26
|
+
// `d` between CHEVRON_RAIL_OPEN and CHEVRON_RAIL_COLLAPSED. `stroke="currentColor"`
|
|
27
|
+
// is load-bearing: it's how the icon inherits the button's themed colour and
|
|
28
|
+
// its `.on` active state for free, with no icon-specific CSS anywhere.
|
|
29
|
+
function buildToggleIcon() {
|
|
30
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
31
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
32
|
+
svg.setAttribute("width", "16");
|
|
33
|
+
svg.setAttribute("height", "16");
|
|
34
|
+
svg.setAttribute("fill", "none");
|
|
35
|
+
svg.setAttribute("stroke", "currentColor");
|
|
36
|
+
svg.setAttribute("stroke-width", "2");
|
|
37
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
38
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
39
|
+
svg.setAttribute("aria-hidden", "true");
|
|
40
|
+
|
|
41
|
+
const rect = document.createElementNS(SVG_NS, "rect");
|
|
42
|
+
rect.setAttribute("width", "18");
|
|
43
|
+
rect.setAttribute("height", "18");
|
|
44
|
+
rect.setAttribute("x", "3");
|
|
45
|
+
rect.setAttribute("y", "3");
|
|
46
|
+
rect.setAttribute("rx", "2");
|
|
47
|
+
svg.append(rect);
|
|
48
|
+
|
|
49
|
+
const divider = document.createElementNS(SVG_NS, "path");
|
|
50
|
+
divider.setAttribute("d", "M15 3v18");
|
|
51
|
+
svg.append(divider);
|
|
52
|
+
|
|
53
|
+
const chevron = document.createElementNS(SVG_NS, "path");
|
|
54
|
+
svg.append(chevron);
|
|
55
|
+
|
|
56
|
+
return { svg, chevron };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Touching the localStorage PROPERTY can throw (opaque origin, blocked site
|
|
60
|
+
// data) — not just its methods. view-state.js guards it the same way; the
|
|
61
|
+
// framework runs in an iframe in production, where this is reachable.
|
|
62
|
+
function safeStorage() {
|
|
63
|
+
try { return globalThis.localStorage ?? null; } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Make the controls rail resizable and collapsible, with partforge-cloud's seam
|
|
67
|
+
// affordance: a 12px hit target holding a pill that is invisible until hover,
|
|
68
|
+
// keyboard focus, or a drag.
|
|
69
|
+
//
|
|
70
|
+
// The seam is created here, so no host markup declares it. Width is written
|
|
71
|
+
// straight onto :root as --pf-rail-w during a drag with no state layer in
|
|
72
|
+
// between: every width change resizes the viewer, whose ResizeObserver
|
|
73
|
+
// reallocates the WebGL drawing buffer. One reallocation per frame is inherent
|
|
74
|
+
// to live resizing; anything on top of it is not.
|
|
75
|
+
//
|
|
76
|
+
// Everything is optional. With no rail this returns a no-op, so hosts that lay
|
|
77
|
+
// the framework out themselves (see embed-test.html) are unaffected.
|
|
78
|
+
export function attachRail({ rail, toggle, shell = rail?.parentElement, storage = safeStorage() } = {}) {
|
|
79
|
+
if (!rail || !shell) {
|
|
80
|
+
// No rail to resolve in this document: --pf-rail-w still defaults to 288px
|
|
81
|
+
// from tokens.css, but nothing is reserving that space, so anything that
|
|
82
|
+
// reads the token to centre itself against the rail (app.css's
|
|
83
|
+
// #pf-pick-banner) would otherwise sit 144px off-centre for no reason.
|
|
84
|
+
// Zero is the truth here — a legacy id-only page or a custom host running
|
|
85
|
+
// ?pickserver with no #panel/elements.rail hits this path.
|
|
86
|
+
const root = document.documentElement;
|
|
87
|
+
root.style.setProperty("--pf-rail-w", "0px");
|
|
88
|
+
return { detach: () => root.style.removeProperty("--pf-rail-w") };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const root = document.documentElement;
|
|
92
|
+
const shellBox = () => shell.getBoundingClientRect();
|
|
93
|
+
const shellWidth = () => shellBox().width;
|
|
94
|
+
let state = readRailPref(storage, shellWidth());
|
|
95
|
+
// Captured before the first apply() mutates the toggle, so detach() can
|
|
96
|
+
// hand back a plain, unwired button rather than a dead "Show controls" one.
|
|
97
|
+
// innerHTML (not textContent) so a host's original content - markup, not
|
|
98
|
+
// just text - genuinely round-trips; the icon apply() writes is markup too.
|
|
99
|
+
const toggleOriginal = toggle ? { html: toggle.innerHTML, title: toggle.title } : null;
|
|
100
|
+
// Built once here rather than inside apply() (which reruns on every resize/
|
|
101
|
+
// key/drag tick); apply() only ever swaps toggleChevron's `d`.
|
|
102
|
+
let toggleChevron = null;
|
|
103
|
+
if (toggle) {
|
|
104
|
+
const { svg, chevron } = buildToggleIcon();
|
|
105
|
+
toggle.replaceChildren(svg);
|
|
106
|
+
toggleChevron = chevron;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const seam = document.createElement("div");
|
|
110
|
+
seam.className = "pf-rail-seam";
|
|
111
|
+
seam.setAttribute("role", "separator");
|
|
112
|
+
seam.setAttribute("aria-orientation", "vertical");
|
|
113
|
+
seam.setAttribute("aria-label", "Resize controls");
|
|
114
|
+
seam.setAttribute("aria-valuemin", "0");
|
|
115
|
+
seam.tabIndex = 0;
|
|
116
|
+
seam.append(document.createElement("span")); // the hover/focus affordance
|
|
117
|
+
rail.before(seam);
|
|
118
|
+
|
|
119
|
+
// shellW lets a caller that already measured the shell this frame (the
|
|
120
|
+
// pointermove handler) thread that width through instead of forcing a
|
|
121
|
+
// second synchronous layout here — apply() writes --pf-rail-w onto :root
|
|
122
|
+
// just below, so any un-measured read after that point is a style+layout
|
|
123
|
+
// flush, not a cached value.
|
|
124
|
+
function apply({ persist = false, shellW } = {}) {
|
|
125
|
+
const sw = shellW ?? shellWidth();
|
|
126
|
+
const width = state.collapsed ? 0 : clampRailWidth(state.width, sw);
|
|
127
|
+
// Written on :root, not the rail/shell, so body-appended overlays (the
|
|
128
|
+
// pick banner, the ?debug overlay) inherit it — see spec §4.4. This
|
|
129
|
+
// assumes ONE rail per document: attachRail is written for a single
|
|
130
|
+
// instance, and mounting two on one page would fight over --pf-rail-w
|
|
131
|
+
// (both write :root; whichever's detach() runs last wins, clearing the
|
|
132
|
+
// survivor's width too). README's "multiple mounts" claim is about
|
|
133
|
+
// multiple mount() calls in general (e.g. cross-fade swaps), which is
|
|
134
|
+
// fine as long as at most one has a resolvable rail at a time.
|
|
135
|
+
root.style.setProperty("--pf-rail-w", `${width}px`);
|
|
136
|
+
rail.toggleAttribute("inert", state.collapsed);
|
|
137
|
+
seam.toggleAttribute("data-collapsed", state.collapsed);
|
|
138
|
+
seam.setAttribute("aria-valuenow", String(width));
|
|
139
|
+
seam.setAttribute("aria-valuemax", String(railMaxWidth(sw)));
|
|
140
|
+
if (toggle) {
|
|
141
|
+
toggleChevron?.setAttribute("d", state.collapsed ? CHEVRON_RAIL_COLLAPSED : CHEVRON_RAIL_OPEN);
|
|
142
|
+
const label = state.collapsed ? "Show controls" : "Hide controls";
|
|
143
|
+
toggle.setAttribute("aria-expanded", String(!state.collapsed));
|
|
144
|
+
toggle.setAttribute("aria-label", label);
|
|
145
|
+
toggle.title = label;
|
|
146
|
+
toggle.classList.toggle("on", state.collapsed);
|
|
147
|
+
}
|
|
148
|
+
if (persist) writeRailPref(state, storage);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- discrete changes: animate, and commit immediately ---
|
|
152
|
+
// The same "transient, debounced" flag suppresses the width transition for
|
|
153
|
+
// both a held arrow-key repeat and a window resize (below) — either can
|
|
154
|
+
// fire several times in a burst, and an animated width would fight the
|
|
155
|
+
// last one before the previous animation finishes.
|
|
156
|
+
let keyTimer = 0;
|
|
157
|
+
function settleKeys() {
|
|
158
|
+
clearTimeout(keyTimer);
|
|
159
|
+
shell.removeAttribute("data-pf-key-resizing");
|
|
160
|
+
}
|
|
161
|
+
function markTransientResize() {
|
|
162
|
+
shell.toggleAttribute("data-pf-key-resizing", true);
|
|
163
|
+
clearTimeout(keyTimer);
|
|
164
|
+
keyTimer = setTimeout(settleKeys, KEY_SETTLE_MS);
|
|
165
|
+
}
|
|
166
|
+
function commit(next) {
|
|
167
|
+
settleKeys(); // a discrete change interrupting a key repeat animates normally
|
|
168
|
+
state = next;
|
|
169
|
+
apply({ persist: true });
|
|
170
|
+
}
|
|
171
|
+
const toggleCollapsed = () => commit({ collapsed: !state.collapsed, width: state.width });
|
|
172
|
+
|
|
173
|
+
// --- keyboard: move the SEPARATOR (standard role="separator" semantics), so
|
|
174
|
+
// ArrowLeft widens a right-hand rail. Arrows clamp at the minimum and never
|
|
175
|
+
// collapse; Enter/Space is the collapse gesture.
|
|
176
|
+
function onKeyDown(e) {
|
|
177
|
+
// Cmd/Alt/Ctrl+Arrow are browser/OS reserved (back, tab switch, ...); don't
|
|
178
|
+
// eat them just because the seam happens to hold focus.
|
|
179
|
+
if (e.metaKey || e.altKey || e.ctrlKey) return;
|
|
180
|
+
// Enter/Space is the deliberate reopen gesture (below); arrows must not
|
|
181
|
+
// also reopen a collapsed rail — that would clamp it to the 240px minimum
|
|
182
|
+
// and silently discard the remembered width, and "narrower" reopening the
|
|
183
|
+
// rail at all is backwards.
|
|
184
|
+
if (state.collapsed && (e.key === "ArrowLeft" || e.key === "ArrowRight")) return;
|
|
185
|
+
const step = e.shiftKey ? KEY_STEP_SHIFT : KEY_STEP;
|
|
186
|
+
// Re-clamp on READ rather than reconciling state.width on resize: a width
|
|
187
|
+
// that no longer fits the current shell is still the user's preference and
|
|
188
|
+
// should come back if the window grows again, so only the transient value
|
|
189
|
+
// used for this keypress is clamped.
|
|
190
|
+
const from = state.collapsed ? 0 : clampRailWidth(state.width, shellWidth());
|
|
191
|
+
let width;
|
|
192
|
+
switch (e.key) {
|
|
193
|
+
case "ArrowLeft": width = from + step; break;
|
|
194
|
+
case "ArrowRight": width = from - step; break;
|
|
195
|
+
case "Home": case "End": {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
// Route through commit() — the path onDoubleClick uses — so these
|
|
198
|
+
// jumps animate. Only a debounced arrow-key repeat (below) suppresses
|
|
199
|
+
// the width transition.
|
|
200
|
+
commit({
|
|
201
|
+
collapsed: false,
|
|
202
|
+
width: e.key === "Home" ? RAIL_MIN_WIDTH : railMaxWidth(shellWidth()),
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
case "Enter": case " ": e.preventDefault(); toggleCollapsed(); return;
|
|
207
|
+
default: return;
|
|
208
|
+
}
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
state = { collapsed: false, width: clampRailWidth(width, shellWidth()) };
|
|
211
|
+
shell.toggleAttribute("data-pf-key-resizing", true);
|
|
212
|
+
clearTimeout(keyTimer);
|
|
213
|
+
keyTimer = setTimeout(() => {
|
|
214
|
+
shell.removeAttribute("data-pf-key-resizing");
|
|
215
|
+
writeRailPref(state, storage);
|
|
216
|
+
}, KEY_SETTLE_MS);
|
|
217
|
+
apply();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const onDoubleClick = () => commit({ collapsed: false, width: RAIL_DEFAULT_WIDTH });
|
|
221
|
+
const onToggleClick = () => toggleCollapsed();
|
|
222
|
+
|
|
223
|
+
// --- drag ---
|
|
224
|
+
let grabOffset = 0;
|
|
225
|
+
function onPointerDown(e) {
|
|
226
|
+
if (e.button !== 0) return;
|
|
227
|
+
// Stacked layout: the rail is under the viewer, so there is no vertical seam
|
|
228
|
+
// to drag (chrome.css hides it). The toggle still works.
|
|
229
|
+
if (window.innerWidth < RAIL_NARROW_BREAKPOINT) return;
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
// setPointerCapture is load-bearing: without it the pointer crosses into the
|
|
232
|
+
// viewer (an iframe, in the cloud editor) whose document eats the move
|
|
233
|
+
// events, and the drag dies the moment it reaches the thing being resized.
|
|
234
|
+
seam.setPointerCapture?.(e.pointerId);
|
|
235
|
+
const box = seam.getBoundingClientRect();
|
|
236
|
+
// Where inside the 12px seam the grab landed, so the rail edge doesn't jump.
|
|
237
|
+
grabOffset = e.clientX - (box.left + box.width / 2);
|
|
238
|
+
shell.toggleAttribute("data-pf-dragging", true);
|
|
239
|
+
// Safety net: setPointerCapture is load-bearing (see above), but it is
|
|
240
|
+
// still an optional call — if it's unavailable or the browser fails to
|
|
241
|
+
// honour it, the pointerup can land on the canvas instead of the seam,
|
|
242
|
+
// onPointerUp never runs, data-pf-dragging is stuck forever, and
|
|
243
|
+
// chrome.css's [data-pf-dragging] .pf-stage { pointer-events: none } dead-
|
|
244
|
+
// locks the viewer with no recovery. Also listening on window guarantees
|
|
245
|
+
// the drag always terminates regardless of where the pointer ends up.
|
|
246
|
+
// Bound only for the duration of a drag (removed in onPointerUp/detach)
|
|
247
|
+
// so there is never a permanently-bound window listener.
|
|
248
|
+
window.addEventListener("pointerup", onPointerUp);
|
|
249
|
+
window.addEventListener("pointercancel", onPointerUp);
|
|
250
|
+
}
|
|
251
|
+
function onPointerMove(e) {
|
|
252
|
+
if (!shell.hasAttribute("data-pf-dragging")) return;
|
|
253
|
+
// Measure the shell once per move and thread it through resolveRailDrag
|
|
254
|
+
// and apply(), instead of re-measuring after apply() has already mutated
|
|
255
|
+
// :root's style (which would force a synchronous style+layout flush).
|
|
256
|
+
const box = shellBox();
|
|
257
|
+
const railX = box.right - (e.clientX - grabOffset);
|
|
258
|
+
const next = resolveRailDrag(railX, state, box.width);
|
|
259
|
+
if (next === state) return; // unchanged — skip the redundant DOM writes
|
|
260
|
+
state = next;
|
|
261
|
+
apply({ shellW: box.width });
|
|
262
|
+
}
|
|
263
|
+
function onPointerUp(e) {
|
|
264
|
+
// Guards against running end-of-drag logic twice for one gesture: whichever
|
|
265
|
+
// of the seam's own pointerup and the window safety-net fires first clears
|
|
266
|
+
// the attribute, so the second (if it fires at all) is a no-op.
|
|
267
|
+
if (!shell.hasAttribute("data-pf-dragging")) return;
|
|
268
|
+
seam.releasePointerCapture?.(e.pointerId);
|
|
269
|
+
shell.removeAttribute("data-pf-dragging");
|
|
270
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
271
|
+
window.removeEventListener("pointercancel", onPointerUp);
|
|
272
|
+
apply({ persist: true });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
seam.addEventListener("pointerdown", onPointerDown);
|
|
276
|
+
seam.addEventListener("pointermove", onPointerMove);
|
|
277
|
+
seam.addEventListener("pointerup", onPointerUp);
|
|
278
|
+
seam.addEventListener("pointercancel", onPointerUp);
|
|
279
|
+
seam.addEventListener("keydown", onKeyDown);
|
|
280
|
+
seam.addEventListener("dblclick", onDoubleClick);
|
|
281
|
+
toggle?.addEventListener("click", onToggleClick);
|
|
282
|
+
// A window resize can invalidate the clamp (max is half the shell). Narrowing
|
|
283
|
+
// the window with a maxed-out rail can genuinely change --pf-rail-w, and
|
|
284
|
+
// without suppression that change would animate — fighting the resize with
|
|
285
|
+
// a trailing 150ms transition and an extra WebGL buffer reallocation.
|
|
286
|
+
const onResize = () => {
|
|
287
|
+
markTransientResize();
|
|
288
|
+
apply();
|
|
289
|
+
};
|
|
290
|
+
window.addEventListener("resize", onResize);
|
|
291
|
+
|
|
292
|
+
apply();
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
detach: () => {
|
|
296
|
+
settleKeys();
|
|
297
|
+
seam.removeEventListener("pointerdown", onPointerDown);
|
|
298
|
+
seam.removeEventListener("pointermove", onPointerMove);
|
|
299
|
+
seam.removeEventListener("pointerup", onPointerUp);
|
|
300
|
+
seam.removeEventListener("pointercancel", onPointerUp);
|
|
301
|
+
seam.removeEventListener("keydown", onKeyDown);
|
|
302
|
+
seam.removeEventListener("dblclick", onDoubleClick);
|
|
303
|
+
toggle?.removeEventListener("click", onToggleClick);
|
|
304
|
+
window.removeEventListener("resize", onResize);
|
|
305
|
+
// In case detach() happens mid-drag, so the safety net doesn't outlive it.
|
|
306
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
307
|
+
window.removeEventListener("pointercancel", onPointerUp);
|
|
308
|
+
seam.remove();
|
|
309
|
+
shell.removeAttribute("data-pf-dragging");
|
|
310
|
+
rail.removeAttribute("inert");
|
|
311
|
+
root.style.removeProperty("--pf-rail-w");
|
|
312
|
+
if (toggle) {
|
|
313
|
+
toggle.innerHTML = toggleOriginal.html;
|
|
314
|
+
toggle.title = toggleOriginal.title;
|
|
315
|
+
toggle.removeAttribute("aria-expanded");
|
|
316
|
+
toggle.removeAttribute("aria-label");
|
|
317
|
+
toggle.classList.remove("on");
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
package/src/framework/tokens.css
CHANGED
|
@@ -7,7 +7,19 @@
|
|
|
7
7
|
--pf-text: oklch(0.985 0 0); --pf-text-strong: oklch(1 0 0); --pf-text-2: oklch(0.92 0.004 286.32);
|
|
8
8
|
--pf-muted: oklch(0.705 0.015 286.067); --pf-muted-2: oklch(0.80 0.01 286); --pf-status: oklch(0.705 0.015 286.067); --pf-hint: oklch(0.552 0.016 285.938);
|
|
9
9
|
--pf-accent: #3f7bf0; --pf-accent-soft: #26314a; --pf-on-accent: #fff; --pf-input-bg: oklch(0.165 0.009 262); --pf-err: oklch(0.704 0.191 22.216);
|
|
10
|
-
--pf-
|
|
10
|
+
--pf-sans: "Geist Variable", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
11
|
+
--pf-mono: "Geist Mono Variable", ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
12
|
+
/* Layout + shape, matching partforge-cloud's control scale (--r-control /
|
|
13
|
+
--r-pill) so a rail nested in the cloud editor reads as one product. */
|
|
14
|
+
--pf-rail-w: 288px; --pf-rail-pad: 14px;
|
|
15
|
+
--pf-radius-control: 7px; --pf-radius-pill: 12px;
|
|
16
|
+
/* Floating pills: cloud's --shadow-editor. Larger and near-even, so a pill
|
|
17
|
+
doesn't pool weight at its bottom edge the way a downward shadow does. */
|
|
18
|
+
--pf-shadow-float: 0 0 6px rgb(0 0 0 / .04), 0 2px 14px rgb(0 0 0 / .072);
|
|
19
|
+
/* The rail is SET BACK: the viewer casts onto it, so this is inset on the
|
|
20
|
+
rail's left edge. An outer shadow would read as the rail floating above the
|
|
21
|
+
viewer — the opposite. Deeper in dark, where black reads weaker. */
|
|
22
|
+
--pf-shadow-rail: inset 9px 0 16px -10px rgb(0 0 0 / .38);
|
|
11
23
|
}
|
|
12
24
|
:root[data-theme="light"] {
|
|
13
25
|
color-scheme: light;
|
|
@@ -15,4 +27,5 @@
|
|
|
15
27
|
--pf-text: oklch(0.141 0.005 285.823); --pf-text-strong: oklch(0.10 0.005 285.823); --pf-text-2: oklch(0.21 0.006 285.885);
|
|
16
28
|
--pf-muted: oklch(0.552 0.016 285.938); --pf-muted-2: oklch(0.44 0.015 285.9); --pf-status: oklch(0.552 0.016 285.938); --pf-hint: oklch(0.705 0.015 286.067);
|
|
17
29
|
--pf-accent: #1f5bd6; --pf-accent-soft: #e6edfc; --pf-on-accent: #fff; --pf-input-bg: #ffffff; --pf-err: oklch(0.577 0.245 27.325);
|
|
30
|
+
--pf-shadow-rail: inset 9px 0 14px -10px rgb(0 0 0 / .12);
|
|
18
31
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Metric registry: name → how to pull the value out of facts, whether a failure
|
|
2
|
+
// is a hard gate or a warning, and the diagnostics attached to a non-pass check:
|
|
3
|
+
// `hint` (required — the report contract promises one on every fail/warn),
|
|
4
|
+
// `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
|
|
5
|
+
// [x,y,z] source). `manifoldOnly` facts are null on OCCT parts.
|
|
6
|
+
//
|
|
7
|
+
// This lives in framework/ rather than testing/ deliberately: the set of legal
|
|
8
|
+
// `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
|
|
9
|
+
// verify runner (testing) and the linter (partforge/lint) must agree on. Keeping
|
|
10
|
+
// it here lets the linter import the vocabulary without reaching measure.js or
|
|
11
|
+
// jobs.js, which pull in the geometry kernels. This module must stay import-free.
|
|
12
|
+
export const SUBPART_METRICS = {
|
|
13
|
+
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
14
|
+
hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
|
|
15
|
+
watertight: { kind: "gate", manifoldOnly: true, extract: (s) => s.watertight,
|
|
16
|
+
hint: "a boolean produced an open shell — check for coplanar faces or a cut that exactly grazes a surface",
|
|
17
|
+
pattern: "boolean-not-watertight" },
|
|
18
|
+
volume: { kind: "gate", extract: (s) => s.volume,
|
|
19
|
+
hint: "solid volume is out of range — a feature is missing, doubled, or a governing parameter is mis-scaled" },
|
|
20
|
+
surfaceArea: { kind: "gate", extract: (s) => s.surfaceArea,
|
|
21
|
+
hint: "surface area is out of range — detail features (facets, ribs, textures) are missing or doubled" },
|
|
22
|
+
triangleCount: { kind: "gate", extract: (s) => s.triangleCount,
|
|
23
|
+
hint: "triangle count is out of range — tessellation quality or feature count changed unexpectedly" },
|
|
24
|
+
bbox: { kind: "gate", extract: (s) => s.bbox,
|
|
25
|
+
hint: "bounding box is out of range — check the governing dimensions and the part's orientation" },
|
|
26
|
+
centerOfMass: { kind: "gate", extract: (s) => s.centerOfMass,
|
|
27
|
+
hint: "center of mass is outside the expected region — mass is distributed differently than intended; check feature placement or a mis-scaled sub-part" },
|
|
28
|
+
boundsMin: { kind: "gate", extract: (s) => s.bounds?.min,
|
|
29
|
+
hint: "the low corner is out of range — the part is positioned or oriented differently than expected" },
|
|
30
|
+
boundsMax: { kind: "gate", extract: (s) => s.bounds?.max,
|
|
31
|
+
hint: "the high corner is out of range — the part is positioned or oriented differently than expected" },
|
|
32
|
+
minWall: { kind: "warn", extract: (s) => s.minWall,
|
|
33
|
+
hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
|
|
34
|
+
pattern: "minwall-sliver-triangles",
|
|
35
|
+
locate: (s) => s.minWallAt },
|
|
36
|
+
};
|
|
37
|
+
export const VIEW_METRICS = {
|
|
38
|
+
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
|
|
39
|
+
hint: "the assembled view exceeds its size limit — shrink the assembly or pick a process with a larger bed" },
|
|
40
|
+
volume: { kind: "gate", extract: (r) => r.aggregate.volume,
|
|
41
|
+
hint: "total assembly volume is out of range — a sub-part is missing, doubled, or mis-scaled" },
|
|
42
|
+
overlaps: { kind: "gate", extract: (r) => r.overlaps.length,
|
|
43
|
+
hint: "sub-parts interpenetrate near the reported location — adjust placement or add clearance in derive()",
|
|
44
|
+
locate: (r) => r.overlaps[0]?.location ?? null },
|
|
45
|
+
centerOfMass: { kind: "gate", extract: (r) => r.aggregate.centerOfMass,
|
|
46
|
+
hint: "the assembly's center of mass is outside the expected region — a sub-part is mis-placed or mis-scaled" },
|
|
47
|
+
boundsMin: { kind: "gate", extract: (r) => r.aggregate.bounds?.min,
|
|
48
|
+
hint: "the assembly's low corner is out of range — check placement or orientation" },
|
|
49
|
+
boundsMax: { kind: "gate", extract: (r) => r.aggregate.bounds?.max,
|
|
50
|
+
hint: "the assembly's high corner is out of range — check placement or orientation" },
|
|
51
|
+
};
|
package/src/framework/worker.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Each instance lazily imports only its own backend, so OCCT's ~11 MB WASM loads
|
|
4
4
|
// only in the worker that needs it, and only on first use.
|
|
5
5
|
import { handle } from "./jobs.js";
|
|
6
|
+
import { lintPart } from "../lint.js";
|
|
6
7
|
|
|
7
8
|
async function manifoldKernels() {
|
|
8
9
|
const [{ default: Module }, { createManifoldKernel }] = await Promise.all([
|
|
@@ -48,6 +49,14 @@ export function runWorker(part) {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
self.onmessage = async (e) => {
|
|
52
|
+
// Lint is geometry-free by construction, so answer it before touching — or
|
|
53
|
+
// booting — a kernel. handle() in jobs.js takes an already-booted kernel, and
|
|
54
|
+
// the branches below await that boot, so routing lint through them would drag
|
|
55
|
+
// in OCCT's ~11 MB WASM to run a check that never calls the kernel at all.
|
|
56
|
+
if (e.data?.type === "lint") {
|
|
57
|
+
postMessage({ type: "lint-report", report: lintPart(part, { params: e.data.params }) });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
51
60
|
let kernel;
|
|
52
61
|
if (backend === "manifold") {
|
|
53
62
|
await booting;
|
package/src/lint.js
ADDED
package/src/testing/verify.js
CHANGED
|
@@ -5,52 +5,11 @@ import { resolveProfile } from "./dfm-profiles.js";
|
|
|
5
5
|
import { expandCases } from "./cases.js";
|
|
6
6
|
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../framework/param-deps.js";
|
|
7
7
|
import { resolveParams } from "../framework/jobs.js";
|
|
8
|
+
import { SUBPART_METRICS, VIEW_METRICS } from "../framework/verify-metrics.js";
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
// `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
|
|
13
|
-
// [x,y,z] source). `manifoldOnly` facts are null on OCCT parts.
|
|
14
|
-
export const SUBPART_METRICS = {
|
|
15
|
-
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
16
|
-
hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
|
|
17
|
-
watertight: { kind: "gate", manifoldOnly: true, extract: (s) => s.watertight,
|
|
18
|
-
hint: "a boolean produced an open shell — check for coplanar faces or a cut that exactly grazes a surface",
|
|
19
|
-
pattern: "boolean-not-watertight" },
|
|
20
|
-
volume: { kind: "gate", extract: (s) => s.volume,
|
|
21
|
-
hint: "solid volume is out of range — a feature is missing, doubled, or a governing parameter is mis-scaled" },
|
|
22
|
-
surfaceArea: { kind: "gate", extract: (s) => s.surfaceArea,
|
|
23
|
-
hint: "surface area is out of range — detail features (facets, ribs, textures) are missing or doubled" },
|
|
24
|
-
triangleCount: { kind: "gate", extract: (s) => s.triangleCount,
|
|
25
|
-
hint: "triangle count is out of range — tessellation quality or feature count changed unexpectedly" },
|
|
26
|
-
bbox: { kind: "gate", extract: (s) => s.bbox,
|
|
27
|
-
hint: "bounding box is out of range — check the governing dimensions and the part's orientation" },
|
|
28
|
-
centerOfMass: { kind: "gate", extract: (s) => s.centerOfMass,
|
|
29
|
-
hint: "center of mass is outside the expected region — mass is distributed differently than intended; check feature placement or a mis-scaled sub-part" },
|
|
30
|
-
boundsMin: { kind: "gate", extract: (s) => s.bounds?.min,
|
|
31
|
-
hint: "the low corner is out of range — the part is positioned or oriented differently than expected" },
|
|
32
|
-
boundsMax: { kind: "gate", extract: (s) => s.bounds?.max,
|
|
33
|
-
hint: "the high corner is out of range — the part is positioned or oriented differently than expected" },
|
|
34
|
-
minWall: { kind: "warn", extract: (s) => s.minWall,
|
|
35
|
-
hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
|
|
36
|
-
pattern: "minwall-sliver-triangles",
|
|
37
|
-
locate: (s) => s.minWallAt },
|
|
38
|
-
};
|
|
39
|
-
export const VIEW_METRICS = {
|
|
40
|
-
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
|
|
41
|
-
hint: "the assembled view exceeds its size limit — shrink the assembly or pick a process with a larger bed" },
|
|
42
|
-
volume: { kind: "gate", extract: (r) => r.aggregate.volume,
|
|
43
|
-
hint: "total assembly volume is out of range — a sub-part is missing, doubled, or mis-scaled" },
|
|
44
|
-
overlaps: { kind: "gate", extract: (r) => r.overlaps.length,
|
|
45
|
-
hint: "sub-parts interpenetrate near the reported location — adjust placement or add clearance in derive()",
|
|
46
|
-
locate: (r) => r.overlaps[0]?.location ?? null },
|
|
47
|
-
centerOfMass: { kind: "gate", extract: (r) => r.aggregate.centerOfMass,
|
|
48
|
-
hint: "the assembly's center of mass is outside the expected region — a sub-part is mis-placed or mis-scaled" },
|
|
49
|
-
boundsMin: { kind: "gate", extract: (r) => r.aggregate.bounds?.min,
|
|
50
|
-
hint: "the assembly's low corner is out of range — check placement or orientation" },
|
|
51
|
-
boundsMax: { kind: "gate", extract: (r) => r.aggregate.bounds?.max,
|
|
52
|
-
hint: "the assembly's high corner is out of range — check placement or orientation" },
|
|
53
|
-
};
|
|
10
|
+
// Re-exported for backwards compatibility: the registries moved to framework/ so
|
|
11
|
+
// the linter can read the metric vocabulary without importing a geometry kernel.
|
|
12
|
+
export { SUBPART_METRICS, VIEW_METRICS };
|
|
54
13
|
|
|
55
14
|
// An expectation is a bare expression (string/number/boolean) or { expr, hint }.
|
|
56
15
|
const normalizeExpectation = (spec) =>
|