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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.53.0",
3
+ "version": "0.55.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- const GIZMO_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
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
  /**
@@ -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
- const measured = measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true });
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
- post({ type: "report", ...report });
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 });