partforge 0.53.0 → 0.55.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/docs/AUTHORING-PARTS.md +12 -2
- package/package.json +1 -1
- package/src/framework/app.css +27 -2
- package/src/framework/cutaway-controls.js +4 -27
- package/src/framework/cutaway-gizmo-scene.js +3 -1
- package/src/framework/jobs.js +75 -2
- package/src/framework/measure/dim3-place.js +455 -0
- package/src/framework/measure/dim3-scene.js +438 -0
- package/src/framework/measure/feature-dims.js +258 -0
- package/src/framework/measure/measure-controls.js +110 -0
- package/src/framework/measure/measure-mode.js +546 -0
- package/src/framework/measure/param-link.js +32 -0
- package/src/framework/measure/pins.js +36 -0
- package/src/framework/mount.js +58 -5
- package/src/framework/oracle/match.js +263 -0
- package/src/framework/oracle/measure.js +5 -1
- package/src/framework/oracle/silhouette.js +146 -0
- package/src/framework/panel/render.js +41 -0
- package/src/framework/selection/drag-tracker.js +39 -0
- package/src/framework/selection/feature-highlight.js +99 -0
- package/src/framework/selection/hover.js +14 -90
- package/src/framework/selection/index.js +1 -0
- package/src/framework/selection/pick.js +11 -37
- package/src/framework/teardown.js +29 -0
- package/src/framework/viewer.js +25 -1
- package/src/testing.js +5 -0
- package/types/index.d.ts +16 -0
- package/types/testing.d.ts +116 -1
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -487,6 +487,12 @@ if (p.drain > 0) s = s.cut(k.cylinder({ r: d.drainR, h: p.floor + 4 }).at([0, 0,
|
|
|
487
487
|
- Names should describe intent ("Drainage hole", not "cylinder2"); make them
|
|
488
488
|
unique per sub-part unless you specifically want the merge behavior.
|
|
489
489
|
|
|
490
|
+
Labels do double duty in the viewer: the hover tooltip names the feature, and
|
|
491
|
+
**measurement mode** (the ruler button in the viewbar) measures it — a labeled
|
|
492
|
+
hole reads ⌀ + depth, a labeled face reads its extents, and a click pins that
|
|
493
|
+
dimension so it tracks parameter changes live. Label the features a user would
|
|
494
|
+
want to measure; unlabeled geometry still measures as its bounding box.
|
|
495
|
+
|
|
490
496
|
### Caching & determinism
|
|
491
497
|
|
|
492
498
|
The preview kernel memoizes geometry by content hash, so editing a parameter only
|
|
@@ -1230,7 +1236,7 @@ stylesheet). `mount` looks up these element IDs:
|
|
|
1230
1236
|
| `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` and opens the resolved default (see the "Which view the viewer opens on" rule above) |
|
|
1231
1237
|
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
1232
1238
|
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
1233
|
-
| `#viewbar` with `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
1239
|
+
| `#viewbar` with `#reframe` / `#cutaway` / `#measure` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
1234
1240
|
| `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
|
|
1235
1241
|
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme` |
|
|
1236
1242
|
|
|
@@ -1296,7 +1302,10 @@ pane's pixel size:
|
|
|
1296
1302
|
`null` when the runtime is disposed or nothing is built/visible yet — it never throws.
|
|
1297
1303
|
`hideGrid: false` keeps the floor grid so the capture matches the on-screen look
|
|
1298
1304
|
exactly. The live view is untouched: the camera never moves, and lights/grid/render
|
|
1299
|
-
target are restored after the render.
|
|
1305
|
+
target are restored after the render. Measurement-mode dimensions render directly
|
|
1306
|
+
in the scene, so a dimensioned capture needs no special handling — enable measure
|
|
1307
|
+
mode (`runtime.measure.setEnabled(true)`) and call `captureCurrent()`; the dims are
|
|
1308
|
+
just part of the rendered frame.
|
|
1300
1309
|
- `runtime.captureViews(viewNames) → [{ view, dataUrl }]` — the canonical-angle
|
|
1301
1310
|
counterpart (fixed poses, framed to the visible assembly, 1024², grid hidden). Sized
|
|
1302
1311
|
for feeding a vision model, not for display; use `captureCurrent` for showcase images.
|
|
@@ -1370,6 +1379,7 @@ mount(part, {
|
|
|
1370
1379
|
chrome: {
|
|
1371
1380
|
reframe,
|
|
1372
1381
|
cutaway,
|
|
1382
|
+
measure,
|
|
1373
1383
|
theme,
|
|
1374
1384
|
railToggle,
|
|
1375
1385
|
},
|
package/package.json
CHANGED
package/src/framework/app.css
CHANGED
|
@@ -313,6 +313,9 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
313
313
|
#viewbar .pf-cutaway-actions { display: flex; gap: 4px; }
|
|
314
314
|
#viewbar .pf-cutaway-actions[hidden] { display: none; }
|
|
315
315
|
#viewbar .pf-cutaway-actions button { width: auto; min-width: 56px; padding: 0 8px; }
|
|
316
|
+
#viewbar .pf-measure-actions { display: flex; gap: 4px; }
|
|
317
|
+
#viewbar .pf-measure-actions[hidden] { display: none; }
|
|
318
|
+
#viewbar .pf-measure-actions button { width: auto; min-width: 56px; padding: 0 8px; }
|
|
316
319
|
#viewbar button:disabled { opacity: .38; cursor: not-allowed; }
|
|
317
320
|
#viewbar button:disabled:hover { color: var(--pf-muted-2); background: transparent; }
|
|
318
321
|
#viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
@@ -327,10 +330,21 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
327
330
|
@media (max-width: 360px) {
|
|
328
331
|
#viewbar { gap: 3px; }
|
|
329
332
|
#viewbar button { width: 30px; height: 30px; font-size: 13px; }
|
|
330
|
-
#viewbar .pf-cutaway-actions { gap: 3px; }
|
|
331
|
-
#viewbar .pf-cutaway-actions button { min-width: 44px; padding: 0 6px; }
|
|
333
|
+
#viewbar .pf-cutaway-actions, #viewbar .pf-measure-actions { gap: 3px; }
|
|
334
|
+
#viewbar .pf-cutaway-actions button, #viewbar .pf-measure-actions button { min-width: 44px; padding: 0 6px; }
|
|
332
335
|
}
|
|
333
336
|
|
|
337
|
+
/* ---- measurement mode -----------------------------------------------------
|
|
338
|
+
Dimensions render in-scene — three.js objects placed alongside the part in
|
|
339
|
+
the viewer, not a DOM/SVG overlay. This CSS block only styles the viewbar
|
|
340
|
+
chrome: the unit tag shown next to the measure controls. */
|
|
341
|
+
.pf-measure-unit {
|
|
342
|
+
font-family: var(--pf-mono); font-size: 10px; color: var(--pf-muted);
|
|
343
|
+
align-self: center; user-select: none; cursor: pointer;
|
|
344
|
+
}
|
|
345
|
+
#viewbar .pf-measure-actions button.pf-measure-unit { min-width: auto; padding: 0 6px; }
|
|
346
|
+
.pf-measure-unit:hover { color: var(--pf-text-2); }
|
|
347
|
+
|
|
334
348
|
/* animation transport bar (placement: chrome.css's .pf-anim-bar). APPEARANCE
|
|
335
349
|
is ungated for the same reason as #viewbar above. */
|
|
336
350
|
.pf-anim-bar {
|
|
@@ -582,3 +596,14 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
582
596
|
white-space: pre-wrap; word-break: break-word; display: none;
|
|
583
597
|
}
|
|
584
598
|
#pf-pick-toast.show { display: block; }
|
|
599
|
+
|
|
600
|
+
/* dimension->control reveal: a decaying accent pulse that hands off to the
|
|
601
|
+
focused control's own focus ring */
|
|
602
|
+
/* outline (not box-shadow) so outline-offset can space the ring away from
|
|
603
|
+
the control without touching layout */
|
|
604
|
+
.pf-param-flash { animation: pf-param-flash 900ms ease-out; outline-offset: 5px; border-radius: 6px; }
|
|
605
|
+
@keyframes pf-param-flash {
|
|
606
|
+
0% { outline: 3px solid color-mix(in oklab, var(--pf-accent) 70%, transparent); }
|
|
607
|
+
100% { outline: 3px solid transparent; }
|
|
608
|
+
}
|
|
609
|
+
@media (prefers-reduced-motion: reduce) { .pf-param-flash { animation: none; } }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { attachButtonTooltips } from "./tooltip.js";
|
|
2
|
+
import { runCleanupSteps, captureAttributes, restoreAttributes } from "./teardown.js";
|
|
2
3
|
|
|
3
4
|
const UNSUPPORTED_TITLE = "Cutaway requires a stencil-capable WebGL context";
|
|
4
5
|
const BUTTON_ATTRIBUTES = [
|
|
@@ -12,17 +13,6 @@ const BUTTON_ATTRIBUTES = [
|
|
|
12
13
|
|
|
13
14
|
const noop = () => {};
|
|
14
15
|
|
|
15
|
-
function runCleanupSteps(steps) {
|
|
16
|
-
const errors = [];
|
|
17
|
-
for (const step of steps) {
|
|
18
|
-
try { step(); } catch (error) { errors.push(error); }
|
|
19
|
-
}
|
|
20
|
-
if (errors.length === 1) throw errors[0];
|
|
21
|
-
if (errors.length > 1) {
|
|
22
|
-
throw new AggregateError(errors, "cutaway control cleanup failed");
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
function actionButton(label, title) {
|
|
27
17
|
const button = document.createElement("button");
|
|
28
18
|
button.type = "button";
|
|
@@ -32,23 +22,9 @@ function actionButton(label, title) {
|
|
|
32
22
|
return button;
|
|
33
23
|
}
|
|
34
24
|
|
|
35
|
-
function captureAttributes(element, names) {
|
|
36
|
-
return new Map(names.map((name) => [name, {
|
|
37
|
-
present: element.hasAttribute(name),
|
|
38
|
-
value: element.getAttribute(name),
|
|
39
|
-
}]));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function restoreAttributes(element, attributes) {
|
|
43
|
-
for (const [name, { present, value }] of attributes) {
|
|
44
|
-
if (present) element.setAttribute(name, value);
|
|
45
|
-
else element.removeAttribute(name);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
25
|
// Wire the optional cutaway button to the viewer and create its contextual
|
|
50
26
|
// actions. Hosts that omit the primary button opt out of all DOM behavior.
|
|
51
|
-
export function attachCutawayControls(viewer, { cutaway: button } = {}, { tooltip } = {}) {
|
|
27
|
+
export function attachCutawayControls(viewer, { cutaway: button } = {}, { tooltip, escapeGuard } = {}) {
|
|
52
28
|
if (!button) return { reset: noop, detach: noop };
|
|
53
29
|
|
|
54
30
|
const canvas = viewer.domElement;
|
|
@@ -116,6 +92,7 @@ export function attachCutawayControls(viewer, { cutaway: button } = {}, { toolti
|
|
|
116
92
|
};
|
|
117
93
|
const onEscape = (event) => {
|
|
118
94
|
if (event.key !== "Escape" || !viewer.cutawayEnabled()) return;
|
|
95
|
+
if (escapeGuard?.()) return; // an inner lens (measure mode) claims this Escape
|
|
119
96
|
event.preventDefault();
|
|
120
97
|
disable();
|
|
121
98
|
};
|
|
@@ -149,7 +126,7 @@ export function attachCutawayControls(viewer, { cutaway: button } = {}, { toolti
|
|
|
149
126
|
() => restoreAttributes(button, hostButtonAttributes),
|
|
150
127
|
() => { button.disabled = hostButtonDisabled; },
|
|
151
128
|
() => button.classList.toggle("on", hostButtonOn),
|
|
152
|
-
]);
|
|
129
|
+
], "cutaway control cleanup failed");
|
|
153
130
|
},
|
|
154
131
|
};
|
|
155
132
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// +10: above the caps/highlight tier AND above measure mode's dims
|
|
5
|
+
// (dim3-scene renders at +2/+3) — an actively-dragged control outranks ink.
|
|
6
|
+
const GIZMO_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 10;
|
|
5
7
|
const HANDLE_HOVER_THICKNESS = 1.6;
|
|
6
8
|
|
|
7
9
|
/**
|
package/src/framework/jobs.js
CHANGED
|
@@ -9,6 +9,9 @@ import { safeName } from "./safe-name.js";
|
|
|
9
9
|
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
10
10
|
import { measure } from "./oracle/measure.js";
|
|
11
11
|
import { verify } from "./oracle/verify.js";
|
|
12
|
+
import { buildView } from "./oracle/build.js";
|
|
13
|
+
import { MATCH_VIEWS, rasterizeMeshMask, rasterizeRingsMask } from "./oracle/silhouette.js";
|
|
14
|
+
import { matchViews } from "./oracle/match.js";
|
|
12
15
|
|
|
13
16
|
// Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
|
|
14
17
|
// Backend-agnostic and part-agnostic: every part specific comes through `part`.
|
|
@@ -26,6 +29,67 @@ import { verify } from "./oracle/verify.js";
|
|
|
26
29
|
// preview generates stay quiet (no callback) to avoid flicker during slider drags.
|
|
27
30
|
const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
|
|
28
31
|
|
|
32
|
+
// One caller-supplied match target as a reference mask, or null when it is not one we
|
|
33
|
+
// can score. Every field here is untrusted wire data, so shape is checked rather than
|
|
34
|
+
// assumed — a target the caller got wrong is skipped, never thrown, so one bad entry
|
|
35
|
+
// cannot cost the others their scores (or the caller their geometry report).
|
|
36
|
+
// {kind: "profile", rings: [[[x,y], ...], ...]} — millimetres, so it carries scale
|
|
37
|
+
// {kind: "image", mask: {data, width, height}} — a photo, so it carries none
|
|
38
|
+
function referenceMask(target) {
|
|
39
|
+
if (target?.kind === "profile") return Array.isArray(target.rings) ? rasterizeRingsMask(target.rings) : null;
|
|
40
|
+
if (target?.kind === "image") {
|
|
41
|
+
const m = target.mask;
|
|
42
|
+
if (!m?.data || !(m.width > 0) || !(m.height > 0)) return null;
|
|
43
|
+
// mmPerPx is deliberately absent: a photograph has no millimetres, which is what
|
|
44
|
+
// keeps the scale-aware comparison off for an image target no matter what is asked.
|
|
45
|
+
return { data: m.data, width: m.width, height: m.height };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Score the part's six canonical silhouettes against each match target.
|
|
51
|
+
// `built` is the view's already-built sub-parts — the meshes are JS-owned, so this
|
|
52
|
+
// reads correctly after the kernel has been cleaned up.
|
|
53
|
+
//
|
|
54
|
+
// Total by construction: match scoring is an EXTRA on top of the geometric report, so
|
|
55
|
+
// any failure here omits `match` and leaves the report itself intact. A caller who
|
|
56
|
+
// asked for a score and got none can ask again; a caller who lost their measurement
|
|
57
|
+
// because a mask blew up has lost the thing they actually came for.
|
|
58
|
+
//
|
|
59
|
+
// The six mesh masks are rasterized ONCE and shared across every target — the targets
|
|
60
|
+
// are the cheap side of this (a couple of reference masks), the part is not.
|
|
61
|
+
function scoreMatchTargets(built, targets, onProgress) {
|
|
62
|
+
if (!targets?.length) return null;
|
|
63
|
+
try {
|
|
64
|
+
const meshes = built.map((b) => b.mesh);
|
|
65
|
+
const viewMasks = {};
|
|
66
|
+
for (const view of MATCH_VIEWS) viewMasks[view] = rasterizeMeshMask(meshes, view);
|
|
67
|
+
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const target of targets) {
|
|
70
|
+
try {
|
|
71
|
+
const reference = referenceMask(target);
|
|
72
|
+
if (!reference) continue;
|
|
73
|
+
// scaleAware is the CALLER's promise that both sides are in millimetres, and
|
|
74
|
+
// this is the caller: rings are mm and the mesh masks carry mmPerPx, so a
|
|
75
|
+
// profile target gets the absolute-size score (`iouScale`, contourDist in mm)
|
|
76
|
+
// while an image target gets the pose-normalized one.
|
|
77
|
+
const scoreOpts = { scaleAware: target.kind === "profile" };
|
|
78
|
+
const { best, views } = matchViews(viewMasks, reference, scoreOpts);
|
|
79
|
+
if (!best) continue; // nothing scoreable — a dropped target, never a zero score
|
|
80
|
+
const { delta, ...scores } = best;
|
|
81
|
+
out.push({ kind: target.kind, best: scores, views, delta: { view: best.view, ...delta } });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
onProgress(`match target skipped: ${String(err?.message || err)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out.length ? out : null;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
onProgress(`match scoring skipped: ${String(err?.message || err)}`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
29
93
|
export async function handle(kernel, part, msg, post, opts = {}) {
|
|
30
94
|
const isStale = opts.isStale ?? (() => false);
|
|
31
95
|
const onProgress = (phase) => post({ type: "progress", phase, jobId: msg.jobId });
|
|
@@ -147,7 +211,12 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
147
211
|
// true }` here is what makes the seed usable by any verify run, min-wall
|
|
148
212
|
// gated or not — the result says so itself (`measuredMinWall`), so this
|
|
149
213
|
// call and the seed cannot drift apart.
|
|
150
|
-
|
|
214
|
+
//
|
|
215
|
+
// The view is built HERE rather than inside measure, and handed down through
|
|
216
|
+
// `opts.built`, because optional match scoring needs the same meshes: one build
|
|
217
|
+
// feeds the measurement and the six silhouette rasterizations both.
|
|
218
|
+
const built = buildView(kernel, part, msg.view, msg.params ?? {});
|
|
219
|
+
const measured = measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true, built });
|
|
151
220
|
const report = {
|
|
152
221
|
measure: measured,
|
|
153
222
|
verify: verify(kernel, part, {
|
|
@@ -155,7 +224,11 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
155
224
|
seed: { params: msg.params ?? {}, result: measured },
|
|
156
225
|
}),
|
|
157
226
|
};
|
|
158
|
-
|
|
227
|
+
// `match` is present only when the caller asked for it AND something scored, so
|
|
228
|
+
// an inspect with no `matchTargets` answers on exactly the shape it always has.
|
|
229
|
+
const match = scoreMatchTargets(built, msg.matchTargets, onProgress);
|
|
230
|
+
if (match) report.match = match;
|
|
231
|
+
post({ type: "report", ...report }, match?.map((m) => m.delta.data.buffer) ?? []);
|
|
159
232
|
}
|
|
160
233
|
} catch (err) {
|
|
161
234
|
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId });
|