partforge 0.53.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +12 -2
- package/package.json +1 -1
- package/src/framework/app.css +27 -2
- package/src/framework/cutaway-controls.js +4 -27
- package/src/framework/cutaway-gizmo-scene.js +3 -1
- package/src/framework/jobs.js +75 -2
- package/src/framework/measure/dim3-place.js +455 -0
- package/src/framework/measure/dim3-scene.js +438 -0
- package/src/framework/measure/feature-dims.js +258 -0
- package/src/framework/measure/measure-controls.js +110 -0
- package/src/framework/measure/measure-mode.js +546 -0
- package/src/framework/measure/param-link.js +32 -0
- package/src/framework/measure/pins.js +36 -0
- package/src/framework/mount.js +58 -5
- package/src/framework/oracle/match.js +263 -0
- package/src/framework/oracle/measure.js +5 -1
- package/src/framework/oracle/silhouette.js +146 -0
- package/src/framework/panel/render.js +41 -0
- package/src/framework/selection/drag-tracker.js +39 -0
- package/src/framework/selection/feature-highlight.js +99 -0
- package/src/framework/selection/hover.js +14 -90
- package/src/framework/selection/index.js +1 -0
- package/src/framework/selection/pick.js +11 -37
- package/src/framework/teardown.js +29 -0
- package/src/framework/viewer.js +25 -1
- package/src/testing.js +5 -0
- package/types/index.d.ts +16 -0
- package/types/testing.d.ts +116 -1
|
@@ -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
|
+
}
|