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.
@@ -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
+ }