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.
@@ -0,0 +1,438 @@
1
+ // In-scene dimension renderer (spec v2 + amendments). Renders dim3-place's
2
+ // parametric drawings as three.js objects parented under the meshes' shared
3
+ // group, so the pivot rotation, per-view recentring and pose fast path apply
4
+ // for free. Placement discovers WHERE a dimension is anchored; this module
5
+ // assembles its final geometry every frame, because every display distance is
6
+ // SCREEN-constant — sized off one shared reference distance (camera to the
7
+ // model centre) so the whole drawing reads the same at any zoom, on any part
8
+ // size, uniformly across a view. Text is painted onto canvas textures by an
9
+ // injectable painter (tests inject a fake; happy-dom has no real 2d context).
10
+ // Dims draw over the model (depthTest:false), are never cutaway-clipped
11
+ // (materials deliberately NOT registered with the cutaway), and are hidden
12
+ // from canonical captures.
13
+ import * as THREE from "three";
14
+ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
15
+ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
16
+ import { LineMaterial } from "three/addons/lines/LineMaterial.js";
17
+ import { CUTAWAY_OVERLAY_RENDER_ORDER } from "../cutaway-render.js";
18
+
19
+ // Theme palettes for dimension ink. Deliberately hardcoded (not CSS vars):
20
+ // the scene renders to WebGL where var() can't reach, and these pair with the
21
+ // viewer THEME backgrounds. static = always-on overall dims; strong = hover +
22
+ // pinned.
23
+ export const DIM_THEME = {
24
+ dark: {
25
+ static: 0x7d93b8, strong: 0xa8c2ff,
26
+ text: "#d6e2ff", halo: "rgba(8, 11, 16, 0.95)",
27
+ },
28
+ light: {
29
+ // darker ink + a solid near-white halo: the first light palette washed
30
+ // out against the pale background and the part's light-blue surfaces
31
+ static: 0x5a6c8a, strong: 0x2c4a86,
32
+ text: "#182a4e", halo: "rgba(255, 255, 255, 0.96)",
33
+ },
34
+ };
35
+
36
+ // Above the cutaway-overlay tier (section caps, and the hover highlight,
37
+ // which renders at CUTAWAY_OVERLAY_RENDER_ORDER): a later-drawn transparent
38
+ // highlight would otherwise tint every dim pixel it covers. The cutaway
39
+ // GIZMO deliberately stays above the dims — it is an active drag control.
40
+ export const RENDER_ORDER_DIMS = CUTAWAY_OVERLAY_RENDER_ORDER + 2;
41
+ export const RENDER_ORDER_LABELS = CUTAWAY_OVERLAY_RENDER_ORDER + 3;
42
+
43
+ // Screen-constant sizes, CSS px, all sized off the same per-view reference
44
+ // distance. Uniform per view by design — a nearer dimension is NOT normalized
45
+ // to match a farther one; it just reads slightly larger under perspective
46
+ // like the rest of the scene.
47
+ export const LABEL_SCREEN_PX = 21; // label text height
48
+ export const ARROW_SCREEN_PX = 10; // arrowhead length
49
+ export const ARROW_HALF_W = 0.25; // × arrow length — the narrow drafting ratio
50
+ export const OVERSHOOT_SCREEN_PX = 7; // extension line past the dim line
51
+ export const GAP_SCREEN_PX = 4; // extension line stands off the surface
52
+ export const STANDOFF_SCREEN_PX = 40; // dim line stands off the part (× dim's standoffScale)
53
+ export const STAGGER_SCREEN_PX = 26; // extra standoff per stacked lane
54
+ export const LEADER_SCREEN_PX = 36; // R-leader length
55
+
56
+ // World units per CSS pixel for a point at `dist` from the camera.
57
+ export function worldPerPx(dist, fovDeg, viewportPx) {
58
+ return (2 * dist * Math.tan((fovDeg * Math.PI) / 360)) / viewportPx;
59
+ }
60
+
61
+ // Kept for compatibility with earlier callers/tests: the world height that
62
+ // renders as `targetPx` on screen.
63
+ export function labelWorldHeight(dist, fovDeg, viewportPx, targetPx = LABEL_SCREEN_PX) {
64
+ return targetPx * worldPerPx(dist, fovDeg, viewportPx);
65
+ }
66
+
67
+ // Default label painter: returns a canvas whose aspect the caller turns into
68
+ // a plane. Pure DOM-canvas; swapped out in tests. Plain value text only —
69
+ // the old param pill misattributed a set-level link to every label in the
70
+ // set, so the dimension->control affordance is now the click itself (every
71
+ // label click flashes the controls that drive the measurement).
72
+ export function defaultPaintLabel({ text, palette }) {
73
+ const font = "700 96px ui-monospace, Menlo, monospace";
74
+ const c = document.createElement("canvas");
75
+ let ctx = c.getContext("2d");
76
+ ctx.font = font;
77
+ const wText = Math.ceil(ctx.measureText(text).width);
78
+ const PAD = 20;
79
+ c.width = wText + PAD * 2;
80
+ c.height = 128;
81
+ ctx = c.getContext("2d");
82
+ ctx.textAlign = "left";
83
+ ctx.textBaseline = "middle";
84
+ ctx.lineJoin = "round";
85
+ ctx.font = font;
86
+ ctx.strokeStyle = palette.halo; // halo so bare text reads on the part body
87
+ ctx.lineWidth = 12;
88
+ ctx.strokeText(text, PAD, c.height / 2);
89
+ ctx.fillStyle = palette.text;
90
+ ctx.fillText(text, PAD, c.height / 2);
91
+ return c;
92
+ }
93
+
94
+ export function createDimScene(viewer, { paintLabel = defaultPaintLabel } = {}) {
95
+ const group = new THREE.Group();
96
+ group.name = "pf-dims";
97
+ let attached = false;
98
+ let unregisterCapture = () => {};
99
+ function ensureAttached() {
100
+ if (attached) return true;
101
+ const parent = Object.values(viewer._subMeshes)[0]?.parent;
102
+ if (!parent) return false;
103
+ parent.add(group);
104
+ unregisterCapture = viewer.registerCanonicalCaptureHidden?.(group) ?? (() => {});
105
+ attached = true;
106
+ return true;
107
+ }
108
+
109
+ let theme = viewer.getTheme?.() ?? "dark";
110
+ const lineMats = {
111
+ static: new LineMaterial({ color: DIM_THEME[theme].static, linewidth: 1.5 }),
112
+ strong: new LineMaterial({ color: DIM_THEME[theme].strong, linewidth: 1.5 }),
113
+ };
114
+ const fillMats = {
115
+ static: new THREE.MeshBasicMaterial({ color: DIM_THEME[theme].static, side: THREE.DoubleSide }),
116
+ strong: new THREE.MeshBasicMaterial({ color: DIM_THEME[theme].strong, side: THREE.DoubleSide }),
117
+ };
118
+ for (const m of [...Object.values(lineMats), ...Object.values(fillMats)]) {
119
+ m.depthTest = false;
120
+ m.depthWrite = false; // overlay ink must not pollute the depth buffer
121
+ m.transparent = true; // draw in the late pass so depthTest:false lands on top
122
+ }
123
+
124
+ const matFor = (tier) => (tier === "static" ? "static" : "strong");
125
+
126
+ // Shared unit arrowhead: tip at the origin pointing +X, the narrow drafting
127
+ // ratio baked in; instances are oriented at build time (their in-plane basis
128
+ // never changes) and positioned + scaled per frame.
129
+ const unitArrowGeo = new THREE.BufferGeometry();
130
+ unitArrowGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array([
131
+ 0, 0, 0, 1, ARROW_HALF_W, 0, 1, -ARROW_HALF_W, 0,
132
+ ]), 3));
133
+
134
+ const quatFromBasis = (x, y) => {
135
+ const z = new THREE.Vector3().crossVectors(x, y).normalize();
136
+ return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(x, y, z));
137
+ };
138
+
139
+ function makeArrow(key, x, y, position) {
140
+ const mesh = new THREE.Mesh(unitArrowGeo, fillMats[key]);
141
+ mesh.quaternion.copy(quatFromBasis(x, y));
142
+ if (position) mesh.position.copy(position);
143
+ mesh.renderOrder = RENDER_ORDER_DIMS;
144
+ mesh.frustumCulled = false; // scaled/moved per frame; stale bounds must not cull it
145
+ group.add(mesh);
146
+ arrows.push(mesh);
147
+ return mesh;
148
+ }
149
+
150
+ // A LineSegments2 whose positions this module rewrites in place each frame.
151
+ function makeLine(key, segmentCount) {
152
+ const geo = new LineSegmentsGeometry();
153
+ geo.setPositions(new Array(segmentCount * 6).fill(0));
154
+ const line = new LineSegments2(geo, lineMats[key]);
155
+ line.renderOrder = RENDER_ORDER_DIMS;
156
+ line.frustumCulled = false;
157
+ group.add(line);
158
+ return line;
159
+ }
160
+
161
+ function writeSegments(line, arr) {
162
+ const data = line.geometry.attributes.instanceStart.data;
163
+ data.array.set(arr);
164
+ data.needsUpdate = true;
165
+ }
166
+
167
+ // ---- record bookkeeping ---------------------------------------------------
168
+ // labels: [{ mesh, baseQuat, mirrored, flipped, itemId, text, param }] —
169
+ // positions/scale are written per frame by the owning record in tick().
170
+ let labels = [];
171
+ let arrows = [];
172
+ let dimRecs = []; // parametric linear dims
173
+ let diamRecs = []; // ⌀ lines (static line, per-frame label anchor)
174
+ let leaderRecs = []; // R leaders
175
+ const textureCache = new Map(); // `${theme}|${text}` -> THREE.CanvasTexture
176
+
177
+ function labelTexture(text) {
178
+ const key = `${theme}|${text}`;
179
+ let tex = textureCache.get(key);
180
+ if (!tex) {
181
+ const canvas = paintLabel({ text, palette: DIM_THEME[theme] });
182
+ tex = new THREE.CanvasTexture(canvas);
183
+ tex.colorSpace = THREE.SRGBColorSpace;
184
+ tex.anisotropy = 8; // keep glancing-angle text legible
185
+ textureCache.set(key, tex);
186
+ }
187
+ return tex;
188
+ }
189
+
190
+ function buildLabel(l, itemId) {
191
+ const tex = labelTexture(l.text);
192
+ const img = tex.image;
193
+ const aspect = img && img.height ? img.width / img.height : 4;
194
+ // unit-height plane; tick() scales it to the screen-constant display height
195
+ const mesh = new THREE.Mesh(
196
+ new THREE.PlaneGeometry(aspect, 1),
197
+ new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthTest: false, depthWrite: false, side: THREE.DoubleSide }),
198
+ );
199
+ mesh.renderOrder = RENDER_ORDER_LABELS;
200
+ mesh.frustumCulled = false;
201
+ const x = new THREE.Vector3(...l.x), y = new THREE.Vector3(...l.y);
202
+ mesh.quaternion.copy(quatFromBasis(x, y));
203
+ mesh.userData.pfDimItemId = itemId;
204
+ mesh.userData.pfDimValue = l.value ?? null;
205
+ group.add(mesh);
206
+ const rec = {
207
+ mesh, baseQuat: mesh.quaternion.clone(), mirrored: false, flipped: false,
208
+ itemId, text: l.text,
209
+ };
210
+ labels.push(rec);
211
+ return rec;
212
+ }
213
+
214
+ // ---- build / clear --------------------------------------------------------
215
+ function disposeChildren() {
216
+ for (const child of [...group.children]) {
217
+ group.remove(child);
218
+ if (child.geometry !== unitArrowGeo) child.geometry?.dispose?.(); // the unit arrow is shared
219
+ // label materials are per-mesh clones; textures live in the cache
220
+ if (child.material && !Object.values(lineMats).includes(child.material)
221
+ && !Object.values(fillMats).includes(child.material)) {
222
+ child.material.dispose?.();
223
+ }
224
+ }
225
+ labels = [];
226
+ arrows = [];
227
+ dimRecs = [];
228
+ diamRecs = [];
229
+ leaderRecs = [];
230
+ }
231
+
232
+ function update(drawings) {
233
+ if (!ensureAttached()) return;
234
+ disposeChildren();
235
+ for (const d of drawings) {
236
+ const key = matFor(d.tier);
237
+ for (const dim of d.dims ?? []) {
238
+ const dir = new THREE.Vector3(...dim.dir);
239
+ const ext = new THREE.Vector3(...dim.ext);
240
+ dimRecs.push({
241
+ pA: new THREE.Vector3(...dim.pA), pB: new THREE.Vector3(...dim.pB),
242
+ baseA: new THREE.Vector3(...dim.baseA), baseB: new THREE.Vector3(...dim.baseB),
243
+ ext, dir, lane: dim.lane ?? 0, standoffScale: dim.standoffScale ?? 1,
244
+ line: makeLine(key, 5), // ext A, ext B, dim line, tail A, tail B
245
+ arrowA: makeArrow(key, dir, ext),
246
+ arrowB: makeArrow(key, dir.clone().negate(), ext),
247
+ labelRec: buildLabel(dim.label, d.itemId),
248
+ });
249
+ }
250
+ for (const diam of d.diams ?? []) {
251
+ const rimA = new THREE.Vector3(...diam.rimA);
252
+ const rimB = new THREE.Vector3(...diam.rimB);
253
+ const du = new THREE.Vector3(...diam.du);
254
+ const dv = new THREE.Vector3(...diam.dv);
255
+ const line = makeLine(key, 1);
256
+ writeSegments(line, [rimA.x, rimA.y, rimA.z, rimB.x, rimB.y, rimB.z]); // static
257
+ makeArrow(key, du.clone().negate(), dv, rimA);
258
+ makeArrow(key, du, dv, rimB);
259
+ diamRecs.push({ rimA, du, labelRec: buildLabel(diam.label, d.itemId) });
260
+ }
261
+ for (const leader of d.leaders ?? []) {
262
+ const rim = new THREE.Vector3(...leader.rim);
263
+ const dir = new THREE.Vector3(...leader.dir);
264
+ const perp = new THREE.Vector3(...leader.perp);
265
+ leaderRecs.push({
266
+ rim, dir,
267
+ line: makeLine(key, 1),
268
+ arrow: makeArrow(key, dir, perp, rim),
269
+ labelRec: buildLabel(leader.label, d.itemId),
270
+ });
271
+ }
272
+ }
273
+ sweepTextureCache();
274
+ }
275
+
276
+ // Bound the texture cache to labels actually in use: continuous param
277
+ // dragging mints a fresh label text every frame, so without this the cache
278
+ // (one CanvasTexture each) would grow unboundedly across a session. Drop
279
+ // every entry not referenced by the labels just built; entries from a prior
280
+ // theme become unreferenced the moment setTheme() repaints (it fetches a
281
+ // new-theme texture on demand but doesn't dispose the old one, since it's
282
+ // still live if setTheme fires again before the next update) and are swept
283
+ // here on the next update(), never while still assigned to a live label.
284
+ function sweepTextureCache() {
285
+ const live = new Set(labels.map((L) => `${theme}|${L.text}`));
286
+ for (const [key, tex] of textureCache) {
287
+ if (live.has(key)) continue;
288
+ tex.dispose();
289
+ textureCache.delete(key);
290
+ }
291
+ }
292
+
293
+ // ---- per-frame assembly + readability flips -------------------------------
294
+ // Labels correct among four in-plane states so they never read mirrored or
295
+ // upside down: Ry(π) fixes viewing the plane from behind, Rz(π) fixes the
296
+ // reading direction. 0.08 deadband stops edge-on flicker.
297
+ const QY = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
298
+ const QZ = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI);
299
+ const _gq = new THREE.Quaternion();
300
+ const _wq = new THREE.Quaternion();
301
+ const _iq = new THREE.Quaternion();
302
+ const _n = new THREE.Vector3();
303
+ const _x = new THREE.Vector3();
304
+ const _wp = new THREE.Vector3();
305
+ const _toCam = new THREE.Vector3();
306
+ const _gp = new THREE.Vector3();
307
+ const _dA = new THREE.Vector3();
308
+ const _dB = new THREE.Vector3();
309
+ const _uA = new THREE.Vector3();
310
+ const _uB = new THREE.Vector3();
311
+ const _p = new THREE.Vector3();
312
+ const _seg = new Float32Array(30);
313
+ function tick() {
314
+ if (!attached || !group.children.length) return;
315
+ const el = viewer.domElement;
316
+ const w = el.clientWidth || 1, h = el.clientHeight || 1;
317
+ lineMats.static.resolution.set(w, h);
318
+ lineMats.strong.resolution.set(w, h);
319
+ // One shared reference distance — camera to the dim group's origin (the
320
+ // recentred model centre) — sizes the whole drawing.
321
+ group.getWorldPosition(_gp);
322
+ const dist = viewer.camera.position.distanceTo(_gp);
323
+ const wpp = worldPerPx(dist, viewer.camera.fov ?? 45, h);
324
+ if (wpp > 0) {
325
+ const hStar = LABEL_SCREEN_PX * wpp;
326
+ const aw = ARROW_SCREEN_PX * wpp;
327
+ const gap = GAP_SCREEN_PX * wpp;
328
+ const tail = OVERSHOOT_SCREEN_PX * wpp;
329
+ for (const R of dimRecs) {
330
+ const off = (STANDOFF_SCREEN_PX * R.standoffScale + R.lane * STAGGER_SCREEN_PX) * wpp;
331
+ _dA.copy(R.baseA).addScaledVector(R.ext, off);
332
+ _dB.copy(R.baseB).addScaledVector(R.ext, off);
333
+ _uA.copy(_dA).sub(R.pA);
334
+ if (_uA.lengthSq() > 1e-12) _uA.normalize(); else _uA.copy(R.ext);
335
+ _uB.copy(_dB).sub(R.pB);
336
+ if (_uB.lengthSq() > 1e-12) _uB.normalize(); else _uB.copy(R.ext);
337
+ let i = 0;
338
+ _p.copy(R.pA).addScaledVector(_uA, gap);
339
+ _seg[i++] = _p.x; _seg[i++] = _p.y; _seg[i++] = _p.z;
340
+ _seg[i++] = _dA.x; _seg[i++] = _dA.y; _seg[i++] = _dA.z;
341
+ _p.copy(R.pB).addScaledVector(_uB, gap);
342
+ _seg[i++] = _p.x; _seg[i++] = _p.y; _seg[i++] = _p.z;
343
+ _seg[i++] = _dB.x; _seg[i++] = _dB.y; _seg[i++] = _dB.z;
344
+ _seg[i++] = _dA.x; _seg[i++] = _dA.y; _seg[i++] = _dA.z;
345
+ _seg[i++] = _dB.x; _seg[i++] = _dB.y; _seg[i++] = _dB.z;
346
+ _p.copy(_dA).addScaledVector(_uA, tail);
347
+ _seg[i++] = _dA.x; _seg[i++] = _dA.y; _seg[i++] = _dA.z;
348
+ _seg[i++] = _p.x; _seg[i++] = _p.y; _seg[i++] = _p.z;
349
+ _p.copy(_dB).addScaledVector(_uB, tail);
350
+ _seg[i++] = _dB.x; _seg[i++] = _dB.y; _seg[i++] = _dB.z;
351
+ _seg[i++] = _p.x; _seg[i++] = _p.y; _seg[i++] = _p.z;
352
+ writeSegments(R.line, _seg);
353
+ R.arrowA.position.copy(_dA);
354
+ R.arrowB.position.copy(_dB);
355
+ R.labelRec.mesh.position
356
+ .copy(_dA).add(_dB).multiplyScalar(0.5)
357
+ .addScaledVector(R.ext, 0.85 * hStar);
358
+ }
359
+ for (const R of diamRecs) {
360
+ R.labelRec.mesh.position.copy(R.rimA).addScaledVector(R.du, 0.85 * hStar);
361
+ }
362
+ for (const R of leaderRecs) {
363
+ const len = LEADER_SCREEN_PX * wpp;
364
+ _p.copy(R.rim).addScaledVector(R.dir, gap);
365
+ _dA.copy(R.rim).addScaledVector(R.dir, gap + len);
366
+ writeSegments(R.line, [_p.x, _p.y, _p.z, _dA.x, _dA.y, _dA.z]);
367
+ R.labelRec.mesh.position.copy(_dA).addScaledVector(R.dir, 0.85 * hStar);
368
+ }
369
+ for (const a of arrows) a.scale.setScalar(aw);
370
+ for (const L of labels) L.mesh.scale.setScalar(hStar);
371
+ }
372
+ group.getWorldQuaternion(_gq);
373
+ _iq.copy(viewer.camera.quaternion).invert();
374
+ for (const L of labels) {
375
+ _wq.copy(_gq).multiply(L.baseQuat);
376
+ if (L.mirrored) _wq.multiply(QY);
377
+ if (L.flipped) _wq.multiply(QZ);
378
+ L.mesh.getWorldPosition(_wp);
379
+ _toCam.copy(viewer.camera.position).sub(_wp).normalize();
380
+ _n.set(0, 0, 1).applyQuaternion(_wq);
381
+ const facing = _n.dot(_toCam);
382
+ if (Math.abs(facing) > 0.08 && facing < 0) {
383
+ L.mirrored = !L.mirrored;
384
+ _wq.multiply(QY);
385
+ }
386
+ _x.set(1, 0, 0).applyQuaternion(_wq).applyQuaternion(_iq);
387
+ if (Math.abs(_x.x) > 0.08 && _x.x < 0) L.flipped = !L.flipped;
388
+ L.mesh.quaternion.copy(L.baseQuat);
389
+ if (L.mirrored) L.mesh.quaternion.multiply(QY);
390
+ if (L.flipped) L.mesh.quaternion.multiply(QZ);
391
+ }
392
+ }
393
+
394
+ // ---- label picking --------------------------------------------------------
395
+ const raycaster = new THREE.Raycaster();
396
+ const _ndc = new THREE.Vector2();
397
+ // Returns { itemId, value } for the label under the pointer (value = the
398
+ // measured number the label shows, for exact-match control focusing), or
399
+ // null when no label is hit.
400
+ function pickLabel(clientX, clientY) {
401
+ if (!attached || !labels.length) return null;
402
+ const r = viewer.domElement.getBoundingClientRect();
403
+ _ndc.set(((clientX - r.left) / r.width) * 2 - 1, -(((clientY - r.top) / r.height) * 2 - 1));
404
+ raycaster.setFromCamera(_ndc, viewer.camera);
405
+ const hit = raycaster.intersectObjects(labels.map((L) => L.mesh), false)[0];
406
+ return hit ? { itemId: hit.object.userData.pfDimItemId, value: hit.object.userData.pfDimValue ?? null } : null;
407
+ }
408
+
409
+ // ---- theme ----------------------------------------------------------------
410
+ function setTheme(mode) {
411
+ if (!DIM_THEME[mode] || mode === theme) return;
412
+ theme = mode;
413
+ lineMats.static.color.set(DIM_THEME[theme].static);
414
+ lineMats.strong.color.set(DIM_THEME[theme].strong);
415
+ fillMats.static.color.set(DIM_THEME[theme].static);
416
+ fillMats.strong.color.set(DIM_THEME[theme].strong);
417
+ // repaint labels: new-theme textures come from the cache or a fresh paint
418
+ for (const L of labels) {
419
+ L.mesh.material.map = labelTexture(L.text);
420
+ L.mesh.material.needsUpdate = true;
421
+ }
422
+ }
423
+
424
+ function clear() { disposeChildren(); }
425
+
426
+ function dispose() {
427
+ disposeChildren();
428
+ unregisterCapture();
429
+ if (attached) group.parent?.remove(group);
430
+ attached = false;
431
+ for (const m of [...Object.values(lineMats), ...Object.values(fillMats)]) m.dispose();
432
+ unitArrowGeo.dispose();
433
+ for (const t of textureCache.values()) t.dispose();
434
+ textureCache.clear();
435
+ }
436
+
437
+ return { update, tick, pickLabel, setTheme, clear, group, dispose };
438
+ }
@@ -0,0 +1,258 @@
1
+ // PURE dimension engine for measurement mode: a feature's triangle subset ->
2
+ // a MeasureSpec (plane / cylinder / bbox). No three.js, no DOM, no kernel —
3
+ // plain typed arrays, same discipline as oracle/mesh.js. Handles both indexed
4
+ // (OCCT) and non-indexed (Manifold) payloads.
5
+ //
6
+ // Spec shapes (anchors are 3D points in the delivered geometry's own frame —
7
+ // the orchestrator projects them through mesh.matrixWorld, which is what makes
8
+ // dims ride the pose fast path and animations):
9
+ // plane { kind, values: {width, height}, anchors: {width:{a,b}, height:{a,b}, normal} }
10
+ // cylinder { kind, values: {diameter, depth, partial}, anchors: {center, axis, top, bottom, rimDir} }
11
+ // bbox { kind, values: {w, d, h}, anchors: {min, max} }
12
+
13
+ const COS_3DEG = 0.99863; // same axis-snap threshold as selection/resolve.js
14
+ const PLANAR_COS = 0.999999; // ~1.4e-3 rad: all normals agree -> planar
15
+ const AXIS_DOT_MAX = 0.05; // wall normals ⊥ axis within ~3°
16
+ const RADIUS_TOL = 0.02; // radial residual: 2% of radius
17
+ const FULL_ARC_DEG = 300; // coverage below this reads R, not ⌀
18
+
19
+ const q2 = (x) => { const r = Math.round(x * 100) / 100; return r === 0 ? 0 : r; };
20
+ export const fmtMm = (v) => v.toFixed(2);
21
+
22
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
23
+ const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
24
+ const scale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
25
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
26
+ const cross = (a, b) => [
27
+ a[1] * b[2] - a[2] * b[1],
28
+ a[2] * b[0] - a[0] * b[2],
29
+ a[0] * b[1] - a[1] * b[0],
30
+ ];
31
+ const norm = (a) => {
32
+ const l = Math.hypot(a[0], a[1], a[2]);
33
+ return l > 0 ? [a[0] / l, a[1] / l, a[2] / l] : [0, 0, 0];
34
+ };
35
+
36
+ // Iterate the triangles of one feature: yields [a, b, c] vertex triples.
37
+ function* featureTris({ positions, indices, featureIds }, featureId) {
38
+ const vert = indices
39
+ ? (t, v) => { const i = indices[t * 3 + v] * 3; return [positions[i], positions[i + 1], positions[i + 2]]; }
40
+ : (t, v) => { const i = (t * 3 + v) * 3; return [positions[i], positions[i + 1], positions[i + 2]]; };
41
+ for (let t = 0; t < featureIds.length; t++) {
42
+ if (featureIds[t] !== featureId) continue;
43
+ yield [vert(t, 0), vert(t, 1), vert(t, 2)];
44
+ }
45
+ }
46
+
47
+ export function unionBounds(list) {
48
+ return list.reduce(
49
+ (acc, b) => ({
50
+ min: acc.min.map((v, i) => Math.min(v, b.min[i])),
51
+ max: acc.max.map((v, i) => Math.max(v, b.max[i])),
52
+ }),
53
+ { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] },
54
+ );
55
+ }
56
+
57
+ export function bboxSpec(min, max) {
58
+ return {
59
+ kind: "bbox",
60
+ values: { w: q2(max[0] - min[0]), d: q2(max[1] - min[1]), h: q2(max[2] - min[2]) },
61
+ anchors: { min: [...min], max: [...max] },
62
+ };
63
+ }
64
+
65
+ function vertexBounds(tris) {
66
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
67
+ for (const tri of tris) for (const p of tri) for (let i = 0; i < 3; i++) {
68
+ if (p[i] < min[i]) min[i] = p[i];
69
+ if (p[i] > max[i]) max[i] = p[i];
70
+ }
71
+ return { min, max };
72
+ }
73
+
74
+ // Axis-snap a unit normal (COS_3DEG idiom from selection/resolve.js).
75
+ function snapAxis(n) {
76
+ let ai = 0;
77
+ if (Math.abs(n[1]) > Math.abs(n[ai])) ai = 1;
78
+ if (Math.abs(n[2]) > Math.abs(n[ai])) ai = 2;
79
+ if (Math.abs(n[ai]) < COS_3DEG) return null;
80
+ const axis = [0, 0, 0];
81
+ axis[ai] = n[ai] > 0 ? 1 : -1;
82
+ return axis;
83
+ }
84
+
85
+ function planeSpec(tris, normals) {
86
+ // area-weighted mean normal
87
+ let acc = [0, 0, 0];
88
+ for (const { n, area } of normals) acc = add(acc, scale(n, area));
89
+ const mean = norm(acc);
90
+ for (const { n } of normals) if (dot(n, mean) < PLANAR_COS) return null;
91
+
92
+ // Basis: axis-snapped normal -> the other two GLOBAL axes (a box face reads
93
+ // W×H, not a PCA-tilted pair). Otherwise: dominant in-plane edge direction.
94
+ const snapped = snapAxis(mean);
95
+ let u, v;
96
+ if (snapped) {
97
+ const ai = snapped.findIndex((c) => c !== 0);
98
+ u = [0, 0, 0]; u[(ai + 1) % 3] = 1;
99
+ v = [0, 0, 0]; v[(ai + 2) % 3] = 1;
100
+ } else {
101
+ let best = null, bestLen = -1;
102
+ for (const [a, b, c] of tris) {
103
+ for (const e of [sub(b, a), sub(c, b), sub(a, c)]) {
104
+ const l = Math.hypot(e[0], e[1], e[2]);
105
+ if (l > bestLen) { bestLen = l; best = e; }
106
+ }
107
+ }
108
+ u = norm(sub(best, scale(mean, dot(best, mean)))); // project into plane
109
+ v = norm(cross(mean, u));
110
+ }
111
+
112
+ const c0 = tris[0][0];
113
+ let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity;
114
+ for (const tri of tris) for (const p of tri) {
115
+ const d = sub(p, c0);
116
+ const uu = dot(d, u), vv = dot(d, v);
117
+ if (uu < uMin) uMin = uu; if (uu > uMax) uMax = uu;
118
+ if (vv < vMin) vMin = vv; if (vv > vMax) vMax = vv;
119
+ }
120
+ const corner = (uu, vv) => add(c0, add(scale(u, uu), scale(v, vv)));
121
+ return {
122
+ kind: "plane",
123
+ values: { width: q2(uMax - uMin), height: q2(vMax - vMin) },
124
+ anchors: {
125
+ width: { a: corner(uMin, vMin), b: corner(uMax, vMin) },
126
+ height: { a: corner(uMax, vMin), b: corner(uMax, vMax) },
127
+ normal: snapped ?? mean.map(q2),
128
+ },
129
+ };
130
+ }
131
+
132
+ function cylinderSpec(tris, normals) {
133
+ // Candidate axes from cross products of well-separated normal pairs, scored
134
+ // by how much triangle area agrees the candidate is ⊥ to it. A single seeded
135
+ // pair is not robust: when a feature carries wall AND cap triangles, a
136
+ // wall×cap pair yields a tangent to the cylinder, not its axis.
137
+ const stride = Math.max(1, Math.floor(normals.length / 16));
138
+ const sample = [];
139
+ for (let i = 0; i < normals.length; i += stride) sample.push(normals[i].n);
140
+ let axis = null, bestScore = 0;
141
+ for (let i = 0; i < sample.length; i++) {
142
+ for (let j = i + 1; j < sample.length; j++) {
143
+ if (Math.abs(dot(sample[i], sample[j])) > 0.95) continue;
144
+ const cand = norm(cross(sample[i], sample[j]));
145
+ if (cand[0] === 0 && cand[1] === 0 && cand[2] === 0) continue;
146
+ let score = 0;
147
+ for (const { n: tn, area } of normals) {
148
+ if (Math.abs(dot(tn, cand)) <= AXIS_DOT_MAX) score += area;
149
+ }
150
+ if (score > bestScore) { bestScore = score; axis = cand; }
151
+ }
152
+ }
153
+ if (!axis) return null;
154
+
155
+ // Wall triangles only (a labeled boss's end caps attribute to the same
156
+ // feature — their normals are along the axis; exclude them from the fit).
157
+ const wallVerts = [];
158
+ for (let t = 0; t < tris.length; t++) {
159
+ if (Math.abs(dot(normals[t].n, axis)) > AXIS_DOT_MAX) continue;
160
+ wallVerts.push(...tris[t]);
161
+ }
162
+ if (wallVerts.length < 9) return null; // fewer than 3 wall triangles: not a cylinder
163
+
164
+ // Circle fit (Kåsa least-squares) in the plane ⊥ axis: for a partial arc
165
+ // the vertex centroid is NOT on the axis, so a centroid-based radius check
166
+ // wrongly rejects arcs. Solve x²+y² = Ax + By + C over the projected wall
167
+ // vertices; center (A/2, B/2), r = sqrt(C + |center|²).
168
+ const e = Math.abs(axis[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
169
+ const uBasis = norm(cross(axis, e));
170
+ const vBasis = cross(axis, uBasis);
171
+ const p0 = wallVerts[0];
172
+ const pts = wallVerts.map((p) => {
173
+ const d = sub(p, p0);
174
+ return [dot(d, uBasis), dot(d, vBasis)];
175
+ });
176
+ let sxx = 0, sxy = 0, syy = 0, sx = 0, sy = 0, sxz = 0, syz = 0, sz = 0;
177
+ for (const [x, y] of pts) {
178
+ const z = x * x + y * y;
179
+ sxx += x * x; sxy += x * y; syy += y * y; sx += x; sy += y;
180
+ sxz += x * z; syz += y * z; sz += z;
181
+ }
182
+ const n = pts.length;
183
+ // Cramer's rule on the 3x3 normal equations [[sxx,sxy,sx],[sxy,syy,sy],[sx,sy,n]] · [A,B,C]ᵀ = [sxz,syz,sz]ᵀ
184
+ const det = sxx * (syy * n - sy * sy) - sxy * (sxy * n - sy * sx) + sx * (sxy * sy - syy * sx);
185
+ if (Math.abs(det) < 1e-12) return null;
186
+ const A = (sxz * (syy * n - sy * sy) - sxy * (syz * n - sy * sz) + sx * (syz * sy - syy * sz)) / det;
187
+ const B = (sxx * (syz * n - sy * sz) - sxz * (sxy * n - sy * sx) + sx * (sxy * sz - syz * sx)) / det;
188
+ const Cc = (sxx * (syy * sz - syz * sy) - sxy * (sxy * sz - syz * sx) + sxz * (sxy * sy - syy * sx)) / det;
189
+ const cx = A / 2, cy = B / 2;
190
+ const rSquared = Cc + cx * cx + cy * cy;
191
+ if (rSquared <= 0) return null;
192
+ const r = Math.sqrt(rSquared);
193
+ // Axis point in 3D: the fitted center lifted back out of the projection plane.
194
+ const c = add(p0, add(scale(uBasis, cx), scale(vBasis, cy)));
195
+ const radial = (p) => { const d = sub(p, c); return sub(d, scale(axis, dot(d, axis))); };
196
+ for (const p of wallVerts) {
197
+ const ri = Math.hypot(...radial(p));
198
+ if (Math.abs(ri - r) > Math.max(RADIUS_TOL * r, 1e-6)) return null;
199
+ }
200
+
201
+ // Depth from ALL feature vertices (caps included) along the axis.
202
+ let tMin = Infinity, tMax = -Infinity;
203
+ for (const tri of tris) for (const p of tri) {
204
+ const t = dot(sub(p, c), axis);
205
+ if (t < tMin) tMin = t; if (t > tMax) tMax = t;
206
+ }
207
+
208
+ // Angular coverage of wall vertices -> ⌀ vs R notation.
209
+ const u = norm(radial(wallVerts[0]));
210
+ const v = cross(axis, u);
211
+ const angles = wallVerts
212
+ .map((p) => { const rd = radial(p); return Math.atan2(dot(rd, v), dot(rd, u)); })
213
+ .sort((a, b) => a - b);
214
+ let maxGap = 2 * Math.PI + angles[0] - angles[angles.length - 1];
215
+ let gapEnd = angles[0]; // angle where the covered span begins (after the largest gap)
216
+ for (let i = 1; i < angles.length; i++) {
217
+ const g = angles[i] - angles[i - 1];
218
+ if (g > maxGap) { maxGap = g; gapEnd = angles[i]; }
219
+ }
220
+ const coverageDeg = 360 - (maxGap * 180) / Math.PI;
221
+ // Radial direction at the angular midpoint of the covered span — where the
222
+ // wall actually is; the placer hangs R-leaders (and degenerate-view ⌀ dims)
223
+ // off it so they always spring from real surface. For a full circle
224
+ // (maxGap ~ the seam between last and first sample) this still yields a
225
+ // stable default direction.
226
+ const midAngle = gapEnd + (2 * Math.PI - maxGap) / 2;
227
+ const rimDir = norm(add(scale(u, Math.cos(midAngle)), scale(v, Math.sin(midAngle))));
228
+
229
+ const snapped = snapAxis(axis);
230
+ const ax = snapped ?? axis.map(q2);
231
+ return {
232
+ kind: "cylinder",
233
+ values: { diameter: q2(2 * r), depth: q2(tMax - tMin), partial: coverageDeg < FULL_ARC_DEG },
234
+ anchors: {
235
+ center: add(c, scale(axis, (tMin + tMax) / 2)).map(q2),
236
+ axis: ax,
237
+ bottom: add(c, scale(axis, tMin)).map(q2),
238
+ top: add(c, scale(axis, tMax)).map(q2),
239
+ rimDir: rimDir.map(q2),
240
+ },
241
+ };
242
+ }
243
+
244
+ export function classifyFeature(mesh, featureId) {
245
+ const tris = [...featureTris(mesh, featureId)];
246
+ if (tris.length === 0) return null;
247
+ const normals = tris.map(([a, b, c]) => {
248
+ const n = cross(sub(b, a), sub(c, a));
249
+ const area = Math.hypot(n[0], n[1], n[2]) / 2;
250
+ return { n: norm(n), area };
251
+ });
252
+ const plane = planeSpec(tris, normals);
253
+ if (plane) return plane;
254
+ const cyl = cylinderSpec(tris, normals);
255
+ if (cyl) return cyl;
256
+ const { min, max } = vertexBounds(tris);
257
+ return bboxSpec(min, max);
258
+ }