partforge 0.53.0 → 0.54.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.54.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
  /**
@@ -0,0 +1,455 @@
1
+ // Pure in-scene dimension placement (spec v2 §Placement + amendments).
2
+ // Everything works in the PARTS frame — the meshes' shared parent group
3
+ // (delivered geometry composed with pose matrices) — so the resulting drawing
4
+ // rides the pivot rotation and per-view recentring untouched. No DOM, no GL,
5
+ // no rendering objects: three's math classes only, so this runs under plain
6
+ // vitest.
7
+ //
8
+ // Placement does the expensive DISCOVERY only: anchor points (extreme-vertex
9
+ // scans, surface raycasts, plane snapping), side selection, dedupe and stagger
10
+ // lanes. Every display distance — standoff, surface gap, arrowheads, the
11
+ // overshoot past the dim line, leader length, text size — is screen-constant,
12
+ // so the final geometry depends on zoom and is assembled per frame by
13
+ // dim3-scene from the parametric records emitted here. That keeps zoom fully
14
+ // rebuild-free.
15
+ //
16
+ // Split in two so the orchestrator can score cheaply every frame and rebuild
17
+ // rarely: evaluateChoices() is dot-products + hysteresis over the previous
18
+ // choices; placeDims() does the discovery only when a choice flipped or the
19
+ // scene changed.
20
+ //
21
+ // Drawing contract (consumed by dim3-scene):
22
+ // { itemId, tier, pinned,
23
+ // dims: [{ pA, pB, baseA, baseB, ext, dir, lane, standoffScale,
24
+ // label: { text, value, x, y } }],
25
+ // diams: [{ rimA, rimB, du, dv, label }],
26
+ // leaders: [{ rim, dir, perp, label }] }
27
+ // All vectors are number[3] in the parts frame. A linear dim's line endpoints
28
+ // at display time are base± + ext·offset (offset chosen on-screen); pA/pB are
29
+ // the discovered surface-contact anchors its extension lines run from. A diam
30
+ // is the fixed line across a circle (rim to rim); a leader points at `rim`
31
+ // along `dir`.
32
+ import * as THREE from "three";
33
+
34
+
35
+ // --- discovery constants ------------------------------------------------------
36
+ // standoffNominal: the mm offset ASSUMED while discovering surface contacts
37
+ // (raycast origins) and extreme-vertex tie-break targets. Display standoff is
38
+ // screen-constant and lives in dim3-scene; discovery only needs a plausible
39
+ // out-of-the-part reference, and the contact points it finds barely depend on
40
+ // it.
41
+ export const standoffNominal = (modelSize) => Math.max(6, modelSize * 0.10);
42
+
43
+ // Display units. Values are ALWAYS mm internally (kernel units; label.value
44
+ // stays mm so control matching keeps working) — only the rendered text
45
+ // converts. Inches show 3 decimals: 0.001 in ≈ 0.0254 mm, in the same
46
+ // precision neighbourhood as the 0.01 mm display quantum.
47
+ export const UNITS = {
48
+ mm: { format: (v) => v.toFixed(2), suffix: " mm" },
49
+ in: { format: (v) => (v / 25.4).toFixed(3), suffix: " in" },
50
+ };
51
+ export const HYSTERESIS = 1.15; // challenger must beat the holder by 15%
52
+ export const FLIP_DEADBAND_DEG = 25; // cylinder ⌀ direction re-aim threshold
53
+
54
+ const AXES = [
55
+ new THREE.Vector3(1, 0, 0),
56
+ new THREE.Vector3(0, 1, 0),
57
+ new THREE.Vector3(0, 0, 1),
58
+ ];
59
+ const v3 = (a) => new THREE.Vector3(a[0], a[1], a[2]);
60
+
61
+ // --- duplicate-dimension suppression ------------------------------------------
62
+ // Two items measuring the same thing draw identical dims on top of each other:
63
+ // a hovered sub-part over the overall bounds (single-part apps), a hover over
64
+ // its own pin. The signature identifies "the same measurement" independent of
65
+ // item id/tier. Within one placeDims call the LATER item wins (pins carry the
66
+ // param pill the overall lacks); across the orchestrator's base/hover split,
67
+ // the hover pass hands the base items' sigs in as `suppress`.
68
+ export function specSig(spec) {
69
+ return JSON.stringify({ kind: spec.kind, values: spec.values, anchors: spec.anchors });
70
+ }
71
+
72
+ // --- stagger lanes ------------------------------------------------------------
73
+ // Dims extending the same outward direction stack at increasing standoff
74
+ // (drafting-style stacked dimension lines), so co-located labels stagger
75
+ // instead of overlapping. Lanes are per-placeDims-call and deterministic in
76
+ // item order (overall, pins, hover). The lane's on-screen spacing lives in
77
+ // dim3-scene.
78
+ function laneFor(lanes, ext) {
79
+ const key = `${ext.x.toFixed(2)},${ext.y.toFixed(2)},${ext.z.toFixed(2)}`;
80
+ const lane = lanes.get(key) ?? 0;
81
+ lanes.set(key, lane + 1);
82
+ return lane;
83
+ }
84
+
85
+ // Lane occupancy of already-placed drawings, in laneFor's key space — the
86
+ // orchestrator seeds the hover pass with the cached base pass's counts so a
87
+ // hovered dim takes the SAME lane it will occupy once pinned (pins append
88
+ // after the base items in the same order), instead of starting at lane 0 and
89
+ // jumping on click.
90
+ export function laneCounts(drawings) {
91
+ const lanes = new Map();
92
+ for (const d of drawings) {
93
+ for (const dim of d.dims ?? []) {
94
+ const key = `${dim.ext[0].toFixed(2)},${dim.ext[1].toFixed(2)},${dim.ext[2].toFixed(2)}`;
95
+ lanes.set(key, Math.max(lanes.get(key) ?? 0, (dim.lane ?? 0) + 1));
96
+ }
97
+ }
98
+ return lanes;
99
+ }
100
+
101
+ // --- candidate sides for a box-extent dim ------------------------------------
102
+ // Measuring along `axis`, the dim can extend outward along ± each of the other
103
+ // two axes; the plane normal is the remaining axis. Keys are stable across
104
+ // frames so hysteresis can hold a choice.
105
+ function boxCandidates(axis) {
106
+ const others = [0, 1, 2].filter((i) => i !== axis);
107
+ const out = [];
108
+ for (const extAxis of others) {
109
+ const nAxis = others.find((i) => i !== extAxis);
110
+ for (const sign of [1, -1]) out.push({ key: `e${extAxis}s${sign}`, extAxis, sign, nAxis });
111
+ }
112
+ return out;
113
+ }
114
+
115
+ function scoreCandidate(ext, n, toCam) {
116
+ // Readability first: the dim's plane should FACE the camera — a face-on
117
+ // drawing is what a viewer can read. "Extend toward the viewer" only breaks
118
+ // ties between equally-oblique planes (and biases the near side there): the
119
+ // two terms are antagonistic — an ext pointing AT the camera means the
120
+ // plane containing it is near edge-on — so an ext-dominant weighting (the
121
+ // original 0.6/0.4) actively picked tilted, foreshortened planes that
122
+ // fought the viewer during orbit.
123
+ return Math.abs(n.dot(toCam)) + 0.15 * Math.max(0, ext.dot(toCam));
124
+ }
125
+
126
+ // Hold the previous choice unless a challenger beats it by HYSTERESIS — and
127
+ // by an absolute margin too: near-degenerate views score every candidate close
128
+ // to zero, where a multiplicative margin is meaningless and tiny camera moves
129
+ // would flip-flop the choice (and force rebuilds) for no visible benefit.
130
+ function chooseWithHysteresis(scored, prevKey) {
131
+ scored.sort((a, b) => b.score - a.score);
132
+ const best = scored[0];
133
+ const prev = prevKey != null ? scored.find((s) => s.key === prevKey) : null;
134
+ if (prev && best.key !== prev.key
135
+ && (best.score < prev.score * HYSTERESIS || best.score - prev.score < 0.02)) return prev;
136
+ return best;
137
+ }
138
+
139
+ // --- per-frame-cheap choice scoring ------------------------------------------
140
+ export function evaluateChoices(items, { camPos, center, prev = {} }) {
141
+ const cam = v3(camPos);
142
+ const toCam = cam.clone().sub(v3(center)).normalize();
143
+ const choices = {};
144
+ for (const item of items) {
145
+ const spec = item.spec;
146
+ if (spec.kind === "bbox") {
147
+ for (const axis of [0, 1, 2]) {
148
+ // zero-span axes place no dim (placeBox skips them) — emitting a
149
+ // choice anyway would let its degenerate near-zero scores flip on
150
+ // tiny camera moves and force pointless rebuilds
151
+ if (spec.anchors.max[axis] - spec.anchors.min[axis] < 1e-6) continue;
152
+ const ck = `${item.id}|ax${axis}`;
153
+ const scored = boxCandidates(axis).map((c) => ({
154
+ ...c,
155
+ score: scoreCandidate(AXES[c.extAxis].clone().multiplyScalar(c.sign), AXES[c.nAxis], toCam),
156
+ }));
157
+ choices[ck] = { key: chooseWithHysteresis(scored, prev[ck]?.key).key };
158
+ }
159
+ } else if (spec.kind === "plane") {
160
+ const n = v3(spec.anchors.normal).normalize();
161
+ for (const dimKey of ["width", "height"]) {
162
+ const { a, b } = spec.anchors[dimKey];
163
+ const dir = v3(b).sub(v3(a)).normalize();
164
+ const perp = new THREE.Vector3().crossVectors(n, dir).normalize();
165
+ const ck = `${item.id}|${dimKey}`;
166
+ const scored = [
167
+ { key: "p+", sign: 1, score: scoreCandidate(perp, n, toCam) },
168
+ { key: "p-", sign: -1, score: scoreCandidate(perp.clone().negate(), n, toCam) },
169
+ ];
170
+ choices[ck] = { key: chooseWithHysteresis(scored, prev[ck]?.key).key };
171
+ }
172
+ } else if (spec.kind === "cylinder") {
173
+ // ⌀/R direction: radial component of the view direction, re-aimed only
174
+ // past the deadband so the drawing doesn't chase every orbit degree.
175
+ const axis = v3(spec.anchors.axis).normalize();
176
+ const toCamHere = cam.clone().sub(v3(spec.anchors.center)).normalize();
177
+ let du = toCamHere.clone().addScaledVector(axis, -toCamHere.dot(axis));
178
+ if (du.lengthSq() < 1e-6) du = v3(spec.anchors.rimDir ?? [1, 0, 0]);
179
+ du.normalize();
180
+ const ck = `${item.id}|du`;
181
+ const prevDu = prev[ck]?.du ? v3(prev[ck].du) : null;
182
+ const hold = prevDu && du.angleTo(prevDu) < (FLIP_DEADBAND_DEG * Math.PI) / 180;
183
+ choices[ck] = { du: (hold ? prevDu : du).toArray() };
184
+ // depth dim side: candidates around the axis — the tangential pair
185
+ // (±dv) puts the dim's plane FACE-ON to the camera, hanging off the
186
+ // cylinder's visible silhouette; the radial pair (±du) is near edge-on
187
+ // and only wins in degenerate down-the-axis views. Same readability
188
+ // rule as the box candidates.
189
+ const dck = `${item.id}|depth`;
190
+ const duHeld = hold ? prevDu : du;
191
+ const dv = new THREE.Vector3().crossVectors(axis, duHeld).normalize();
192
+ const scored = [
193
+ { key: "t+", ext: dv },
194
+ { key: "t-", ext: dv.clone().negate() },
195
+ { key: "d+", ext: duHeld },
196
+ { key: "d-", ext: duHeld.clone().negate() },
197
+ ].map((c) => ({
198
+ key: c.key,
199
+ score: scoreCandidate(c.ext, new THREE.Vector3().crossVectors(axis, c.ext).normalize(), toCamHere),
200
+ }));
201
+ choices[dck] = { key: chooseWithHysteresis(scored, prev[dck]?.key).key, du: duHeld.toArray() };
202
+ }
203
+ }
204
+ return choices;
205
+ }
206
+
207
+ export function choicesEqual(a, b) {
208
+ const ka = Object.keys(a), kb = Object.keys(b);
209
+ if (ka.length !== kb.length) return false;
210
+ for (const k of ka) {
211
+ const x = a[k], y = b[k];
212
+ if (!y) return false;
213
+ if (x.key !== y.key) return false;
214
+ if (!!x.du !== !!y.du) return false;
215
+ if (x.du && (x.du[0] !== y.du[0] || x.du[1] !== y.du[1] || x.du[2] !== y.du[2])) return false;
216
+ }
217
+ return true;
218
+ }
219
+
220
+ // --- extreme vertex scan ------------------------------------------------------
221
+ // The vertex realizing the extreme along `axis` over the posed meshes; ties
222
+ // within tolerance (a flat base is all "the minimum") break toward `near`, so
223
+ // the anchor lands on the side of the part the dimension is drawn on.
224
+ const _sv = new THREE.Vector3();
225
+ export function extremeVertex(meshData, axis, sign, near) {
226
+ let bestVal = sign > 0 ? -Infinity : Infinity;
227
+ for (const { positions, matrix } of meshData) {
228
+ for (let i = 0; i < positions.length; i += 3) {
229
+ _sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
230
+ const val = _sv.getComponent(axis);
231
+ if (sign > 0 ? val > bestVal : val < bestVal) bestVal = val;
232
+ }
233
+ }
234
+ if (!Number.isFinite(bestVal)) return null;
235
+ let best = null, bestD = Infinity;
236
+ for (const { positions, matrix } of meshData) {
237
+ for (let i = 0; i < positions.length; i += 3) {
238
+ _sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
239
+ if (Math.abs(_sv.getComponent(axis) - bestVal) > 1e-3) continue;
240
+ const d = _sv.distanceToSquared(near);
241
+ if (d < bestD) { bestD = d; best = _sv.clone(); }
242
+ }
243
+ }
244
+ return best;
245
+ }
246
+
247
+ // --- one parametric linear dimension -----------------------------------------
248
+ // pA/pB: surface anchor points. nomA/nomB: NOMINAL dim-line endpoints used for
249
+ // discovery only. baseA/baseB: dim-line endpoints at ZERO standoff — the scene
250
+ // slides them out along `ext` by the screen-derived offset. When `surfaceHit`
251
+ // is given, each extension line starts at the first in-plane surface hit
252
+ // walking from the nominal endpoint back toward the part (ray nudged 0.05 mm
253
+ // inside the extreme plane so a grazing ray on the extreme face registers);
254
+ // otherwise (feature dims — anchors already ON the surface) it starts at the
255
+ // anchor.
256
+ function linearDim(out, {
257
+ pA, pB, baseA, baseB, nomA, nomB, ext, lane, standoffScale = 1,
258
+ text, value, surfaceHit, planeAxis, planeC,
259
+ }) {
260
+ const dir = baseB.clone().sub(baseA).normalize();
261
+ const anchors = [pA, pB];
262
+ [[pA, nomA, 1], [pB, nomB, -1]].forEach(([p, nom, inwardSign], i) => {
263
+ if (!surfaceHit) return;
264
+ const nudged = nom.clone().addScaledVector(dir, 0.05 * inwardSign);
265
+ const toward = p.clone().sub(nom).normalize();
266
+ const hit = surfaceHit(nudged, toward);
267
+ if (hit) {
268
+ const start = hit.clone();
269
+ if (planeAxis != null) start.setComponent(planeAxis, planeC); // stay exactly coplanar
270
+ anchors[i] = start;
271
+ }
272
+ });
273
+ out.dims.push({
274
+ pA: anchors[0].toArray(), pB: anchors[1].toArray(),
275
+ baseA: baseA.toArray(), baseB: baseB.toArray(),
276
+ ext: ext.toArray(), dir: dir.toArray(),
277
+ lane, standoffScale,
278
+ label: { text, value: value ?? null, x: dir.toArray(), y: ext.clone().negate().toArray() },
279
+ });
280
+ }
281
+
282
+ // --- per-kind placement -------------------------------------------------------
283
+ function placeBox(out, item, spec, choices, { meshData, surfaceHit, modelSize, lanes, unit }, refSide) {
284
+ const min = spec.anchors.min, max = spec.anchors.max;
285
+ const nomOff = standoffNominal(modelSize);
286
+ const scan = meshData; // caller pre-filtered by item.meshes
287
+ const valueByAxis = [spec.values.w, spec.values.d, spec.values.h];
288
+ const seenValues = new Set();
289
+ for (const axis of [0, 1, 2]) {
290
+ const span = max[axis] - min[axis];
291
+ if (span < 1e-6) continue;
292
+ // duplicate-value suppression within the item: a round or square part has
293
+ // equal extents — one dim carries the shared value
294
+ const text = `${unit.format(valueByAxis[axis])}${unit.suffix}`;
295
+ if (seenValues.has(text)) continue;
296
+ seenValues.add(text);
297
+ const cand = boxCandidates(axis).find((c) => c.key === choices[`${item.id}|ax${axis}`]?.key)
298
+ ?? boxCandidates(axis)[0];
299
+ const { extAxis, sign, nAxis } = cand;
300
+ const ext = AXES[extAxis].clone().multiplyScalar(sign);
301
+ // dim-line base points: measured coordinate at min/max, ext coordinate on
302
+ // the near face (zero standoff); the plane coordinate (nAxis) is snapped
303
+ // below. Nominal points add the discovery standoff for raycast origins and
304
+ // tie-break targets.
305
+ const extBase = sign > 0 ? max[extAxis] : min[extAxis];
306
+ const mk = (m, off) => {
307
+ const p = new THREE.Vector3();
308
+ p.setComponent(axis, m);
309
+ p.setComponent(extAxis, extBase + sign * off);
310
+ p.setComponent(nAxis, refSide(nAxis, min, max));
311
+ return p;
312
+ };
313
+ const nomA = mk(min[axis], nomOff), nomB = mk(max[axis], nomOff);
314
+ // true extreme anchors (tie-break toward the nominal dim line), then plane
315
+ // snap: slide the plane along nAxis to whichever anchor sits nearer the
316
+ // mid-plane reference; the other anchor projects into the plane.
317
+ const ref = refSide(nAxis, min, max);
318
+ let pA = extremeVertex(scan, axis, -1, nomA) ?? new THREE.Vector3().setComponent(axis, min[axis]);
319
+ let pB = extremeVertex(scan, axis, +1, nomB) ?? new THREE.Vector3().setComponent(axis, max[axis]);
320
+ const cA = pA.getComponent(nAxis), cB = pB.getComponent(nAxis);
321
+ const c = Math.abs(cA - ref) <= Math.abs(cB - ref) ? cA : cB;
322
+ const baseA = mk(min[axis], 0), baseB = mk(max[axis], 0);
323
+ for (const p of [pA, pB, nomA, nomB, baseA, baseB]) p.setComponent(nAxis, c);
324
+ linearDim(out, {
325
+ pA, pB, baseA, baseB, nomA, nomB, ext,
326
+ lane: laneFor(lanes, ext),
327
+ text, value: valueByAxis[axis],
328
+ surfaceHit, planeAxis: nAxis, planeC: c,
329
+ });
330
+ }
331
+ }
332
+
333
+ function placePlane(out, item, spec, choices, { lanes, unit }) {
334
+ const n = v3(spec.anchors.normal).normalize();
335
+ const dims = [
336
+ ["width", spec.values.width],
337
+ ["height", spec.values.height],
338
+ ];
339
+ for (const [dimKey, value] of dims) {
340
+ if (value < 1e-6) continue;
341
+ const a = v3(spec.anchors[dimKey].a), b = v3(spec.anchors[dimKey].b);
342
+ const dir = b.clone().sub(a).normalize();
343
+ const perp = new THREE.Vector3().crossVectors(n, dir).normalize();
344
+ const sign = choices[`${item.id}|${dimKey}`]?.key === "p-" ? -1 : 1;
345
+ const ext = perp.multiplyScalar(sign);
346
+ linearDim(out, {
347
+ pA: a, pB: b, baseA: a.clone(), baseB: b.clone(), nomA: a, nomB: b, ext,
348
+ lane: laneFor(lanes, ext), standoffScale: 0.55, // feature dims hug their feature
349
+ text: `${unit.format(value)}${unit.suffix}`, value, surfaceHit: null,
350
+ });
351
+ }
352
+ }
353
+
354
+ function placeCylinder(out, item, spec, choices, { lanes, unit }) {
355
+ const axis = v3(spec.anchors.axis).normalize();
356
+ const top = v3(spec.anchors.top);
357
+ const bottom = v3(spec.anchors.bottom);
358
+ const r = spec.values.diameter / 2;
359
+ const du = v3(choices[`${item.id}|du`]?.du ?? spec.anchors.rimDir ?? [1, 0, 0]).normalize();
360
+ const dv = new THREE.Vector3().crossVectors(axis, du).normalize();
361
+
362
+ if (spec.values.partial) {
363
+ // R leader from the covered-arc midpoint, radial, in the top plane
364
+ const rd = v3(spec.anchors.rimDir ?? du.toArray()).normalize();
365
+ const rim = top.clone().addScaledVector(rd, r);
366
+ out.leaders.push({
367
+ rim: rim.toArray(), dir: rd.toArray(),
368
+ perp: new THREE.Vector3().crossVectors(axis, rd).normalize().toArray(),
369
+ label: {
370
+ text: `R${unit.format(r)}`, value: r,
371
+ x: new THREE.Vector3().crossVectors(axis, rd).normalize().toArray(),
372
+ y: rd.clone().negate().toArray(),
373
+ },
374
+ });
375
+ } else {
376
+ // full circle: diameter line across the top circle along dv — the
377
+ // projected ellipse's WIDE axis (du, the camera radial, is its
378
+ // foreshortened one) — arrows outward at both rim points, ⌀ text
379
+ // continuing off the end of the line
380
+ const rimA = top.clone().addScaledVector(dv, r);
381
+ const rimB = top.clone().addScaledVector(dv, -r);
382
+ out.diams.push({
383
+ // scene contract: `du` is the label-offset direction off rimA (here the
384
+ // line's own direction, so the text runs off the end of the diameter);
385
+ // `dv` is the arrows' in-plane spread direction
386
+ rimA: rimA.toArray(), rimB: rimB.toArray(), du: dv.toArray(), dv: du.toArray(),
387
+ label: {
388
+ text: `⌀${unit.format(spec.values.diameter)}`, value: spec.values.diameter,
389
+ x: dv.toArray(), y: du.clone().negate().toArray(),
390
+ },
391
+ });
392
+ }
393
+
394
+ // depth: linear dim along the axis, hung off the silhouette at the chosen side
395
+ if (spec.values.depth > 1e-6) {
396
+ const extKey = choices[`${item.id}|depth`]?.key ?? "t+";
397
+ const ext = ({
398
+ "t+": dv.clone(), "t-": dv.clone().negate(),
399
+ "d+": du.clone(), "d-": du.clone().negate(),
400
+ })[extKey] ?? dv.clone();
401
+ const pA = bottom.clone().addScaledVector(ext, r);
402
+ const pB = top.clone().addScaledVector(ext, r);
403
+ linearDim(out, {
404
+ pA, pB, baseA: pA.clone(), baseB: pB.clone(), nomA: pA, nomB: pB, ext,
405
+ lane: laneFor(lanes, ext), standoffScale: 0.55,
406
+ text: `${unit.format(spec.values.depth)}${unit.suffix}`, value: spec.values.depth, surfaceHit: null,
407
+ });
408
+ }
409
+ }
410
+
411
+ // --- entry point --------------------------------------------------------------
412
+ export function placeDims(items, { meshData = [], surfaceHit = null, bounds, suppress = null, lanes: laneSeed = null, units = "mm" }, choices) {
413
+ const size = bounds
414
+ ? Math.max(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2])
415
+ : 10;
416
+ // duplicate-measurement suppression: see specSig. Later item wins in-call;
417
+ // `suppress` carries sigs already drawn by another call (the base pass).
418
+ const sigs = items.map((i) => (i.spec ? specSig(i.spec) : null));
419
+ const skip = new Set();
420
+ for (let i = 0; i < items.length; i++) {
421
+ if (!sigs[i]) { skip.add(i); continue; }
422
+ if (suppress?.has(sigs[i])) { skip.add(i); continue; }
423
+ for (let j = i + 1; j < items.length; j++) {
424
+ if (sigs[i] === sigs[j]) { skip.add(i); break; }
425
+ }
426
+ }
427
+ // plane-snap reference: for bbox dims the plane snaps to whichever true
428
+ // extreme anchor sits nearer the model's mid-plane along nAxis (see refSide
429
+ // below) rather than to a camera side — deterministic and adequate: the
430
+ // spec only requires "the side of the model the dim is drawn toward".
431
+ const lanes = new Map(laneSeed ?? undefined);
432
+ const unit = UNITS[units] ?? UNITS.mm;
433
+ const drawings = [];
434
+ items.forEach((item, idx) => {
435
+ const spec = item.spec;
436
+ if (!spec || skip.has(idx)) return;
437
+ const out = { itemId: item.id, tier: item.tier, pinned: !!item.pinned, dims: [], diams: [], leaders: [] };
438
+ const scan = item.meshes ? item.meshes.map((i) => meshData[i]).filter(Boolean) : meshData;
439
+ if (spec.kind === "bbox") {
440
+ const refSide = (nAxis, min, max) => {
441
+ // draw-side reference along the plane normal: mid-plane — the snap then
442
+ // picks whichever anchor is nearer the model's middle along n, keeping
443
+ // the drawing close to where the extent actually occurs.
444
+ return (min[nAxis] + max[nAxis]) / 2;
445
+ };
446
+ placeBox(out, item, spec, choices, { meshData: scan, surfaceHit, modelSize: size, lanes, unit }, refSide);
447
+ } else if (spec.kind === "plane") {
448
+ placePlane(out, item, spec, choices, { lanes, unit });
449
+ } else if (spec.kind === "cylinder") {
450
+ placeCylinder(out, item, spec, choices, { lanes, unit });
451
+ }
452
+ if (out.dims.length || out.diams.length || out.leaders.length) drawings.push(out);
453
+ });
454
+ return drawings;
455
+ }