@toolpath/viewer 0.2.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,2537 @@
1
+ // src/engine/engine-part.tsx
2
+ import { useEffect as useEffect5, useMemo as useMemo6 } from "react";
3
+
4
+ // src/part-mesh.tsx
5
+ import { useThree as useThree6 } from "@react-three/fiber";
6
+ import { useCallback as useCallback3, useEffect as useEffect4, useLayoutEffect, useMemo as useMemo5, useRef as useRef5 } from "react";
7
+ import { Vector3 as Vector38 } from "three";
8
+
9
+ // src/model/directions.ts
10
+ var EPSILON = 1e-6;
11
+ function sameDirection(a, b) {
12
+ return Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON && Math.abs(a.z - b.z) < EPSILON;
13
+ }
14
+ function directionIndexOf(model, direction) {
15
+ return model.candidateDirections.findIndex((candidate) => sameDirection(candidate, direction));
16
+ }
17
+ function groupByDirection(model) {
18
+ const groups = model.candidateDirections.map((direction, index) => ({
19
+ index,
20
+ direction,
21
+ features: []
22
+ }));
23
+ const unmatched = [];
24
+ for (const feature of model.features) {
25
+ const index = directionIndexOf(model, feature.machiningDirection);
26
+ const group = index === -1 ? void 0 : groups[index];
27
+ if (group) group.features.push(feature);
28
+ else unmatched.push(feature);
29
+ }
30
+ if (unmatched.length === 0) return groups;
31
+ return [...groups, { index: -1, direction: { x: 0, y: 0, z: 0 }, features: unmatched }];
32
+ }
33
+ function directionLabel(direction) {
34
+ const axes = [
35
+ ["X", direction.x],
36
+ ["Y", direction.y],
37
+ ["Z", direction.z]
38
+ ];
39
+ const nonZero = axes.filter(([, value]) => Math.abs(value) > EPSILON);
40
+ const [axis] = nonZero;
41
+ if (nonZero.length === 1 && axis && Math.abs(Math.abs(axis[1]) - 1) < EPSILON) {
42
+ return `${axis[1] > 0 ? "+" : "\u2212"}${axis[0]}`;
43
+ }
44
+ return axes.map(([, value]) => trim(value)).join(", ");
45
+ }
46
+ function trim(value) {
47
+ return Number.parseFloat(value.toFixed(3)).toString();
48
+ }
49
+
50
+ // src/model/surfaces.ts
51
+ var CONTINUES_WITHIN = Math.cos(1 * Math.PI / 180);
52
+ var MERGEABLE = "Plane";
53
+ var worked = /* @__PURE__ */ new WeakMap();
54
+ function visualSurfaces(geometry, regions) {
55
+ const already = worked.get(geometry);
56
+ if (already && already.regions === regions) return already.of;
57
+ const found = computeSurfaces(geometry, regions);
58
+ worked.set(geometry, { regions, of: found });
59
+ return found;
60
+ }
61
+ function computeSurfaces(geometry, regions) {
62
+ const position = geometry.getAttribute("position");
63
+ const surfaces = /* @__PURE__ */ new Map();
64
+ for (const region of regions) surfaces.set(region.idx, region.idx);
65
+ if (!position || geometry.index) return surfaces;
66
+ const triangleCount = Math.floor(position.count / 3);
67
+ const regionOf = new Int32Array(triangleCount).fill(-1);
68
+ const kindOf = /* @__PURE__ */ new Map();
69
+ for (const region of regions) {
70
+ kindOf.set(region.idx, region.shapeKind);
71
+ const end = Math.min(region.triangles.end, triangleCount);
72
+ for (let triangle = region.triangles.start; triangle < end; triangle += 1) {
73
+ regionOf[triangle] = region.idx;
74
+ }
75
+ }
76
+ const normals = facetNormals(geometry, triangleCount);
77
+ const parent = new Map(surfaces);
78
+ const find = (idx) => {
79
+ let root = idx;
80
+ while (parent.get(root) !== root) root = parent.get(root) ?? root;
81
+ let walk = idx;
82
+ while (parent.get(walk) !== root) {
83
+ const next = parent.get(walk) ?? root;
84
+ parent.set(walk, root);
85
+ walk = next;
86
+ }
87
+ return root;
88
+ };
89
+ const union = (a, b) => {
90
+ const [rootA, rootB] = [find(a), find(b)];
91
+ if (rootA !== rootB) parent.set(rootB, rootA);
92
+ };
93
+ const seen = /* @__PURE__ */ new Map();
94
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
95
+ for (let corner = 0; corner < 3; corner += 1) {
96
+ const a = triangle * 3 + corner;
97
+ const b = triangle * 3 + (corner + 1) % 3;
98
+ const id = edgeKey(position, a, b);
99
+ const met = seen.get(id);
100
+ if (met === void 0) {
101
+ seen.set(id, triangle);
102
+ continue;
103
+ }
104
+ const here = regionOf[triangle] ?? -1;
105
+ const there = regionOf[met] ?? -1;
106
+ if (here === -1 || there === -1 || here === there) continue;
107
+ if (kindOf.get(here) !== kindOf.get(there)) continue;
108
+ const facing = normals[triangle * 3] * normals[met * 3] + normals[triangle * 3 + 1] * normals[met * 3 + 1] + normals[triangle * 3 + 2] * normals[met * 3 + 2];
109
+ if (kindOf.get(here) !== MERGEABLE) continue;
110
+ if (facing >= CONTINUES_WITHIN) union(here, there);
111
+ }
112
+ }
113
+ for (const region of regions) surfaces.set(region.idx, find(region.idx));
114
+ return surfaces;
115
+ }
116
+ function facetNormals(geometry, triangleCount) {
117
+ const position = geometry.getAttribute("position");
118
+ const normals = new Float32Array(triangleCount * 3);
119
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
120
+ const at = triangle * 3;
121
+ const ax = position.getX(at);
122
+ const ay = position.getY(at);
123
+ const az = position.getZ(at);
124
+ const bx = position.getX(at + 1) - ax;
125
+ const by = position.getY(at + 1) - ay;
126
+ const bz = position.getZ(at + 1) - az;
127
+ const cx = position.getX(at + 2) - ax;
128
+ const cy = position.getY(at + 2) - ay;
129
+ const cz = position.getZ(at + 2) - az;
130
+ const nx = by * cz - bz * cy;
131
+ const ny = bz * cx - bx * cz;
132
+ const nz = bx * cy - by * cx;
133
+ const length = Math.hypot(nx, ny, nz) || 1;
134
+ normals[at] = nx / length;
135
+ normals[at + 1] = ny / length;
136
+ normals[at + 2] = nz / length;
137
+ }
138
+ return normals;
139
+ }
140
+ function edgeKey(position, a, b) {
141
+ const ax = position.getX(a);
142
+ const ay = position.getY(a);
143
+ const az = position.getZ(a);
144
+ const bx = position.getX(b);
145
+ const by = position.getY(b);
146
+ const bz = position.getZ(b);
147
+ const first = ax < bx || ax === bx && (ay < by || ay === by && az <= bz);
148
+ return first ? `${ax},${ay},${az}|${bx},${by},${bz}` : `${bx},${by},${bz}|${ax},${ay},${az}`;
149
+ }
150
+
151
+ // src/render/theme.ts
152
+ var HIGHLIGHT_COLORS = {
153
+ default: 16753434,
154
+ toolIssue: 9647082,
155
+ geometryIssue: 16711680
156
+ };
157
+ var DIRECTION_COLORS = [
158
+ 3900150,
159
+ // blue
160
+ 1357990,
161
+ // teal
162
+ 14239471,
163
+ // fuchsia
164
+ 440020,
165
+ // cyan
166
+ /*
167
+ * Olive, where a purple used to be.
168
+ *
169
+ * The purple sat one step from the violet a proposed feature is painted in,
170
+ * so an offer laid over work already assigned to this direction was a clash
171
+ * between "suggested" and "decided" — the two states the part is read for.
172
+ * Olive is the only hue with room: the warm ramp belongs to the difficulty
173
+ * bands, red to sharp corners, orange to faces being picked, and green to
174
+ * whatever is being looked at.
175
+ */
176
+ 6660877,
177
+ // olive
178
+ 15485081,
179
+ // pink
180
+ 6583435,
181
+ // slate
182
+ 1096065,
183
+ // emerald
184
+ 6514417
185
+ // indigo
186
+ ];
187
+ var DEFAULT_THEME = {
188
+ background: null,
189
+ hemisphereSky: 16777215,
190
+ hemisphereGround: 0,
191
+ hemisphereIntensity: 2.5,
192
+ ambient: 5067112,
193
+ ambientIntensity: 2,
194
+ part: 16777215,
195
+ partEmissive: 3948625,
196
+ /*
197
+ * A paler form of the selection orange it leads to.
198
+ *
199
+ * It was a teal, which sat between two of the nine direction colors — on a
200
+ * part painted by direction, "what the pointer is on" and "cut from way up
201
+ * number two" were the same answer. The warm end of the wheel belongs to this
202
+ * moment rather than to the plan: what is under the pointer, and what has
203
+ * been picked. Nothing in the direction cycle is warm.
204
+ */
205
+ hover: 16756838,
206
+ highlight: HIGHLIGHT_COLORS.default,
207
+ picked: 16347926,
208
+ edge: 0,
209
+ edgeOpacity: 0.5,
210
+ sectionCap: 13093848,
211
+ sectionOutline: 7057587,
212
+ sectionHandle: 15922167,
213
+ sectionHandleOutline: 3948625,
214
+ cube: 14080482,
215
+ cubeEdge: 7435917,
216
+ cubeLabel: 3948625
217
+ };
218
+ function resolveTheme(overrides) {
219
+ return { ...DEFAULT_THEME, ...overrides };
220
+ }
221
+ function themesEqual(a, b) {
222
+ return a.background === b.background && a.hemisphereSky === b.hemisphereSky && a.hemisphereGround === b.hemisphereGround && a.hemisphereIntensity === b.hemisphereIntensity && a.ambient === b.ambient && a.ambientIntensity === b.ambientIntensity && a.part === b.part && a.partEmissive === b.partEmissive && a.hover === b.hover && a.highlight === b.highlight && a.picked === b.picked && a.edge === b.edge && a.edgeOpacity === b.edgeOpacity && a.sectionCap === b.sectionCap && a.sectionOutline === b.sectionOutline && a.sectionHandle === b.sectionHandle && a.sectionHandleOutline === b.sectionHandleOutline && a.cube === b.cube && a.cubeEdge === b.cubeEdge && a.cubeLabel === b.cubeLabel;
223
+ }
224
+ function directionColor(index) {
225
+ const wrapped = index % DIRECTION_COLORS.length;
226
+ return DIRECTION_COLORS[wrapped] ?? HIGHLIGHT_COLORS.default;
227
+ }
228
+
229
+ // src/render/paint.ts
230
+ var HIGHLIGHT_WEIGHT = 0.7;
231
+ var CANDIDATE_WEIGHT = 0.4;
232
+ var HOVER_WEIGHT = 0.85;
233
+ function selectedRegions(part, layers) {
234
+ const regions = /* @__PURE__ */ new Set();
235
+ for (const tag of layers.selection ?? []) {
236
+ for (const region of part.model.regionIndex.regionsForFeature(tag)) regions.add(region);
237
+ }
238
+ return regions;
239
+ }
240
+ function applyHighlightLayers(part, layers, theme) {
241
+ part.clearPaint();
242
+ for (const highlight of layers.highlights ?? []) {
243
+ part.paintFeature(highlight.tag, highlight.color, highlight.weight ?? HIGHLIGHT_WEIGHT);
244
+ }
245
+ for (const highlight of layers.regionHighlights ?? []) {
246
+ part.paintRegion(highlight.region, highlight.color, highlight.weight ?? HIGHLIGHT_WEIGHT);
247
+ }
248
+ for (const tag of layers.candidates ?? []) {
249
+ const feature = part.model.features.find((candidate) => candidate.tag === tag);
250
+ const index = feature ? directionIndexOf(part.model, feature.machiningDirection) : -1;
251
+ part.paintFeature(tag, index === -1 ? theme.hover : directionColor(index), CANDIDATE_WEIGHT);
252
+ }
253
+ for (const tag of layers.selection ?? []) {
254
+ part.paintFeature(tag, theme.highlight, 1);
255
+ }
256
+ for (const region of layers.pickedRegions ?? []) {
257
+ part.paintRegion(region, theme.picked, 1);
258
+ }
259
+ for (const tag of layers.hoveredFeatures ?? []) {
260
+ part.paintFeature(tag, theme.hover, HOVER_WEIGHT);
261
+ }
262
+ if (layers.hoverRegion != null && !selectedRegions(part, layers).has(layers.hoverRegion)) {
263
+ part.paintRegion(layers.hoverRegion, theme.hover, HOVER_WEIGHT);
264
+ }
265
+ spreadAcrossSurfaces(part);
266
+ }
267
+ function spreadAcrossSurfaces(part) {
268
+ const surfaces = visualSurfaces(part.mesh.geometry, part.model.regions);
269
+ const claims = /* @__PURE__ */ new Map();
270
+ for (const region of part.model.regions) {
271
+ const paint = part.regionPaint(region.idx);
272
+ if (!paint || paint.weight === 0) continue;
273
+ const surface = surfaces.get(region.idx) ?? region.idx;
274
+ const claim = claims.get(surface);
275
+ if (!claim) {
276
+ claims.set(surface, { color: paint.color, weight: paint.weight });
277
+ continue;
278
+ }
279
+ if (claim === "contested") continue;
280
+ if (claim.color !== paint.color || claim.weight !== paint.weight) {
281
+ claims.set(surface, "contested");
282
+ }
283
+ }
284
+ for (const region of part.model.regions) {
285
+ const paint = part.regionPaint(region.idx);
286
+ if (paint && paint.weight > 0) continue;
287
+ const claim = claims.get(surfaces.get(region.idx) ?? region.idx);
288
+ if (claim && claim !== "contested") part.paintRegion(region.idx, claim.color, claim.weight);
289
+ }
290
+ }
291
+
292
+ // src/render/section.ts
293
+ import { OrthographicCamera, Plane, Vector3 } from "three";
294
+ var SECTION_RENDER_ORDER = {
295
+ stencil: 1,
296
+ cap: 2,
297
+ handle: 6
298
+ };
299
+ var START_DEPTH = 5e-3;
300
+ var HANDLE_PIXELS = 78;
301
+ var EPSILON2 = 1e-9;
302
+ var ARROW_AXIS = new Vector3(0, 1, 0);
303
+ var PICKED_SURFACE_LABEL = "Part surface";
304
+ function unit(v) {
305
+ const vector = new Vector3(v.x, v.y, v.z);
306
+ return vector.lengthSq() === 0 ? vector.set(0, 0, 1) : vector.normalize();
307
+ }
308
+ function clamp01(value) {
309
+ return Math.min(1, Math.max(0, value));
310
+ }
311
+ function sectionBounds(box, normal) {
312
+ const axis = unit(normal);
313
+ const corner = new Vector3();
314
+ let low = Number.POSITIVE_INFINITY;
315
+ let high = Number.NEGATIVE_INFINITY;
316
+ for (let i = 0; i < 8; i += 1) {
317
+ corner.set(
318
+ i & 1 ? box.max.x : box.min.x,
319
+ i & 2 ? box.max.y : box.min.y,
320
+ i & 4 ? box.max.z : box.min.z
321
+ );
322
+ const distance = corner.dot(axis);
323
+ low = Math.min(low, distance);
324
+ high = Math.max(high, distance);
325
+ }
326
+ const margin = Math.max((high - low) * 5e-3, 1e-6);
327
+ return { min: -(high + margin), max: -(low - margin) };
328
+ }
329
+ function sectionConstant(bounds, t) {
330
+ return bounds.max + clamp01(t) * (bounds.min - bounds.max);
331
+ }
332
+ function sectionOffset(bounds, constant) {
333
+ const span = bounds.min - bounds.max;
334
+ return span === 0 ? 0 : clamp01((constant - bounds.max) / span);
335
+ }
336
+ function sectionDepth(normal, anchor, constant) {
337
+ return -unit(normal).dot(new Vector3(anchor.x, anchor.y, anchor.z)) - constant;
338
+ }
339
+ function sectionDepthConstant(normal, anchor, depth) {
340
+ return sectionDepth(normal, anchor, depth);
341
+ }
342
+ function sectionDepthRange(bounds, normal, anchor) {
343
+ return {
344
+ min: sectionDepth(normal, anchor, bounds.max),
345
+ max: sectionDepth(normal, anchor, bounds.min)
346
+ };
347
+ }
348
+ function sectionFromPick(surface, label = PICKED_SURFACE_LABEL) {
349
+ return {
350
+ normal: { x: -surface.normal.x, y: -surface.normal.y, z: -surface.normal.z },
351
+ point: { x: surface.point.x, y: surface.point.y, z: surface.point.z },
352
+ label
353
+ };
354
+ }
355
+ function pickedStartDepth(box) {
356
+ return box.getSize(new Vector3()).length() * START_DEPTH;
357
+ }
358
+ function sectionPlane(box, normal, offset, into = new Plane()) {
359
+ const axis = unit(normal);
360
+ return into.set(axis, sectionConstant(sectionBounds(box, normal), offset));
361
+ }
362
+ function screenLength(camera, point, viewport, pixels) {
363
+ const height = viewport.height > 0 ? viewport.height : 1;
364
+ const zoom = camera.zoom || 1;
365
+ const visible = camera instanceof OrthographicCamera ? (camera.top - camera.bottom) / zoom : 2 * Math.tan(camera.fov / 2 * Math.PI / 180) * Math.max(camera.position.distanceTo(point), EPSILON2) / zoom;
366
+ return visible / height * pixels;
367
+ }
368
+ function dragPlane(axis, view, point, into = new Plane()) {
369
+ const along = axis.lengthSq() === 0 ? ARROW_AXIS.clone() : axis.clone().normalize();
370
+ const side = new Vector3().crossVectors(view, along);
371
+ if (side.lengthSq() < EPSILON2) side.copy(perpendicular(along));
372
+ return into.setFromNormalAndCoplanarPoint(side.cross(along).normalize(), point);
373
+ }
374
+ function perpendicular(axis) {
375
+ const candidate = Math.abs(axis.x) < 0.9 ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0);
376
+ return candidate.cross(axis).normalize();
377
+ }
378
+
379
+ // src/tap.tsx
380
+ import { useThree } from "@react-three/fiber";
381
+ import { useEffect, useMemo, useRef } from "react";
382
+
383
+ // src/render/tap.ts
384
+ var TAP_SLOP = 4;
385
+ function movedFar(from, to, slop = TAP_SLOP) {
386
+ return Math.hypot(to.clientX - from.clientX, to.clientY - from.clientY) > slop;
387
+ }
388
+ function trackTaps(element) {
389
+ let start = null;
390
+ const down = (event) => {
391
+ start = { clientX: event.clientX, clientY: event.clientY };
392
+ };
393
+ element.addEventListener("pointerdown", down, { capture: true });
394
+ return {
395
+ // No recorded press means the gesture began somewhere else — over a panel,
396
+ // or before this element existed. Not a click on this element.
397
+ isTap: (event) => start !== null && !movedFar(start, event),
398
+ dispose: () => element.removeEventListener("pointerdown", down, { capture: true })
399
+ };
400
+ }
401
+
402
+ // src/tap.tsx
403
+ function useTapGuard() {
404
+ const domElement = useThree((state) => state.gl.domElement);
405
+ const tracker = useRef(null);
406
+ useEffect(() => {
407
+ const tracked = trackTaps(domElement);
408
+ tracker.current = tracked;
409
+ return () => {
410
+ tracked.dispose();
411
+ if (tracker.current === tracked) tracker.current = null;
412
+ };
413
+ }, [domElement]);
414
+ return useMemo(() => (event) => tracker.current?.isTap(event) ?? true, []);
415
+ }
416
+
417
+ // src/render/part.ts
418
+ import {
419
+ Box3,
420
+ Color,
421
+ DataTexture,
422
+ Float32BufferAttribute as Float32BufferAttribute2,
423
+ Group,
424
+ LineBasicMaterial,
425
+ LineSegments,
426
+ Mesh,
427
+ MeshLambertMaterial,
428
+ RGBAFormat,
429
+ UnsignedByteType,
430
+ Vector3 as Vector32
431
+ } from "three";
432
+
433
+ // src/render/edges.ts
434
+ import { BufferGeometry as Buffer, Float32BufferAttribute } from "three";
435
+ function regionEdgesGeometry(geometry, model) {
436
+ const position = geometry.getAttribute("position");
437
+ const edges = new Buffer();
438
+ edges.setAttribute("position", new Float32BufferAttribute([], 3));
439
+ if (!position || geometry.index) return edges;
440
+ const triangleCount = Math.floor(position.count / 3);
441
+ const surfaces = visualSurfaces(geometry, model.regions);
442
+ const regionOf = new Int32Array(triangleCount).fill(-1);
443
+ for (const region of model.regions) {
444
+ const end = Math.min(region.triangles.end, triangleCount);
445
+ for (let triangle = region.triangles.start; triangle < end; triangle += 1) {
446
+ regionOf[triangle] = surfaces.get(region.idx) ?? region.idx;
447
+ }
448
+ }
449
+ const seen = /* @__PURE__ */ new Map();
450
+ const key = (a, b) => {
451
+ const ax = position.getX(a);
452
+ const ay = position.getY(a);
453
+ const az = position.getZ(a);
454
+ const bx = position.getX(b);
455
+ const by = position.getY(b);
456
+ const bz = position.getZ(b);
457
+ const first = ax < bx || ax === bx && (ay < by || ay === by && az <= bz);
458
+ return first ? `${ax},${ay},${az}|${bx},${by},${bz}` : `${bx},${by},${bz}|${ax},${ay},${az}`;
459
+ };
460
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
461
+ const region = regionOf[triangle] ?? -1;
462
+ const corners = [triangle * 3, triangle * 3 + 1, triangle * 3 + 2];
463
+ for (let i = 0; i < 3; i += 1) {
464
+ const a = corners[i];
465
+ const b = corners[(i + 1) % 3];
466
+ const id = key(a, b);
467
+ const found = seen.get(id);
468
+ if (!found) {
469
+ seen.set(id, { region, a, b, shared: false });
470
+ continue;
471
+ }
472
+ found.shared = found.region === region;
473
+ }
474
+ }
475
+ const points = [];
476
+ for (const edge of seen.values()) {
477
+ if (edge.shared) continue;
478
+ points.push(
479
+ position.getX(edge.a),
480
+ position.getY(edge.a),
481
+ position.getZ(edge.a),
482
+ position.getX(edge.b),
483
+ position.getY(edge.b),
484
+ position.getZ(edge.b)
485
+ );
486
+ }
487
+ edges.setAttribute("position", new Float32BufferAttribute(points, 3));
488
+ return edges;
489
+ }
490
+
491
+ // src/render/part.ts
492
+ var REGION_ATTRIBUTE = "aRegion";
493
+ var EMISSIVE_MIX = 0.4;
494
+ function buildRegionTexels(model) {
495
+ return new Map(model.regions.map((region, column) => [region.idx, column]));
496
+ }
497
+ function buildRegionAttribute(model, texels, vertexCount) {
498
+ const values = new Float32Array(vertexCount).fill(model.regions.length);
499
+ for (const region of model.regions) {
500
+ const column = texels.get(region.idx);
501
+ if (column === void 0) continue;
502
+ values.fill(
503
+ column,
504
+ Math.min(region.triangles.start * 3, vertexCount),
505
+ Math.min(region.triangles.end * 3, vertexCount)
506
+ );
507
+ }
508
+ return values;
509
+ }
510
+ function createPart(model, geometry, theme) {
511
+ const position = geometry.getAttribute("position");
512
+ const texels = buildRegionTexels(model);
513
+ geometry.setAttribute(
514
+ REGION_ATTRIBUTE,
515
+ new Float32BufferAttribute2(buildRegionAttribute(model, texels, position.count), 1)
516
+ );
517
+ const width = model.regions.length + 1;
518
+ const state = new Uint8Array(width * 4);
519
+ const stateTexture = new DataTexture(state, width, 1, RGBAFormat, UnsignedByteType);
520
+ stateTexture.needsUpdate = true;
521
+ const material = new MeshLambertMaterial({
522
+ color: theme.part,
523
+ emissive: theme.partEmissive,
524
+ // Pushes the surface back so the edge lines below sit on top of it rather
525
+ // than z-fighting with it.
526
+ polygonOffset: true,
527
+ polygonOffsetFactor: 1,
528
+ polygonOffsetUnits: 1
529
+ });
530
+ material.onBeforeCompile = (shader) => {
531
+ shader.uniforms["uRegionState"] = { value: stateTexture };
532
+ shader.vertexShader = shader.vertexShader.replace(
533
+ "#include <common>",
534
+ `#include <common>
535
+ attribute float ${REGION_ATTRIBUTE};
536
+ varying float vRegion;`
537
+ ).replace(
538
+ "#include <begin_vertex>",
539
+ `#include <begin_vertex>
540
+ vRegion = ${REGION_ATTRIBUTE};`
541
+ );
542
+ shader.fragmentShader = shader.fragmentShader.replace(
543
+ "#include <common>",
544
+ `#include <common>
545
+ uniform sampler2D uRegionState;
546
+ varying float vRegion;
547
+ vec4 regionState;`
548
+ ).replace(
549
+ "#include <color_fragment>",
550
+ `#include <color_fragment>
551
+ regionState = texelFetch(uRegionState, ivec2(int(vRegion + 0.5), 0), 0);
552
+ diffuseColor.rgb = mix(diffuseColor.rgb, regionState.rgb, regionState.a);`
553
+ ).replace(
554
+ "#include <emissivemap_fragment>",
555
+ `#include <emissivemap_fragment>
556
+ totalEmissiveRadiance = mix(
557
+ totalEmissiveRadiance,
558
+ regionState.rgb * ${EMISSIVE_MIX.toFixed(2)},
559
+ regionState.a
560
+ );`
561
+ );
562
+ };
563
+ const mesh = new Mesh(geometry, material);
564
+ mesh.renderOrder = 3;
565
+ const edgeGeometry = regionEdgesGeometry(geometry, model);
566
+ const edgeMaterial = new LineBasicMaterial({
567
+ color: theme.edge,
568
+ opacity: theme.edgeOpacity,
569
+ transparent: true
570
+ });
571
+ const edges = new LineSegments(edgeGeometry, edgeMaterial);
572
+ edges.renderOrder = 4;
573
+ edges.raycast = () => {
574
+ };
575
+ const object = new Group();
576
+ object.add(mesh, edges);
577
+ const scratchColor = new Color();
578
+ const scratchVector = new Vector32();
579
+ const paintRegion = (region, color, weight) => {
580
+ const column = texels.get(region);
581
+ if (column === void 0) return;
582
+ scratchColor.setHex(color);
583
+ const offset = column * 4;
584
+ state[offset] = Math.round(scratchColor.r * 255);
585
+ state[offset + 1] = Math.round(scratchColor.g * 255);
586
+ state[offset + 2] = Math.round(scratchColor.b * 255);
587
+ state[offset + 3] = Math.round(Math.min(Math.max(weight, 0), 1) * 255);
588
+ stateTexture.needsUpdate = true;
589
+ };
590
+ return {
591
+ object,
592
+ mesh,
593
+ edges,
594
+ model,
595
+ paintRegion,
596
+ regionPaint(region) {
597
+ const column = texels.get(region);
598
+ if (column === void 0) return null;
599
+ const offset = column * 4;
600
+ scratchColor.setRGB(
601
+ (state[offset] ?? 0) / 255,
602
+ (state[offset + 1] ?? 0) / 255,
603
+ (state[offset + 2] ?? 0) / 255
604
+ );
605
+ return { color: scratchColor.getHex(), weight: (state[offset + 3] ?? 0) / 255 };
606
+ },
607
+ paintFeature(tag, color, weight) {
608
+ for (const region of model.regionIndex.regionsForFeature(tag)) {
609
+ paintRegion(region, color, weight);
610
+ }
611
+ },
612
+ clearPaint() {
613
+ state.fill(0);
614
+ stateTexture.needsUpdate = true;
615
+ },
616
+ boxForFeature(tag) {
617
+ const regions = model.regionIndex.regionsForFeature(tag);
618
+ if (regions.length === 0) return null;
619
+ const box = new Box3();
620
+ for (const region of regions) {
621
+ const range = model.regionIndex.rangeForRegion(region);
622
+ if (!range) continue;
623
+ for (let vertex = range.start * 3; vertex < range.end * 3 && vertex < position.count; vertex += 1) {
624
+ box.expandByPoint(scratchVector.fromBufferAttribute(position, vertex));
625
+ }
626
+ }
627
+ return box.isEmpty() ? null : box;
628
+ },
629
+ setClippingPlanes(planes) {
630
+ const value = planes === null ? null : [...planes];
631
+ material.clippingPlanes = value;
632
+ edgeMaterial.clippingPlanes = value;
633
+ },
634
+ setTheme(next) {
635
+ material.color.setHex(next.part);
636
+ material.emissive.setHex(next.partEmissive);
637
+ edgeMaterial.color.setHex(next.edge);
638
+ edgeMaterial.opacity = next.edgeOpacity;
639
+ },
640
+ dispose() {
641
+ object.clear();
642
+ geometry.deleteAttribute(REGION_ATTRIBUTE);
643
+ material.dispose();
644
+ edgeGeometry.dispose();
645
+ edgeMaterial.dispose();
646
+ stateTexture.dispose();
647
+ }
648
+ };
649
+ }
650
+
651
+ // src/render/selection.ts
652
+ var FEATURE_TYPE_RANKS = [
653
+ ["blind_hole", "through_hole", "filleted_blind_hole"],
654
+ ["pocket", "open_pocket", "boss", "filleted_boss", "undercut_tslot", "undercut_dovetail", "sink"],
655
+ ["chamfer", "inner_fillet", "outer_fillet", "contour_surface", "slanted_face"],
656
+ // Unrecognized types sit here, ahead of the bulk surfaces: `featureType` is
657
+ // an open set, and a type a future kernel adds is far likelier to be a
658
+ // specific machined feature than a new kind of wall.
659
+ [],
660
+ ["wall", "face"],
661
+ ["profile"]
662
+ ];
663
+ var RANK_BY_TYPE = new Map(
664
+ FEATURE_TYPE_RANKS.flatMap((types, rank) => types.map((type) => [type, rank]))
665
+ );
666
+ var UNKNOWN_RANK = FEATURE_TYPE_RANKS.findIndex((types) => types.length === 0);
667
+ function featureTypeRank(type) {
668
+ return RANK_BY_TYPE.get(type) ?? UNKNOWN_RANK;
669
+ }
670
+ function dot(a, b) {
671
+ return a.x * b.x + a.y * b.y + a.z * b.z;
672
+ }
673
+ function rankOwners(model, owners, context = {}) {
674
+ const byTag = new Map(model.features.map((feature) => [feature.tag, feature]));
675
+ const features = [];
676
+ for (const tag of owners) {
677
+ const feature = byTag.get(tag);
678
+ if (!feature) continue;
679
+ if (context.activeDirection && !sameDirection(feature.machiningDirection, context.activeDirection)) {
680
+ continue;
681
+ }
682
+ features.push(feature);
683
+ }
684
+ const view = context.viewDirection;
685
+ return features.map((feature) => ({
686
+ tag: feature.tag,
687
+ rank: featureTypeRank(feature.featureType),
688
+ alignment: view ? dot(feature.machiningDirection, view) : 0
689
+ })).sort((a, b) => {
690
+ if (a.rank !== b.rank) return a.rank - b.rank;
691
+ if (a.alignment !== b.alignment) return b.alignment - a.alignment;
692
+ if (a.tag < b.tag) return -1;
693
+ if (a.tag > b.tag) return 1;
694
+ return 0;
695
+ }).map((entry) => entry.tag);
696
+ }
697
+ function bestOwner(model, owners, context = {}) {
698
+ return rankOwners(model, owners, context)[0] ?? null;
699
+ }
700
+ function cycleOwner(owners, current) {
701
+ if (owners.length === 0) return null;
702
+ const index = current === null ? -1 : owners.indexOf(current);
703
+ return owners[(index + 1) % owners.length] ?? null;
704
+ }
705
+
706
+ // src/render/picking.ts
707
+ var NO_MODIFIERS = {
708
+ alt: false,
709
+ ctrl: false,
710
+ meta: false,
711
+ shift: false,
712
+ secondary: false
713
+ };
714
+ function viewDirection(camera, target) {
715
+ const { x, y, z } = camera.position;
716
+ const dx = x - target.x;
717
+ const dy = y - target.y;
718
+ const dz = z - target.z;
719
+ const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
720
+ if (length === 0) return { x: 0, y: 0, z: 1 };
721
+ return { x: dx / length, y: dy / length, z: dz / length };
722
+ }
723
+ function buildPick(input) {
724
+ const { model, region } = input;
725
+ const owners = model.regionIndex.featuresForRegion(region);
726
+ const context = {
727
+ activeDirection: input.activeDirection == null ? null : model.candidateDirections[input.activeDirection] ?? null,
728
+ viewDirection: input.viewDirection ?? null
729
+ };
730
+ return {
731
+ region,
732
+ owners,
733
+ ranked: rankOwners(model, owners, context),
734
+ best: bestOwner(model, owners, context),
735
+ triangleIndex: input.triangleIndex,
736
+ point: input.point,
737
+ normal: input.normal,
738
+ modifiers: input.modifiers ?? NO_MODIFIERS
739
+ };
740
+ }
741
+ function focusForPick(pick, previousRegion, previousFocus) {
742
+ if (pick.ranked.length === 0) return null;
743
+ if (previousRegion !== pick.region) return pick.best;
744
+ const index = previousFocus === null ? -1 : pick.ranked.indexOf(previousFocus);
745
+ return pick.ranked[(index + 1) % pick.ranked.length] ?? pick.best;
746
+ }
747
+
748
+ // src/viewer.tsx
749
+ import { Canvas, useFrame as useFrame2, useThree as useThree3 } from "@react-three/fiber";
750
+ import {
751
+ createContext,
752
+ forwardRef,
753
+ useCallback,
754
+ useContext,
755
+ useEffect as useEffect3,
756
+ useImperativeHandle,
757
+ useMemo as useMemo3,
758
+ useRef as useRef2
759
+ } from "react";
760
+ import { Box3 as Box33, Vector3 as Vector35 } from "three";
761
+
762
+ // src/camera.tsx
763
+ import { useFrame, useThree as useThree2 } from "@react-three/fiber";
764
+ import { useEffect as useEffect2, useMemo as useMemo2 } from "react";
765
+
766
+ // src/render/camera.ts
767
+ import { OrthographicCamera as OrthographicCamera2, Vector3 as Vector33 } from "three";
768
+ var PERSPECTIVE_FOV = 30;
769
+ var DEFAULT_FIT_MARGIN = 1.2;
770
+ var EXCLUDE_FROM_FRAME = "viewerExcludeFromFrame";
771
+ function defaultBounds() {
772
+ return { center: new Vector33(0, 0, 0), radius: 1 };
773
+ }
774
+ function aspectRatio(size) {
775
+ return size.width > 0 && size.height > 0 ? size.width / size.height : 1;
776
+ }
777
+ function boundsFromBox(box) {
778
+ if (box.isEmpty()) return defaultBounds();
779
+ const center = box.getCenter(new Vector33());
780
+ const radius = box.getSize(new Vector33()).length() / 2;
781
+ return { center, radius: radius > 0 ? radius : 1 };
782
+ }
783
+ function contentBounds(root, into) {
784
+ into.makeEmpty();
785
+ root.updateWorldMatrix(true, true);
786
+ root.traverse((object) => {
787
+ if (object.userData[EXCLUDE_FROM_FRAME]) return;
788
+ let ancestor = object.parent;
789
+ while (ancestor && ancestor !== root) {
790
+ if (ancestor.userData[EXCLUDE_FROM_FRAME]) return;
791
+ ancestor = ancestor.parent;
792
+ }
793
+ if ("isMesh" in object || "isLine" in object || "isPoints" in object) {
794
+ into.expandByObject(object);
795
+ }
796
+ });
797
+ return boundsFromBox(into);
798
+ }
799
+ function perspectiveFitDistance(fovDegrees, aspect, radius) {
800
+ const verticalFov = fovDegrees * Math.PI / 180;
801
+ const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect);
802
+ return radius / Math.sin(Math.min(verticalFov, horizontalFov) / 2);
803
+ }
804
+ function orthographicHalfHeight(aspect, radius) {
805
+ return aspect >= 1 ? radius : radius / aspect;
806
+ }
807
+ function nearFar(projection, radius) {
808
+ if (projection === "orthographic") return { near: -radius * 100, far: radius * 100 };
809
+ return { near: radius / 100, far: radius * 100 };
810
+ }
811
+ var START_DIRECTION = {
812
+ orthographic: [1.2, -2.5, 3],
813
+ perspective: [2, 1.2, -2.5]
814
+ };
815
+ function fitDistance(projection, size, bounds, margin = DEFAULT_FIT_MARGIN) {
816
+ if (projection === "perspective") {
817
+ return perspectiveFitDistance(PERSPECTIVE_FOV, aspectRatio(size), bounds.radius * margin);
818
+ }
819
+ return bounds.radius * margin * 4;
820
+ }
821
+ function startPosition(projection, size, bounds, margin = DEFAULT_FIT_MARGIN) {
822
+ const [x, y, z] = START_DIRECTION[projection];
823
+ return new Vector33(x, y, z).normalize().multiplyScalar(fitDistance(projection, size, bounds, margin)).add(bounds.center);
824
+ }
825
+ function applyProjection(camera, size, bounds, margin = DEFAULT_FIT_MARGIN) {
826
+ const aspect = aspectRatio(size);
827
+ const radius = bounds.radius * margin;
828
+ if (camera instanceof OrthographicCamera2) {
829
+ const halfHeight = orthographicHalfHeight(aspect, radius);
830
+ const halfWidth = halfHeight * aspect;
831
+ camera.left = -halfWidth;
832
+ camera.right = halfWidth;
833
+ camera.top = halfHeight;
834
+ camera.bottom = -halfHeight;
835
+ const { near, far } = nearFar("orthographic", radius);
836
+ camera.near = near;
837
+ camera.far = far;
838
+ } else {
839
+ camera.fov = PERSPECTIVE_FOV;
840
+ camera.aspect = aspect;
841
+ const { near, far } = nearFar("perspective", radius);
842
+ camera.near = near;
843
+ camera.far = far;
844
+ }
845
+ camera.updateProjectionMatrix();
846
+ }
847
+ var CAD_CAMERA_UP = new Vector33(0, 0, 1);
848
+ var cadViewDirections = {
849
+ front: new Vector33(0, -1, 0),
850
+ back: new Vector33(0, 1, 0),
851
+ left: new Vector33(-1, 0, 0),
852
+ right: new Vector33(1, 0, 0),
853
+ top: new Vector33(0, 0, 1),
854
+ bottom: new Vector33(0, 0, -1),
855
+ isometric: new Vector33(1, -1, 1).normalize()
856
+ };
857
+ function currentViewDirection(camera, target, into) {
858
+ into.subVectors(camera.position, target);
859
+ if (into.lengthSq() <= Number.EPSILON) return into.copy(cadViewDirections.isometric);
860
+ return into.normalize();
861
+ }
862
+
863
+ // src/render/controls.ts
864
+ import CameraControls from "camera-controls";
865
+ import {
866
+ Box3 as Box32,
867
+ Matrix4,
868
+ OrthographicCamera as OrthographicCamera3,
869
+ Quaternion,
870
+ Raycaster,
871
+ Sphere,
872
+ Spherical,
873
+ Vector2,
874
+ Vector3 as Vector34,
875
+ Vector4
876
+ } from "three";
877
+ CameraControls.install({
878
+ THREE: {
879
+ Box3: Box32,
880
+ Matrix4,
881
+ Quaternion,
882
+ Raycaster,
883
+ Sphere,
884
+ Spherical,
885
+ Vector2,
886
+ Vector3: Vector34,
887
+ Vector4
888
+ }
889
+ });
890
+ var EPSILON3 = 1e-6;
891
+ var FUSION_PINCH_ZOOM_SCALE = 0.04;
892
+ var FUSION_ROTATE_SCALE = 2.2;
893
+ var FUSION_TRUCK_SCALE = 0.33;
894
+ var DEFAULT_SMOOTH_TIME = 1e-3;
895
+ var ExtendedCameraControls = class extends CameraControls {
896
+ #domElement;
897
+ #freeOrbit;
898
+ #scheme = "toolpath";
899
+ #attached = false;
900
+ #autoUpEnabled = false;
901
+ #shiftPressed = false;
902
+ #wheelHandler = null;
903
+ // Scratch objects — `#onPointerMove` and `#adaptUpVector` run at pointer and
904
+ // frame rate respectively, so neither may allocate.
905
+ #spherical = new Spherical();
906
+ #scratchA = new Vector34();
907
+ #scratchB = new Vector34();
908
+ #scratchC = new Vector34();
909
+ #scratchD = new Vector34();
910
+ constructor(camera, domElement, options = {}) {
911
+ super(camera, domElement);
912
+ this.#domElement = domElement;
913
+ this.#freeOrbit = options.freeOrbit ?? true;
914
+ this.azimuthRotateSpeed = 0;
915
+ this.polarRotateSpeed = 0;
916
+ }
917
+ get freeOrbit() {
918
+ return this.#freeOrbit;
919
+ }
920
+ get scheme() {
921
+ return this.#scheme;
922
+ }
923
+ /** Adds the listeners this subclass owns, on top of the base connection. */
924
+ attach() {
925
+ if (this.#attached) {
926
+ return;
927
+ }
928
+ this.#attached = true;
929
+ this.#domElement.ownerDocument.addEventListener("pointermove", this.#onPointerMove);
930
+ const view = this.#domElement.ownerDocument.defaultView;
931
+ view?.addEventListener("keydown", this.#onModifierChange);
932
+ view?.addEventListener("keyup", this.#onModifierChange);
933
+ view?.addEventListener("blur", this.#onWindowBlur);
934
+ if (this.#freeOrbit) {
935
+ this.#enableAutoUp();
936
+ }
937
+ this.applyScheme(this.#scheme);
938
+ }
939
+ detach() {
940
+ if (!this.#attached) {
941
+ return;
942
+ }
943
+ this.#attached = false;
944
+ this.#domElement.ownerDocument.removeEventListener("pointermove", this.#onPointerMove);
945
+ const view = this.#domElement.ownerDocument.defaultView;
946
+ view?.removeEventListener("keydown", this.#onModifierChange);
947
+ view?.removeEventListener("keyup", this.#onModifierChange);
948
+ view?.removeEventListener("blur", this.#onWindowBlur);
949
+ this.#disableAutoUp();
950
+ this.#disableFusionWheel();
951
+ }
952
+ dispose() {
953
+ this.detach();
954
+ super.dispose();
955
+ }
956
+ /**
957
+ * Reapplies the current preset. The Viewer calls this after a projection
958
+ * change too, because the correct wheel action differs between an
959
+ * orthographic and a perspective camera.
960
+ */
961
+ applyScheme(scheme) {
962
+ this.#scheme = scheme;
963
+ this.mouseButtons.left = CameraControls.ACTION.NONE;
964
+ this.mouseButtons.middle = CameraControls.ACTION.NONE;
965
+ this.mouseButtons.right = CameraControls.ACTION.NONE;
966
+ this.mouseButtons.wheel = CameraControls.ACTION.NONE;
967
+ this.touches.one = CameraControls.ACTION.TOUCH_ROTATE;
968
+ this.touches.two = CameraControls.ACTION.TOUCH_DOLLY_TRUCK;
969
+ this.touches.three = CameraControls.ACTION.TOUCH_TRUCK;
970
+ this.smoothTime = DEFAULT_SMOOTH_TIME;
971
+ this.draggingSmoothTime = DEFAULT_SMOOTH_TIME;
972
+ this.#disableFusionWheel();
973
+ if (scheme === "fusion") {
974
+ const rotating = this.#shiftPressed;
975
+ this.mouseButtons.middle = rotating ? CameraControls.ACTION.ROTATE : CameraControls.ACTION.TRUCK;
976
+ this.touches.two = rotating ? CameraControls.ACTION.TOUCH_ROTATE : CameraControls.ACTION.TOUCH_TRUCK;
977
+ this.smoothTime = 0;
978
+ this.draggingSmoothTime = 0;
979
+ this.#enableFusionWheel();
980
+ return;
981
+ }
982
+ this.mouseButtons.left = CameraControls.ACTION.ROTATE;
983
+ this.mouseButtons.right = CameraControls.ACTION.TRUCK;
984
+ this.mouseButtons.middle = CameraControls.ACTION.TRUCK;
985
+ this.mouseButtons.wheel = this.camera instanceof OrthographicCamera3 ? CameraControls.ACTION.ZOOM : CameraControls.ACTION.DOLLY;
986
+ }
987
+ setFreeOrbit(freeOrbit) {
988
+ if (this.#freeOrbit === freeOrbit) {
989
+ return;
990
+ }
991
+ this.#freeOrbit = freeOrbit;
992
+ if (!this.#attached) {
993
+ return;
994
+ }
995
+ if (freeOrbit) {
996
+ this.#enableAutoUp();
997
+ } else {
998
+ this.#disableAutoUp();
999
+ this.resetUpVector();
1000
+ }
1001
+ }
1002
+ /** Returns the camera to Z-up, the orientation the part data is authored in. */
1003
+ resetUpVector() {
1004
+ this.camera.up.set(0, 0, 1);
1005
+ this.updateCameraUp();
1006
+ const position = this.getPosition(this.#scratchA);
1007
+ void this.setPosition(position.x, position.y, position.z, false);
1008
+ }
1009
+ #enableAutoUp() {
1010
+ if (this.#autoUpEnabled) {
1011
+ return;
1012
+ }
1013
+ this.#autoUpEnabled = true;
1014
+ this.addEventListener("update", this.#adaptUpVector);
1015
+ }
1016
+ #disableAutoUp() {
1017
+ if (!this.#autoUpEnabled) {
1018
+ return;
1019
+ }
1020
+ this.#autoUpEnabled = false;
1021
+ this.removeEventListener("update", this.#adaptUpVector);
1022
+ }
1023
+ #enableFusionWheel() {
1024
+ this.#wheelHandler = (event) => this.#onFusionWheel(event);
1025
+ this.#domElement.addEventListener("wheel", this.#wheelHandler, {
1026
+ passive: false
1027
+ });
1028
+ }
1029
+ #disableFusionWheel() {
1030
+ if (!this.#wheelHandler) {
1031
+ return;
1032
+ }
1033
+ this.#domElement.removeEventListener("wheel", this.#wheelHandler);
1034
+ this.#wheelHandler = null;
1035
+ }
1036
+ /**
1037
+ * Re-derives the up vector from the current view each update, so orbiting
1038
+ * over a pole keeps going instead of flipping the horizon.
1039
+ */
1040
+ #adaptUpVector = () => {
1041
+ const target = this.getTarget(this.#scratchA);
1042
+ const view = this.#scratchB.subVectors(target, this.camera.position).normalize();
1043
+ const side = this.#scratchC.crossVectors(view, this.camera.up).normalize();
1044
+ const up = this.#scratchD.crossVectors(side, view).normalize();
1045
+ this.camera.up.copy(up);
1046
+ const position = this.getPosition(this.#scratchA);
1047
+ this.updateCameraUp();
1048
+ void this.setPosition(position.x, position.y, position.z, false);
1049
+ };
1050
+ /**
1051
+ * Rotation, done here rather than by the base class so a constrained orbit
1052
+ * can bounce off a pole — flipping the up vector and inverting azimuth —
1053
+ * instead of sticking there.
1054
+ */
1055
+ #onPointerMove = (event) => {
1056
+ if (this.currentAction !== CameraControls.ACTION.ROTATE) {
1057
+ return;
1058
+ }
1059
+ const scale = Math.PI / Math.max(1, Math.min(this.#domElement.clientWidth, this.#domElement.clientHeight));
1060
+ const spherical = this.getSpherical(this.#spherical);
1061
+ let azimuth = spherical.theta - event.movementX * scale;
1062
+ let polar = spherical.phi - event.movementY * scale;
1063
+ const exceededUpper = polar > Math.PI - EPSILON3;
1064
+ const exceededLower = polar < EPSILON3;
1065
+ if (!this.#freeOrbit && (exceededUpper || exceededLower)) {
1066
+ polar = exceededUpper ? EPSILON3 : Math.PI - EPSILON3;
1067
+ azimuth = -azimuth;
1068
+ this.camera.up.negate();
1069
+ this.updateCameraUp();
1070
+ }
1071
+ void this.rotateTo(azimuth, polar, false);
1072
+ this.update(0);
1073
+ };
1074
+ #onFusionWheel = (event) => {
1075
+ event.preventDefault();
1076
+ if (event.ctrlKey) {
1077
+ void this.zoom(-event.deltaY * FUSION_PINCH_ZOOM_SCALE, false);
1078
+ } else if (this.#shiftPressed) {
1079
+ const scale = Math.PI / Math.max(1, Math.min(this.#domElement.clientWidth, this.#domElement.clientHeight));
1080
+ void this.rotate(
1081
+ event.deltaX * scale * FUSION_ROTATE_SCALE,
1082
+ event.deltaY * scale * FUSION_ROTATE_SCALE,
1083
+ false
1084
+ );
1085
+ } else {
1086
+ void this.truck(event.deltaX * FUSION_TRUCK_SCALE, event.deltaY * FUSION_TRUCK_SCALE, false);
1087
+ }
1088
+ this.update(0);
1089
+ this.dispatchEvent({ type: "control" });
1090
+ };
1091
+ #onModifierChange = (event) => {
1092
+ this.#setShiftPressed(event.shiftKey);
1093
+ };
1094
+ #onWindowBlur = () => {
1095
+ this.#setShiftPressed(false);
1096
+ };
1097
+ #setShiftPressed(pressed) {
1098
+ if (this.#shiftPressed === pressed) {
1099
+ return;
1100
+ }
1101
+ this.#shiftPressed = pressed;
1102
+ if (this.#scheme === "fusion") {
1103
+ this.applyScheme(this.#scheme);
1104
+ }
1105
+ }
1106
+ };
1107
+
1108
+ // src/camera.tsx
1109
+ var CadCameraControls = ({
1110
+ controlsRef,
1111
+ scheme = "toolpath",
1112
+ freeOrbit = true
1113
+ }) => {
1114
+ const camera = useThree2((state) => state.camera);
1115
+ const domElement = useThree2((state) => state.gl.domElement);
1116
+ const invalidate = useThree2((state) => state.invalidate);
1117
+ const set = useThree2((state) => state.set);
1118
+ const controls = useMemo2(
1119
+ () => new ExtendedCameraControls(camera, domElement, { freeOrbit }),
1120
+ // `freeOrbit` is the constructor's initial value only; the effect below
1121
+ // owns it from then on, and rebuilding the controls would drop the pose.
1122
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1123
+ [camera, domElement]
1124
+ );
1125
+ useEffect2(() => {
1126
+ controlsRef.current = controls;
1127
+ camera.up.copy(CAD_CAMERA_UP);
1128
+ controls.updateCameraUp();
1129
+ controls.attach();
1130
+ const request = () => invalidate();
1131
+ controls.addEventListener("control", request);
1132
+ controls.addEventListener("update", request);
1133
+ invalidate();
1134
+ return () => {
1135
+ controls.removeEventListener("control", request);
1136
+ controls.removeEventListener("update", request);
1137
+ controls.dispose();
1138
+ if (controlsRef.current === controls) controlsRef.current = null;
1139
+ };
1140
+ }, [camera, controls, controlsRef, invalidate]);
1141
+ useEffect2(() => {
1142
+ set({ controls });
1143
+ return () => set({ controls: null });
1144
+ }, [controls, set]);
1145
+ useEffect2(() => {
1146
+ controls.applyScheme(scheme);
1147
+ }, [controls, scheme]);
1148
+ useEffect2(() => {
1149
+ controls.setFreeOrbit(freeOrbit);
1150
+ }, [controls, freeOrbit]);
1151
+ useFrame((_, delta) => {
1152
+ controls.update(delta);
1153
+ });
1154
+ return null;
1155
+ };
1156
+
1157
+ // src/viewer.tsx
1158
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1159
+ var ViewerControlsContext = createContext(null);
1160
+ var useViewerControls = () => {
1161
+ const controls = useContext(ViewerControlsContext);
1162
+ if (!controls) throw new Error("useViewerControls must be used inside <Viewer>");
1163
+ return controls;
1164
+ };
1165
+ var ViewerScene = ({
1166
+ children,
1167
+ setControls,
1168
+ projection,
1169
+ scheme,
1170
+ freeOrbit,
1171
+ theme
1172
+ }) => {
1173
+ const camera = useThree3((state) => state.camera);
1174
+ const size = useThree3((state) => state.size);
1175
+ const invalidate = useThree3((state) => state.invalidate);
1176
+ const controlsRef = useRef2(null);
1177
+ const contentRef = useRef2(null);
1178
+ const lightsRef = useRef2(null);
1179
+ const initialFrameComplete = useRef2(false);
1180
+ const boundsRef = useRef2(defaultBounds());
1181
+ const scratchBox = useMemo3(() => new Box33(), []);
1182
+ const scratchDirection = useMemo3(() => new Vector35(), []);
1183
+ const scratchView = useMemo3(() => new Vector35(), []);
1184
+ const measure = useCallback(() => {
1185
+ const content = contentRef.current;
1186
+ boundsRef.current = content ? contentBounds(content, scratchBox) : defaultBounds();
1187
+ return boundsRef.current;
1188
+ }, [scratchBox]);
1189
+ const frame = useCallback(
1190
+ (direction, transition = false) => {
1191
+ const controls = controlsRef.current;
1192
+ if (!controls) return false;
1193
+ const bounds = measure();
1194
+ applyProjection(camera, size, bounds, DEFAULT_FIT_MARGIN);
1195
+ const distance = fitDistance(projection, size, bounds, DEFAULT_FIT_MARGIN);
1196
+ const position = scratchDirection.copy(direction).normalize().multiplyScalar(distance);
1197
+ position.add(bounds.center);
1198
+ void controls.setLookAt(
1199
+ position.x,
1200
+ position.y,
1201
+ position.z,
1202
+ bounds.center.x,
1203
+ bounds.center.y,
1204
+ bounds.center.z,
1205
+ transition
1206
+ );
1207
+ if (projection === "orthographic") void controls.zoomTo(1, transition);
1208
+ invalidate();
1209
+ return true;
1210
+ },
1211
+ [camera, invalidate, measure, projection, scratchDirection, size]
1212
+ );
1213
+ const frameBounds = useCallback(
1214
+ (bounds) => {
1215
+ const controls = controlsRef.current;
1216
+ if (!controls) return false;
1217
+ const target = controls.getTarget(new Vector35());
1218
+ const direction = currentViewDirection(camera, target, new Vector35());
1219
+ const distance = fitDistance(projection, size, bounds, DEFAULT_FIT_MARGIN);
1220
+ const position = direction.multiplyScalar(distance).add(bounds.center);
1221
+ void controls.setLookAt(
1222
+ position.x,
1223
+ position.y,
1224
+ position.z,
1225
+ bounds.center.x,
1226
+ bounds.center.y,
1227
+ bounds.center.z,
1228
+ true
1229
+ );
1230
+ if (projection === "orthographic") {
1231
+ void controls.zoomTo(boundsRef.current.radius / bounds.radius, true);
1232
+ }
1233
+ invalidate();
1234
+ return true;
1235
+ },
1236
+ [camera, invalidate, projection, size]
1237
+ );
1238
+ const fitContent = useCallback(() => {
1239
+ const controls = controlsRef.current;
1240
+ if (!controls) return false;
1241
+ const target = controls.getTarget(new Vector35());
1242
+ return frame(currentViewDirection(camera, target, new Vector35()), true);
1243
+ }, [camera, frame]);
1244
+ const resetContent = useCallback(() => {
1245
+ const bounds = measure();
1246
+ const start = startPosition(projection, size, bounds).sub(bounds.center);
1247
+ return frame(start, true);
1248
+ }, [frame, measure, projection, size]);
1249
+ useEffect3(() => {
1250
+ setControls({
1251
+ fit: () => {
1252
+ fitContent();
1253
+ },
1254
+ reset: () => {
1255
+ resetContent();
1256
+ },
1257
+ setView: (view) => {
1258
+ frame(cadViewDirections[view], true);
1259
+ },
1260
+ setViewDirection: (direction) => {
1261
+ frame(scratchView.set(direction.x, direction.y, direction.z), true);
1262
+ },
1263
+ frameBox: (box) => {
1264
+ frameBounds(boundsFromBox(box));
1265
+ }
1266
+ });
1267
+ }, [fitContent, frame, frameBounds, resetContent, scratchView, setControls]);
1268
+ useEffect3(() => {
1269
+ if (initialFrameComplete.current) resetContent();
1270
+ }, [projection]);
1271
+ useEffect3(() => {
1272
+ applyProjection(camera, size, boundsRef.current, DEFAULT_FIT_MARGIN);
1273
+ invalidate();
1274
+ }, [camera, invalidate, size]);
1275
+ useFrame2(() => {
1276
+ const lights = lightsRef.current;
1277
+ if (lights && !lights.quaternion.equals(camera.quaternion)) {
1278
+ lights.quaternion.copy(camera.quaternion);
1279
+ invalidate();
1280
+ }
1281
+ });
1282
+ useFrame2(() => {
1283
+ if (!initialFrameComplete.current && contentRef.current?.children.length) {
1284
+ initialFrameComplete.current = resetContent();
1285
+ }
1286
+ });
1287
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1288
+ /* @__PURE__ */ jsxs("group", { ref: lightsRef, children: [
1289
+ /* @__PURE__ */ jsx("ambientLight", { color: theme.ambient, intensity: theme.ambientIntensity }),
1290
+ /* @__PURE__ */ jsx(
1291
+ "hemisphereLight",
1292
+ {
1293
+ args: [theme.hemisphereSky, theme.hemisphereGround, theme.hemisphereIntensity]
1294
+ }
1295
+ )
1296
+ ] }),
1297
+ /* @__PURE__ */ jsx("group", { ref: contentRef, children }),
1298
+ /* @__PURE__ */ jsx(CadCameraControls, { controlsRef, scheme, freeOrbit })
1299
+ ] });
1300
+ };
1301
+ var Viewer = forwardRef(function Viewer2({
1302
+ children,
1303
+ className,
1304
+ style,
1305
+ projection = "perspective",
1306
+ controls = "toolpath",
1307
+ freeOrbit = true,
1308
+ theme,
1309
+ onPointerMissed
1310
+ }, ref) {
1311
+ const actionsRef = useRef2(null);
1312
+ const resolved = useMemo3(() => resolveTheme(theme), [theme]);
1313
+ const proxy = useMemo3(
1314
+ () => ({
1315
+ fit: () => actionsRef.current?.fit(),
1316
+ reset: () => actionsRef.current?.reset(),
1317
+ setView: (view) => actionsRef.current?.setView(view),
1318
+ setViewDirection: (direction) => actionsRef.current?.setViewDirection(direction),
1319
+ frameBox: (box) => actionsRef.current?.frameBox(box)
1320
+ }),
1321
+ []
1322
+ );
1323
+ useImperativeHandle(ref, () => proxy, [proxy]);
1324
+ const setControls = useCallback((next) => {
1325
+ actionsRef.current = next;
1326
+ }, []);
1327
+ const tracker = useRef2(null);
1328
+ const hold = useCallback((element) => {
1329
+ tracker.current?.dispose();
1330
+ tracker.current = element ? trackTaps(element) : null;
1331
+ }, []);
1332
+ return /* @__PURE__ */ jsx(ViewerControlsContext.Provider, { value: proxy, children: /* @__PURE__ */ jsx("div", { className, ref: hold, style: { height: "100%", width: "100%", ...style }, children: /* @__PURE__ */ jsx(
1333
+ Canvas,
1334
+ {
1335
+ orthographic: projection === "orthographic",
1336
+ camera: { fov: PERSPECTIVE_FOV, up: [0, 0, 1], position: [1, -1, 1] },
1337
+ dpr: [1, 2],
1338
+ frameloop: "demand",
1339
+ gl: { antialias: true, alpha: true, stencil: true, localClippingEnabled: true },
1340
+ onPointerMissed: (event) => {
1341
+ if (event.button !== 0) return;
1342
+ if (tracker.current?.isTap(event) ?? true) onPointerMissed?.();
1343
+ },
1344
+ children: /* @__PURE__ */ jsx(
1345
+ ViewerScene,
1346
+ {
1347
+ setControls,
1348
+ projection,
1349
+ scheme: controls,
1350
+ freeOrbit,
1351
+ theme: resolved,
1352
+ children
1353
+ }
1354
+ )
1355
+ },
1356
+ projection
1357
+ ) }) });
1358
+ });
1359
+
1360
+ // src/section-view.tsx
1361
+ import { useFrame as useFrame3, useThree as useThree4 } from "@react-three/fiber";
1362
+ import { useCallback as useCallback2, useMemo as useMemo4, useRef as useRef3, useState } from "react";
1363
+ import {
1364
+ AlwaysStencilFunc,
1365
+ BackSide,
1366
+ DecrementWrapStencilOp,
1367
+ DoubleSide,
1368
+ FrontSide,
1369
+ IncrementWrapStencilOp,
1370
+ NotEqualStencilFunc,
1371
+ Plane as Plane2,
1372
+ Quaternion as Quaternion2,
1373
+ Raycaster as Raycaster2,
1374
+ ReplaceStencilOp,
1375
+ Vector2 as Vector22,
1376
+ Vector3 as Vector37
1377
+ } from "three";
1378
+
1379
+ // src/render/directions.ts
1380
+ import { Vector3 as Vector36 } from "three";
1381
+ var CONE_AXIS = new Vector36(0, 1, 0);
1382
+ var LENGTH = 0.45;
1383
+ var HEAD = 0.45;
1384
+ var HEAD_RADIUS = 0.3;
1385
+ var SHAFT_RADIUS = 0.09;
1386
+ var GAP = 0.2;
1387
+ function arrowPlacement(direction, box) {
1388
+ const center = box.getCenter(new Vector36());
1389
+ const half = box.getSize(new Vector36()).multiplyScalar(0.5);
1390
+ const radius = half.length() || 1;
1391
+ const axis = new Vector36(direction.x, direction.y, direction.z);
1392
+ if (axis.lengthSq() === 0) axis.set(0, 0, 1);
1393
+ axis.normalize();
1394
+ let exit = Number.POSITIVE_INFINITY;
1395
+ for (const [component, extent] of [
1396
+ [axis.x, half.x],
1397
+ [axis.y, half.y],
1398
+ [axis.z, half.z]
1399
+ ]) {
1400
+ if (Math.abs(component) > 1e-6) exit = Math.min(exit, extent / Math.abs(component));
1401
+ }
1402
+ if (!Number.isFinite(exit)) exit = radius;
1403
+ return { tip: center.addScaledVector(axis, exit + radius * GAP), length: radius * LENGTH };
1404
+ }
1405
+
1406
+ // src/section-view.tsx
1407
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1408
+ var DEFAULT_NORMAL = { x: 0, y: 0, z: 1 };
1409
+ var FURNITURE = { [EXCLUDE_FROM_FRAME]: true };
1410
+ function resolveSectionPlane(options, box) {
1411
+ if (!options?.enabled || box.isEmpty()) return null;
1412
+ const normal = options.plane?.normal ?? options.normal ?? DEFAULT_NORMAL;
1413
+ const axis = new Vector37(normal.x, normal.y, normal.z);
1414
+ if (axis.lengthSq() === 0) axis.set(0, 0, 1);
1415
+ axis.normalize();
1416
+ const bounds = sectionBounds(box, normal);
1417
+ const anchor = options.plane?.point ?? null;
1418
+ const depth = options.depth ?? null;
1419
+ const constant = anchor === null ? sectionConstant(bounds, options.offset ?? 0) : sectionDepthConstant(normal, anchor, depth ?? 0);
1420
+ return {
1421
+ plane: new Plane2(axis, constant),
1422
+ state: {
1423
+ enabled: true,
1424
+ normal: { x: axis.x, y: axis.y, z: axis.z },
1425
+ offset: sectionOffset(bounds, constant),
1426
+ constant,
1427
+ plane: options.plane ?? null,
1428
+ depth: anchor === null ? null : sectionDepth(normal, anchor, constant),
1429
+ depthRange: anchor === null ? null : sectionDepthRange(bounds, normal, anchor)
1430
+ }
1431
+ };
1432
+ }
1433
+ var SectionView = ({
1434
+ geometry,
1435
+ box,
1436
+ plane,
1437
+ theme,
1438
+ showHandle,
1439
+ onDrag
1440
+ }) => {
1441
+ const camera = useThree4((state) => state.camera);
1442
+ const size = useThree4((state) => state.size);
1443
+ const invalidate = useThree4((state) => state.invalidate);
1444
+ const controls = useThree4((state) => state.controls);
1445
+ const domElement = useThree4((state) => state.gl.domElement);
1446
+ const capRef = useRef3(null);
1447
+ const handleRef = useRef3(null);
1448
+ const [hovered, setHovered] = useState(false);
1449
+ const dragging = useRef3(null);
1450
+ const centre = useMemo4(() => box.getCenter(new Vector37()), [box]);
1451
+ const span = useMemo4(() => box.getSize(new Vector37()).length(), [box]);
1452
+ const clip = useMemo4(() => [plane], [plane]);
1453
+ const capPosition = useMemo4(() => {
1454
+ const point = centre.clone();
1455
+ return point.addScaledVector(plane.normal, -(plane.constant + plane.normal.dot(centre)));
1456
+ }, [centre, plane]);
1457
+ const capQuaternion = useMemo4(
1458
+ () => new Quaternion2().setFromUnitVectors(new Vector37(0, 0, 1), plane.normal),
1459
+ [plane]
1460
+ );
1461
+ const handleQuaternion = useMemo4(
1462
+ () => new Quaternion2().setFromUnitVectors(CONE_AXIS, plane.normal.clone().negate()),
1463
+ [plane]
1464
+ );
1465
+ useFrame3(() => {
1466
+ const handle = handleRef.current;
1467
+ if (!handle) return;
1468
+ const length = screenLength(camera, capPosition, size, HANDLE_PIXELS);
1469
+ if (Math.abs(handle.scale.x - length) > length * 1e-3) {
1470
+ handle.scale.setScalar(length);
1471
+ invalidate();
1472
+ }
1473
+ });
1474
+ const setControlsEnabled = useCallback2(
1475
+ (enabled) => {
1476
+ const target = controls;
1477
+ if (target && "enabled" in target) target.enabled = enabled;
1478
+ },
1479
+ [controls]
1480
+ );
1481
+ const beginDrag = (event) => {
1482
+ if (!onDrag) return;
1483
+ event.stopPropagation();
1484
+ const view = camera.position.clone().sub(capPosition).normalize();
1485
+ const surface = dragPlane(plane.normal.clone(), view, capPosition);
1486
+ const hit = event.ray.intersectPlane(surface, new Vector37());
1487
+ const start = {
1488
+ plane: surface,
1489
+ from: hit ? hit.dot(plane.normal) : 0,
1490
+ constant: plane.constant
1491
+ };
1492
+ dragging.current = start;
1493
+ setControlsEnabled(false);
1494
+ const raycaster = new Raycaster2();
1495
+ const pointer = new Vector22();
1496
+ const move = (native) => {
1497
+ const rect = domElement.getBoundingClientRect();
1498
+ pointer.set(
1499
+ (native.clientX - rect.left) / rect.width * 2 - 1,
1500
+ -((native.clientY - rect.top) / rect.height) * 2 + 1
1501
+ );
1502
+ raycaster.setFromCamera(pointer, camera);
1503
+ const point = raycaster.ray.intersectPlane(start.plane, new Vector37());
1504
+ if (!point) return;
1505
+ onDrag(start.constant - (point.dot(plane.normal) - start.from));
1506
+ invalidate();
1507
+ };
1508
+ const end = () => {
1509
+ window.removeEventListener("pointermove", move);
1510
+ window.removeEventListener("pointerup", end);
1511
+ window.removeEventListener("pointercancel", end);
1512
+ dragging.current = null;
1513
+ setControlsEnabled(true);
1514
+ };
1515
+ window.addEventListener("pointermove", move);
1516
+ window.addEventListener("pointerup", end);
1517
+ window.addEventListener("pointercancel", end);
1518
+ };
1519
+ const grab = {
1520
+ onPointerDown: beginDrag,
1521
+ onPointerOver: (event) => {
1522
+ event.stopPropagation();
1523
+ setHovered(true);
1524
+ invalidate();
1525
+ },
1526
+ onPointerOut: () => {
1527
+ setHovered(false);
1528
+ invalidate();
1529
+ }
1530
+ };
1531
+ return (
1532
+ // Excluded from framing, all of it. The cap is a quad half again as wide as
1533
+ // the part's diagonal, so a Fit that measured it would frame the cut rather
1534
+ // than the part and leave the part a speck in the middle of it.
1535
+ /* @__PURE__ */ jsxs2("group", { userData: FURNITURE, children: [
1536
+ /* @__PURE__ */ jsx2("mesh", { geometry, renderOrder: SECTION_RENDER_ORDER.stencil, raycast: () => null, children: /* @__PURE__ */ jsx2(
1537
+ "meshBasicMaterial",
1538
+ {
1539
+ side: BackSide,
1540
+ depthWrite: false,
1541
+ depthTest: false,
1542
+ colorWrite: false,
1543
+ stencilWrite: true,
1544
+ stencilFunc: AlwaysStencilFunc,
1545
+ stencilFail: IncrementWrapStencilOp,
1546
+ stencilZFail: IncrementWrapStencilOp,
1547
+ stencilZPass: IncrementWrapStencilOp,
1548
+ clippingPlanes: clip
1549
+ }
1550
+ ) }),
1551
+ /* @__PURE__ */ jsx2("mesh", { geometry, renderOrder: SECTION_RENDER_ORDER.stencil, raycast: () => null, children: /* @__PURE__ */ jsx2(
1552
+ "meshBasicMaterial",
1553
+ {
1554
+ side: FrontSide,
1555
+ depthWrite: false,
1556
+ depthTest: false,
1557
+ colorWrite: false,
1558
+ stencilWrite: true,
1559
+ stencilFunc: AlwaysStencilFunc,
1560
+ stencilFail: DecrementWrapStencilOp,
1561
+ stencilZFail: DecrementWrapStencilOp,
1562
+ stencilZPass: DecrementWrapStencilOp,
1563
+ clippingPlanes: clip
1564
+ }
1565
+ ) }),
1566
+ /* @__PURE__ */ jsx2("group", { ref: capRef, position: capPosition, quaternion: capQuaternion, children: /* @__PURE__ */ jsxs2("mesh", { renderOrder: SECTION_RENDER_ORDER.cap, raycast: () => null, children: [
1567
+ /* @__PURE__ */ jsx2("planeGeometry", { args: [span * 1.5, span * 1.5] }),
1568
+ /* @__PURE__ */ jsx2(
1569
+ "meshBasicMaterial",
1570
+ {
1571
+ color: theme.sectionCap,
1572
+ side: DoubleSide,
1573
+ stencilWrite: true,
1574
+ stencilRef: 0,
1575
+ stencilFunc: NotEqualStencilFunc,
1576
+ stencilFail: ReplaceStencilOp,
1577
+ stencilZFail: ReplaceStencilOp,
1578
+ stencilZPass: ReplaceStencilOp
1579
+ }
1580
+ )
1581
+ ] }) }),
1582
+ showHandle && onDrag ? /* @__PURE__ */ jsxs2(
1583
+ "group",
1584
+ {
1585
+ ref: handleRef,
1586
+ position: capPosition,
1587
+ quaternion: handleQuaternion,
1588
+ renderOrder: SECTION_RENDER_ORDER.handle,
1589
+ children: [
1590
+ /* @__PURE__ */ jsxs2("mesh", { position: [0, 0.28, 0], ...grab, children: [
1591
+ /* @__PURE__ */ jsx2("coneGeometry", { args: [0.16, 0.34, 20] }),
1592
+ /* @__PURE__ */ jsx2(
1593
+ "meshBasicMaterial",
1594
+ {
1595
+ color: hovered ? theme.hover : theme.sectionHandle,
1596
+ depthTest: false
1597
+ }
1598
+ )
1599
+ ] }),
1600
+ /* @__PURE__ */ jsxs2("mesh", { position: [0, 0.08, 0], ...grab, children: [
1601
+ /* @__PURE__ */ jsx2("cylinderGeometry", { args: [0.045, 0.045, 0.4, 12] }),
1602
+ /* @__PURE__ */ jsx2(
1603
+ "meshBasicMaterial",
1604
+ {
1605
+ color: hovered ? theme.hover : theme.sectionHandle,
1606
+ depthTest: false
1607
+ }
1608
+ )
1609
+ ] })
1610
+ ]
1611
+ }
1612
+ ) : null
1613
+ ] })
1614
+ );
1615
+ };
1616
+
1617
+ // src/content-box.ts
1618
+ import { useFrame as useFrame4, useThree as useThree5 } from "@react-three/fiber";
1619
+ import { useRef as useRef4, useState as useState2 } from "react";
1620
+ import { Box3 as Box34 } from "three";
1621
+ function useContentBox() {
1622
+ const scene = useThree5((state) => state.scene);
1623
+ const [box, setBox] = useState2(() => new Box34());
1624
+ const measured = useRef4(false);
1625
+ useFrame4(() => {
1626
+ if (measured.current) return;
1627
+ const next = new Box34();
1628
+ contentBounds(scene, next);
1629
+ if (next.isEmpty()) return;
1630
+ measured.current = true;
1631
+ setBox(next);
1632
+ });
1633
+ return box;
1634
+ }
1635
+
1636
+ // src/part-mesh.tsx
1637
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1638
+ var PartMesh = ({
1639
+ model,
1640
+ geometry,
1641
+ selection = [],
1642
+ candidates = [],
1643
+ highlights = [],
1644
+ regionHighlights = [],
1645
+ pickedRegions = [],
1646
+ hoveredFeatureIds = [],
1647
+ activeDirection = null,
1648
+ section,
1649
+ onSectionChange,
1650
+ focusFeature = null,
1651
+ onHover,
1652
+ onPick,
1653
+ theme,
1654
+ showEdges = true
1655
+ }) => {
1656
+ const { camera, controls, invalidate } = useThree6();
1657
+ const viewerControls = useViewerControls();
1658
+ const resolved = useStableTheme(theme);
1659
+ const currentTheme = useRef5(resolved);
1660
+ currentTheme.current = resolved;
1661
+ const part = useMemo5(() => createPart(model, geometry, currentTheme.current), [geometry, model]);
1662
+ const hoverRegion = useRef5(null);
1663
+ const box = useContentBox();
1664
+ const cut = useMemo5(() => resolveSectionPlane(section, box), [box, section]);
1665
+ const layers = useRef5({
1666
+ selection,
1667
+ candidates,
1668
+ highlights,
1669
+ regionHighlights,
1670
+ pickedRegions,
1671
+ hoveredFeatureIds
1672
+ });
1673
+ layers.current = {
1674
+ selection,
1675
+ candidates,
1676
+ highlights,
1677
+ regionHighlights,
1678
+ pickedRegions,
1679
+ hoveredFeatureIds
1680
+ };
1681
+ const repaint = useCallback3(() => {
1682
+ const { hoveredFeatureIds: hoveredFeatures, ...rest } = layers.current;
1683
+ applyHighlightLayers(
1684
+ part,
1685
+ { ...rest, hoveredFeatures, hoverRegion: hoverRegion.current },
1686
+ currentTheme.current
1687
+ );
1688
+ invalidate();
1689
+ }, [invalidate, part]);
1690
+ useEffect4(() => () => part.dispose(), [part]);
1691
+ const framed = useRef5(null);
1692
+ useEffect4(() => {
1693
+ if (focusFeature === null || focusFeature === framed.current) return;
1694
+ framed.current = focusFeature;
1695
+ const box2 = part.boxForFeature(focusFeature);
1696
+ if (box2) viewerControls.frameBox(box2);
1697
+ }, [focusFeature, part, viewerControls]);
1698
+ useLayoutEffect(() => {
1699
+ part.setTheme(resolved);
1700
+ repaint();
1701
+ }, [part, repaint, resolved]);
1702
+ useLayoutEffect(() => {
1703
+ part.edges.visible = showEdges;
1704
+ invalidate();
1705
+ }, [invalidate, part, showEdges]);
1706
+ useLayoutEffect(() => {
1707
+ part.setClippingPlanes(cut ? [cut.plane] : null);
1708
+ invalidate();
1709
+ }, [cut, invalidate, part]);
1710
+ const reportedSection = useRef5("");
1711
+ useLayoutEffect(() => {
1712
+ const state = cut?.state ?? null;
1713
+ const key = state ? `${state.constant}|${state.normal.x},${state.normal.y},${state.normal.z}` : "";
1714
+ if (key === reportedSection.current) return;
1715
+ reportedSection.current = key;
1716
+ if (state) onSectionChange?.(state);
1717
+ }, [cut, onSectionChange]);
1718
+ const layerKey = [
1719
+ selection.join(" "),
1720
+ candidates.join(" "),
1721
+ pickedRegions.join(" "),
1722
+ hoveredFeatureIds.join(" "),
1723
+ highlights.map((entry) => `${entry.tag}:${entry.color}:${entry.weight ?? ""}`).join(" "),
1724
+ regionHighlights.map((entry) => `${entry.region}:${entry.color}:${entry.weight ?? ""}`).join(" ")
1725
+ ].join("|");
1726
+ useLayoutEffect(() => {
1727
+ repaint();
1728
+ }, [layerKey, repaint]);
1729
+ const isTap = useTapGuard();
1730
+ const pickFor = (event) => {
1731
+ const triangleIndex = event.faceIndex;
1732
+ if (triangleIndex == null) return null;
1733
+ const region = model.regionIndex.regionForTriangle(triangleIndex);
1734
+ if (region === null) return null;
1735
+ const target = readTarget(controls);
1736
+ const normal = event.face?.normal ?? UP;
1737
+ const source = event.nativeEvent;
1738
+ return buildPick({
1739
+ model,
1740
+ region,
1741
+ triangleIndex,
1742
+ point: [event.point.x, event.point.y, event.point.z],
1743
+ normal: [normal.x, normal.y, normal.z],
1744
+ activeDirection,
1745
+ viewDirection: viewDirection(camera, target),
1746
+ modifiers: {
1747
+ alt: source.altKey,
1748
+ ctrl: source.ctrlKey,
1749
+ meta: source.metaKey,
1750
+ shift: source.shiftKey,
1751
+ secondary: "button" in source && source.button === 2
1752
+ }
1753
+ });
1754
+ };
1755
+ const emitHover = (next) => {
1756
+ const region = next?.region ?? null;
1757
+ if (hoverRegion.current === region) return;
1758
+ hoverRegion.current = region;
1759
+ repaint();
1760
+ onHover?.(next);
1761
+ };
1762
+ const dragSection = useCallback3(
1763
+ (constant) => {
1764
+ if (!cut) return;
1765
+ const anchor = section?.plane?.point;
1766
+ onSectionChange?.({
1767
+ ...cut.state,
1768
+ constant,
1769
+ offset: sectionOffset(sectionBounds(box, cut.state.normal), constant),
1770
+ depth: anchor ? sectionDepth(cut.state.normal, anchor, constant) : null
1771
+ });
1772
+ },
1773
+ [box, cut, onSectionChange, section]
1774
+ );
1775
+ return /* @__PURE__ */ jsxs3(Fragment2, { children: [
1776
+ /* @__PURE__ */ jsx3(
1777
+ "primitive",
1778
+ {
1779
+ object: part.object,
1780
+ onPointerMove: (event) => emitHover(pickFor(event)),
1781
+ onPointerOut: () => emitHover(null),
1782
+ onClick: (event) => {
1783
+ if (!isTap(event.nativeEvent)) return;
1784
+ const pick = pickFor(event);
1785
+ if (pick) onPick?.(pick);
1786
+ }
1787
+ }
1788
+ ),
1789
+ cut ? /* @__PURE__ */ jsx3(
1790
+ SectionView,
1791
+ {
1792
+ geometry,
1793
+ box,
1794
+ plane: cut.plane,
1795
+ theme: resolved,
1796
+ showHandle: onSectionChange !== void 0,
1797
+ onDrag: onSectionChange ? dragSection : void 0
1798
+ }
1799
+ ) : null
1800
+ ] });
1801
+ };
1802
+ var ORIGIN = new Vector38();
1803
+ var UP = new Vector38(0, 0, 1);
1804
+ var TARGET = new Vector38();
1805
+ function readTarget(controls) {
1806
+ if (controls && typeof controls.getTarget === "function") {
1807
+ return controls.getTarget(TARGET);
1808
+ }
1809
+ const target = controls?.target;
1810
+ return target ?? ORIGIN;
1811
+ }
1812
+ function useStableTheme(theme) {
1813
+ const held = useRef5(null);
1814
+ const next = resolveTheme(theme);
1815
+ if (held.current === null || !themesEqual(held.current, next)) held.current = next;
1816
+ return held.current;
1817
+ }
1818
+
1819
+ // src/engine/geometry.ts
1820
+ import { Matrix4 as Matrix42, Mesh as Mesh2 } from "three";
1821
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
1822
+ import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
1823
+ var PartMeshError = class extends Error {
1824
+ name = "PartMeshError";
1825
+ };
1826
+ var IDENTITY = new Matrix42();
1827
+ function partMeshAssets(mesh) {
1828
+ const assets = [];
1829
+ if (mesh.glbUrl) assets.push({ format: "glb", url: mesh.glbUrl });
1830
+ if (mesh.stlUrl) assets.push({ format: "stl", url: mesh.stlUrl });
1831
+ return assets;
1832
+ }
1833
+ async function loadPartGeometry(url, mesh, options = {}) {
1834
+ const request = options.fetch ?? globalThis.fetch;
1835
+ const response = await request(url, options.signal ? { signal: options.signal } : void 0);
1836
+ if (!response.ok) {
1837
+ throw new PartMeshError(
1838
+ `Fetching the part mesh failed with ${response.status} ${response.statusText}. A presigned mesh URL expires 15 minutes after the report that carried it.`
1839
+ );
1840
+ }
1841
+ return parsePartGeometry(await response.arrayBuffer(), mesh, options.format ?? "glb");
1842
+ }
1843
+ async function loadPartMesh(mesh, options = {}) {
1844
+ const assets = partMeshAssets(mesh);
1845
+ if (assets.length === 0) {
1846
+ throw new PartMeshError("The report carries neither a GLB nor an STL mesh URL.");
1847
+ }
1848
+ const failures = [];
1849
+ for (const asset of assets) {
1850
+ try {
1851
+ return await loadPartGeometry(asset.url, mesh, { ...options, format: asset.format });
1852
+ } catch (error) {
1853
+ failures.push(error instanceof Error ? error : new Error(String(error)));
1854
+ }
1855
+ }
1856
+ throw new AggregateError(failures, "Could not load the part mesh.");
1857
+ }
1858
+ async function parsePartGeometry(data, mesh, format = "glb") {
1859
+ if (format === "stl") return parseStl(data, mesh);
1860
+ const loader = new GLTFLoader();
1861
+ let scene;
1862
+ try {
1863
+ ;
1864
+ ({ scene } = await loader.parseAsync(data, ""));
1865
+ } catch (cause) {
1866
+ throw new PartMeshError("The part mesh is not a readable GLB.", { cause });
1867
+ }
1868
+ const source = singleMesh(scene);
1869
+ const geometry = source.geometry;
1870
+ assertCounts(geometry, mesh);
1871
+ source.updateWorldMatrix(true, false);
1872
+ if (!source.matrixWorld.equals(IDENTITY)) {
1873
+ geometry.applyMatrix4(source.matrixWorld);
1874
+ }
1875
+ const prepared = geometry.index ? geometry.toNonIndexed() : geometry;
1876
+ if (prepared !== geometry) geometry.dispose();
1877
+ if (!prepared.hasAttribute("normal")) prepared.computeVertexNormals();
1878
+ disposeMaterials(scene);
1879
+ return prepared;
1880
+ }
1881
+ function parseStl(data, mesh) {
1882
+ let geometry;
1883
+ try {
1884
+ geometry = new STLLoader().parse(data);
1885
+ } catch (cause) {
1886
+ throw new PartMeshError("The part mesh is not a readable STL.", { cause });
1887
+ }
1888
+ assertCounts(geometry, mesh);
1889
+ if (!geometry.hasAttribute("normal")) geometry.computeVertexNormals();
1890
+ return geometry;
1891
+ }
1892
+ function singleMesh(scene) {
1893
+ const meshes = [];
1894
+ scene.traverse((object) => {
1895
+ if (object instanceof Mesh2) meshes.push(object);
1896
+ });
1897
+ const [mesh] = meshes;
1898
+ if (mesh === void 0 || meshes.length !== 1) {
1899
+ throw new PartMeshError(
1900
+ `The part mesh must be a single triangle mesh; this GLB has ${meshes.length}. Region triangle ranges index one buffer, so there is no order to apply them in.`
1901
+ );
1902
+ }
1903
+ return mesh;
1904
+ }
1905
+ function assertCounts(geometry, expected) {
1906
+ const position = geometry.getAttribute("position");
1907
+ if (position === void 0) {
1908
+ throw new PartMeshError("The part mesh has no POSITION attribute.");
1909
+ }
1910
+ const indices = geometry.index?.count ?? position.count;
1911
+ if (indices % 3 !== 0) {
1912
+ throw new PartMeshError(
1913
+ `The part mesh has ${indices} vertex indices, which is not a whole number of triangles.`
1914
+ );
1915
+ }
1916
+ const triangleCount = indices / 3;
1917
+ if (triangleCount !== expected.triangleCount) {
1918
+ throw new PartMeshError(
1919
+ `The report describes ${expected.triangleCount} triangles but the mesh has ${triangleCount}. Region ranges index the mesh directly, so the two must be the same artifact.`
1920
+ );
1921
+ }
1922
+ const points = geometry.index ? expected.pointCount : triangleCount * 3;
1923
+ if (position.count !== points) {
1924
+ throw new PartMeshError(
1925
+ `The report describes ${expected.pointCount} points but the mesh has ${position.count}.`
1926
+ );
1927
+ }
1928
+ }
1929
+ function disposeMaterials(scene) {
1930
+ scene.traverse((object) => {
1931
+ if (!(object instanceof Mesh2)) return;
1932
+ for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
1933
+ material.dispose();
1934
+ }
1935
+ });
1936
+ }
1937
+
1938
+ // src/engine/normals.ts
1939
+ import { Float32BufferAttribute as Float32BufferAttribute3 } from "three";
1940
+ function smoothRegionNormals(geometry, regions) {
1941
+ const position = geometry.getAttribute("position");
1942
+ if (!position || geometry.index) return;
1943
+ const vertexCount = position.count;
1944
+ const triangleCount = Math.floor(vertexCount / 3);
1945
+ const surfaces = visualSurfaces(geometry, regions);
1946
+ const regionOf = new Int32Array(triangleCount).fill(-1);
1947
+ for (const region of regions) {
1948
+ const end = Math.min(region.triangles.end, triangleCount);
1949
+ for (let triangle = region.triangles.start; triangle < end; triangle += 1) {
1950
+ regionOf[triangle] = surfaces.get(region.idx) ?? region.idx;
1951
+ }
1952
+ }
1953
+ const sums = /* @__PURE__ */ new Map();
1954
+ const keys = new Array(vertexCount);
1955
+ for (let triangle = 0; triangle < triangleCount; triangle += 1) {
1956
+ const a = triangle * 3;
1957
+ const ax = position.getX(a);
1958
+ const ay = position.getY(a);
1959
+ const az = position.getZ(a);
1960
+ const bx = position.getX(a + 1);
1961
+ const by = position.getY(a + 1);
1962
+ const bz = position.getZ(a + 1);
1963
+ const cx = position.getX(a + 2);
1964
+ const cy = position.getY(a + 2);
1965
+ const cz = position.getZ(a + 2);
1966
+ const ux = bx - ax;
1967
+ const uy = by - ay;
1968
+ const uz = bz - az;
1969
+ const vx = cx - ax;
1970
+ const vy = cy - ay;
1971
+ const vz = cz - az;
1972
+ const nx = uy * vz - uz * vy;
1973
+ const ny = uz * vx - ux * vz;
1974
+ const nz = ux * vy - uy * vx;
1975
+ const region = regionOf[triangle] ?? -1;
1976
+ const corners = [
1977
+ [a, ax, ay, az],
1978
+ [a + 1, bx, by, bz],
1979
+ [a + 2, cx, cy, cz]
1980
+ ];
1981
+ for (const [vertex, x, y, z] of corners) {
1982
+ const key = `${region}|${x}|${y}|${z}`;
1983
+ keys[vertex] = key;
1984
+ const sum = sums.get(key);
1985
+ if (sum) {
1986
+ sum[0] += nx;
1987
+ sum[1] += ny;
1988
+ sum[2] += nz;
1989
+ } else {
1990
+ sums.set(key, [nx, ny, nz]);
1991
+ }
1992
+ }
1993
+ }
1994
+ const normals = new Float32Array(vertexCount * 3);
1995
+ for (let vertex = 0; vertex < vertexCount; vertex += 1) {
1996
+ const sum = sums.get(keys[vertex] ?? "");
1997
+ if (!sum) continue;
1998
+ const [x, y, z] = sum;
1999
+ const length = Math.hypot(x, y, z);
2000
+ if (length <= 1e-12) continue;
2001
+ normals[vertex * 3] = x / length;
2002
+ normals[vertex * 3 + 1] = y / length;
2003
+ normals[vertex * 3 + 2] = z / length;
2004
+ }
2005
+ geometry.setAttribute("normal", new Float32BufferAttribute3(normals, 3));
2006
+ }
2007
+
2008
+ // src/engine/geometry-cache.ts
2009
+ function engineGeometryResourceKey(mesh) {
2010
+ return [mesh.glbUrl, mesh.stlUrl].map((url) => url?.split("?")[0] ?? "").join("|");
2011
+ }
2012
+ function createEngineGeometryCache(loadGeometry = async (part) => {
2013
+ const geometry = await loadPartMesh(part.mesh);
2014
+ smoothRegionNormals(geometry, part.regions);
2015
+ return geometry;
2016
+ }, maximumEntries = 8) {
2017
+ const resources = /* @__PURE__ */ new Map();
2018
+ let accessSequence = 0;
2019
+ const touch = (resource) => {
2020
+ resource.lastAccess = ++accessSequence;
2021
+ };
2022
+ const evictReleasedResources = () => {
2023
+ while (resources.size > maximumEntries) {
2024
+ let oldest;
2025
+ for (const entry of resources) {
2026
+ const [, resource] = entry;
2027
+ if (resource.status !== "fulfilled" || resource.references > 0 || oldest && oldest[1].lastAccess <= resource.lastAccess) {
2028
+ continue;
2029
+ }
2030
+ oldest = entry;
2031
+ }
2032
+ if (!oldest) return;
2033
+ resources.delete(oldest[0]);
2034
+ oldest[1].geometry?.dispose();
2035
+ }
2036
+ };
2037
+ return {
2038
+ get(part) {
2039
+ const key = engineGeometryResourceKey(part.mesh);
2040
+ const existing = resources.get(key);
2041
+ if (existing) {
2042
+ touch(existing);
2043
+ return existing;
2044
+ }
2045
+ const resource = {
2046
+ status: "pending",
2047
+ promise: Promise.resolve(),
2048
+ references: 0,
2049
+ lastAccess: ++accessSequence
2050
+ };
2051
+ resource.promise = loadGeometry(part).then(
2052
+ (geometry) => {
2053
+ resource.status = "fulfilled";
2054
+ resource.geometry = geometry;
2055
+ },
2056
+ (error) => {
2057
+ resource.status = "rejected";
2058
+ resource.error = error instanceof Error ? error : new Error(String(error));
2059
+ if (resources.get(key) === resource) resources.delete(key);
2060
+ }
2061
+ );
2062
+ resources.set(key, resource);
2063
+ return resource;
2064
+ },
2065
+ retain(resource) {
2066
+ resource.references += 1;
2067
+ touch(resource);
2068
+ },
2069
+ release(resource) {
2070
+ resource.references = Math.max(0, resource.references - 1);
2071
+ touch(resource);
2072
+ evictReleasedResources();
2073
+ },
2074
+ clear() {
2075
+ for (const resource of resources.values()) resource.geometry?.dispose();
2076
+ resources.clear();
2077
+ }
2078
+ };
2079
+ }
2080
+ var engineGeometryCache = createEngineGeometryCache();
2081
+
2082
+ // src/model/errors.ts
2083
+ var PartReportFormatError = class extends Error {
2084
+ name = "PartReportFormatError";
2085
+ /** Every problem found, not just the first. */
2086
+ issues;
2087
+ constructor(issues) {
2088
+ super(
2089
+ issues.length === 1 ? `Malformed part report: ${issues[0]}` : `Malformed part report (${issues.length} problems):
2090
+ - ${issues.join("\n- ")}`
2091
+ );
2092
+ this.issues = issues;
2093
+ }
2094
+ };
2095
+ var UnsupportedKernelVersionError = class extends Error {
2096
+ name = "UnsupportedKernelVersionError";
2097
+ kernelVersion;
2098
+ minimumKernelVersion;
2099
+ constructor(kernelVersion, minimumKernelVersion) {
2100
+ super(
2101
+ `Part report is from kernel ${kernelVersion}; @toolpath/viewer requires ${minimumKernelVersion} or newer, which is the first version to publish regions[] and featureTag. Re-analyze the part on a current engine.`
2102
+ );
2103
+ this.kernelVersion = kernelVersion;
2104
+ this.minimumKernelVersion = minimumKernelVersion;
2105
+ }
2106
+ };
2107
+
2108
+ // src/model/region-index.ts
2109
+ function buildRegionIndex(input) {
2110
+ const { regions, features, triangleCount } = input;
2111
+ const issues = [];
2112
+ if (!Number.isInteger(triangleCount) || triangleCount < 0) {
2113
+ issues.push("meshTriangleCount must be a non-negative integer");
2114
+ }
2115
+ const byIdx = /* @__PURE__ */ new Map();
2116
+ for (const region of regions) {
2117
+ const { idx, triangles } = region;
2118
+ if (!Number.isInteger(idx) || idx < 0) {
2119
+ issues.push(`region idx ${idx} is not a non-negative integer`);
2120
+ continue;
2121
+ }
2122
+ if (byIdx.has(idx)) {
2123
+ issues.push(`region idx ${idx} appears more than once`);
2124
+ continue;
2125
+ }
2126
+ if (!Number.isInteger(triangles.start) || !Number.isInteger(triangles.end) || triangles.start < 0 || triangles.end < triangles.start) {
2127
+ issues.push(
2128
+ `region ${idx} has an invalid triangle range [${triangles.start}, ${triangles.end})`
2129
+ );
2130
+ continue;
2131
+ }
2132
+ if (triangles.end > triangleCount) {
2133
+ issues.push(
2134
+ `region ${idx} ends at triangle ${triangles.end}, past meshTriangleCount ${triangleCount}`
2135
+ );
2136
+ continue;
2137
+ }
2138
+ byIdx.set(idx, region);
2139
+ }
2140
+ const sorted = [...byIdx.values()].sort((a, b) => a.triangles.start - b.triangles.start);
2141
+ let expected = 0;
2142
+ for (const region of sorted) {
2143
+ if (region.triangles.start !== expected) {
2144
+ issues.push(
2145
+ region.triangles.start > expected ? `triangles [${expected}, ${region.triangles.start}) belong to no region` : `region ${region.idx} overlaps the region before it at triangle ${region.triangles.start}`
2146
+ );
2147
+ }
2148
+ expected = Math.max(expected, region.triangles.end);
2149
+ }
2150
+ if (issues.length === 0 && expected !== triangleCount) {
2151
+ issues.push(`regions cover ${expected} triangles but the mesh has ${triangleCount}`);
2152
+ }
2153
+ const regionsByFeature = /* @__PURE__ */ new Map();
2154
+ const featuresByRegion = /* @__PURE__ */ new Map();
2155
+ for (const feature of features) {
2156
+ if (regionsByFeature.has(feature.tag)) {
2157
+ issues.push(`featureTag ${feature.tag} appears more than once`);
2158
+ continue;
2159
+ }
2160
+ for (const idx of feature.regionIdxs) {
2161
+ if (!byIdx.has(idx)) {
2162
+ issues.push(`feature ${feature.tag} references region ${idx}, which does not exist`);
2163
+ continue;
2164
+ }
2165
+ const owners = featuresByRegion.get(idx);
2166
+ if (owners) owners.push(feature.tag);
2167
+ else featuresByRegion.set(idx, [feature.tag]);
2168
+ }
2169
+ regionsByFeature.set(feature.tag, [...feature.regionIdxs]);
2170
+ }
2171
+ if (issues.length > 0) throw new PartReportFormatError(issues);
2172
+ const starts = new Int32Array(sorted.length);
2173
+ const ends = new Int32Array(sorted.length);
2174
+ const regionIds = new Int32Array(sorted.length);
2175
+ for (const [i, region] of sorted.entries()) {
2176
+ starts[i] = region.triangles.start;
2177
+ ends[i] = region.triangles.end;
2178
+ regionIds[i] = region.idx;
2179
+ }
2180
+ const noFeatures = Object.freeze([]);
2181
+ const noRegions = Object.freeze([]);
2182
+ return {
2183
+ regionCount: sorted.length,
2184
+ regionForTriangle(triangle) {
2185
+ if (!Number.isInteger(triangle) || triangle < 0) return null;
2186
+ const i = upperBound(starts, triangle) - 1;
2187
+ if (i < 0) return null;
2188
+ return triangle < ends[i] ? regionIds[i] : null;
2189
+ },
2190
+ featuresForRegion(region) {
2191
+ return featuresByRegion.get(region) ?? noFeatures;
2192
+ },
2193
+ regionsForFeature(tag) {
2194
+ return regionsByFeature.get(tag) ?? noRegions;
2195
+ },
2196
+ rangeForRegion(region) {
2197
+ const found = byIdx.get(region);
2198
+ return found ? found.triangles : null;
2199
+ }
2200
+ };
2201
+ }
2202
+ function upperBound(values, target) {
2203
+ let low = 0;
2204
+ let high = values.length;
2205
+ while (low < high) {
2206
+ const mid = low + high >>> 1;
2207
+ if (values[mid] <= target) low = mid + 1;
2208
+ else high = mid;
2209
+ }
2210
+ return low;
2211
+ }
2212
+
2213
+ // src/engine/guards.ts
2214
+ function isRecord(value) {
2215
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2216
+ }
2217
+ function isFiniteNumber(value) {
2218
+ return typeof value === "number" && Number.isFinite(value);
2219
+ }
2220
+ function isNonNegativeInteger(value) {
2221
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
2222
+ }
2223
+ function isString(value) {
2224
+ return typeof value === "string";
2225
+ }
2226
+ function isNullableString(value) {
2227
+ return value === null || typeof value === "string";
2228
+ }
2229
+
2230
+ // src/engine/normalize.ts
2231
+ var MIN_KERNEL_VERSION = "0.3.0";
2232
+ function normalizePartReport(report) {
2233
+ if (!isRecord(report)) {
2234
+ throw new PartReportFormatError(["report is not an object"]);
2235
+ }
2236
+ const kernelVersion = report["kernelVersion"];
2237
+ if (!isString(kernelVersion)) {
2238
+ throw new PartReportFormatError(["kernelVersion is missing or not a string"]);
2239
+ }
2240
+ assertSupportedKernelVersion(kernelVersion);
2241
+ const issues = [];
2242
+ const warnings = [];
2243
+ const partId = requireString(report["partId"], "partId", issues);
2244
+ const meshPointCount = requireCount(report["meshPointCount"], "meshPointCount", issues);
2245
+ const meshTriangleCount = requireCount(report["meshTriangleCount"], "meshTriangleCount", issues);
2246
+ const regions = readRegions(report["regions"], issues);
2247
+ const features = readFeatures(report["features"], issues, warnings);
2248
+ const candidateDirections = readDirections(report["candidateDirections"], issues);
2249
+ for (const [key, value] of [
2250
+ ["meshGlbUrl", report["meshGlbUrl"]],
2251
+ ["meshStlUrl", report["meshStlUrl"]],
2252
+ ["thumbnailUrl", report["thumbnailUrl"]]
2253
+ ]) {
2254
+ if (value !== void 0 && !isNullableString(value)) {
2255
+ issues.push(`${key} is neither a string nor null`);
2256
+ }
2257
+ }
2258
+ if (issues.length > 0 || partId === null || meshPointCount === null || meshTriangleCount === null) {
2259
+ throw new PartReportFormatError(issues);
2260
+ }
2261
+ const regionIndex = buildRegionIndex({
2262
+ regions,
2263
+ features,
2264
+ triangleCount: meshTriangleCount
2265
+ });
2266
+ return {
2267
+ partId,
2268
+ kernelVersion,
2269
+ features,
2270
+ regions,
2271
+ candidateDirections,
2272
+ mesh: {
2273
+ pointCount: meshPointCount,
2274
+ triangleCount: meshTriangleCount,
2275
+ glbUrl: readNullableString(report["meshGlbUrl"]),
2276
+ stlUrl: readNullableString(report["meshStlUrl"]),
2277
+ thumbnailUrl: readNullableString(report["thumbnailUrl"])
2278
+ },
2279
+ regionIndex,
2280
+ warnings
2281
+ };
2282
+ }
2283
+ function assertSupportedKernelVersion(kernelVersion) {
2284
+ const parsed = parseVersion(kernelVersion);
2285
+ if (parsed === null) {
2286
+ throw new PartReportFormatError([
2287
+ `kernelVersion "${kernelVersion}" is not a recognizable version`
2288
+ ]);
2289
+ }
2290
+ const minimum = parseVersion(MIN_KERNEL_VERSION);
2291
+ if (minimum === null || compareVersions(parsed, minimum) < 0) {
2292
+ throw new UnsupportedKernelVersionError(kernelVersion, MIN_KERNEL_VERSION);
2293
+ }
2294
+ }
2295
+ function readRegions(value, issues) {
2296
+ if (!Array.isArray(value)) {
2297
+ issues.push("regions is missing or not an array \u2014 a 0.3.0 report always has one");
2298
+ return [];
2299
+ }
2300
+ const regions = [];
2301
+ for (const [i, raw] of value.entries()) {
2302
+ if (!isRecord(raw)) {
2303
+ issues.push(`regions[${i}] is not an object`);
2304
+ continue;
2305
+ }
2306
+ const idx = raw["idx"];
2307
+ const start = raw["triangleStart"];
2308
+ const end = raw["triangleEnd"];
2309
+ const area = raw["area"];
2310
+ const shapeKind = raw["shapeKind"];
2311
+ if (!isNonNegativeInteger(idx) || !isNonNegativeInteger(start) || !isNonNegativeInteger(end) || !isFiniteNumber(area) || !isString(shapeKind)) {
2312
+ issues.push(`regions[${i}] does not match the Region schema`);
2313
+ continue;
2314
+ }
2315
+ regions.push({ idx, shapeKind, area, triangles: { start, end } });
2316
+ }
2317
+ return regions;
2318
+ }
2319
+ function readFeatures(value, issues, warnings) {
2320
+ if (!Array.isArray(value)) {
2321
+ issues.push("features is missing or not an array");
2322
+ return [];
2323
+ }
2324
+ const features = [];
2325
+ for (const [i, raw] of value.entries()) {
2326
+ if (!isRecord(raw)) {
2327
+ issues.push(`features[${i}] is not an object`);
2328
+ continue;
2329
+ }
2330
+ const tag = raw["featureTag"];
2331
+ const featureType = raw["featureType"];
2332
+ const regionIdxs = raw["regionIdxs"];
2333
+ const machiningDirection = readVec3(raw["machiningDirection"]);
2334
+ if (!isString(tag)) {
2335
+ issues.push(`features[${i}] has no featureTag`);
2336
+ continue;
2337
+ }
2338
+ if (!isString(featureType)) {
2339
+ issues.push(`feature ${tag} has no featureType`);
2340
+ continue;
2341
+ }
2342
+ if (machiningDirection === null) {
2343
+ issues.push(`feature ${tag} has no machiningDirection`);
2344
+ continue;
2345
+ }
2346
+ if (!isRegionIdxArray(regionIdxs)) {
2347
+ issues.push(`feature ${tag} has an invalid regionIdxs`);
2348
+ continue;
2349
+ }
2350
+ features.push({
2351
+ tag,
2352
+ featureType,
2353
+ machiningDirection,
2354
+ // Absent and null are both normal — plenty of features have no natural
2355
+ // axis. A value that is present but unreadable is a data problem worth
2356
+ // saying out loud, and not one worth failing a load over: nothing is
2357
+ // rendered from the axis.
2358
+ axis: readAxis(raw["axis"], tag, warnings),
2359
+ regionIdxs
2360
+ });
2361
+ }
2362
+ return features;
2363
+ }
2364
+ function readDirections(value, issues) {
2365
+ if (!Array.isArray(value)) {
2366
+ issues.push("candidateDirections is missing or not an array");
2367
+ return [];
2368
+ }
2369
+ const directions = [];
2370
+ for (const [i, raw] of value.entries()) {
2371
+ const vec = readVec3(raw);
2372
+ if (vec === null) {
2373
+ issues.push(`candidateDirections[${i}] is not a vector`);
2374
+ continue;
2375
+ }
2376
+ directions.push(vec);
2377
+ }
2378
+ return directions;
2379
+ }
2380
+ function readAxis(value, tag, warnings) {
2381
+ if (value === null || value === void 0) return null;
2382
+ const axis = readVec3(value);
2383
+ if (axis === null) {
2384
+ warnings.push(`feature ${tag} has an axis this package cannot read; dropped`);
2385
+ }
2386
+ return axis;
2387
+ }
2388
+ function isRegionIdxArray(value) {
2389
+ return Array.isArray(value) && value.every(isNonNegativeInteger);
2390
+ }
2391
+ function requireString(value, field, issues) {
2392
+ if (isString(value)) return value;
2393
+ issues.push(`${field} is missing or not a string`);
2394
+ return null;
2395
+ }
2396
+ function requireCount(value, field, issues) {
2397
+ if (isNonNegativeInteger(value)) return value;
2398
+ issues.push(`${field} is missing or not a non-negative integer`);
2399
+ return null;
2400
+ }
2401
+ function readVec3(value) {
2402
+ if (!isRecord(value)) return null;
2403
+ const { x, y, z } = value;
2404
+ if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(z)) return null;
2405
+ return { x, y, z };
2406
+ }
2407
+ function readNullableString(value) {
2408
+ return isString(value) ? value : null;
2409
+ }
2410
+ function parseVersion(value) {
2411
+ const core = value.trim().split(/[-+]/, 1)[0] ?? "";
2412
+ const parts = core.split(".");
2413
+ if (parts.length < 2 || parts.length > 3) return null;
2414
+ const numbers = parts.map((part) => /^\d+$/.test(part) ? Number(part) : NaN);
2415
+ if (numbers.some(Number.isNaN)) return null;
2416
+ return [numbers[0] ?? 0, numbers[1] ?? 0, numbers[2] ?? 0];
2417
+ }
2418
+ function compareVersions(a, b) {
2419
+ for (let i = 0; i < 3; i += 1) {
2420
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
2421
+ if (diff !== 0) return diff;
2422
+ }
2423
+ return 0;
2424
+ }
2425
+
2426
+ // src/engine/engine-part.tsx
2427
+ import { jsx as jsx4 } from "react/jsx-runtime";
2428
+ var EnginePart = ({ report, ...props }) => {
2429
+ const model = useMemo6(() => normalizePartReport(report), [report]);
2430
+ const resource = useGeometryResource(model);
2431
+ useEffect5(() => {
2432
+ engineGeometryCache.retain(resource);
2433
+ return () => engineGeometryCache.release(resource);
2434
+ }, [resource]);
2435
+ return /* @__PURE__ */ jsx4(PartMesh, { model, geometry: resource.geometry, ...props });
2436
+ };
2437
+ function useGeometryResource(part) {
2438
+ const resource = engineGeometryCache.get(part);
2439
+ if (resource.status === "pending") throw resource.promise;
2440
+ if (resource.status === "rejected") throw resource.error;
2441
+ return resource;
2442
+ }
2443
+
2444
+ export {
2445
+ sameDirection,
2446
+ directionIndexOf,
2447
+ groupByDirection,
2448
+ directionLabel,
2449
+ CONTINUES_WITHIN,
2450
+ visualSurfaces,
2451
+ HIGHLIGHT_COLORS,
2452
+ DIRECTION_COLORS,
2453
+ DEFAULT_THEME,
2454
+ resolveTheme,
2455
+ themesEqual,
2456
+ directionColor,
2457
+ HIGHLIGHT_WEIGHT,
2458
+ CANDIDATE_WEIGHT,
2459
+ HOVER_WEIGHT,
2460
+ applyHighlightLayers,
2461
+ SECTION_RENDER_ORDER,
2462
+ HANDLE_PIXELS,
2463
+ PICKED_SURFACE_LABEL,
2464
+ sectionBounds,
2465
+ sectionConstant,
2466
+ sectionOffset,
2467
+ sectionDepth,
2468
+ sectionDepthConstant,
2469
+ sectionDepthRange,
2470
+ sectionFromPick,
2471
+ pickedStartDepth,
2472
+ sectionPlane,
2473
+ screenLength,
2474
+ dragPlane,
2475
+ TAP_SLOP,
2476
+ movedFar,
2477
+ trackTaps,
2478
+ useTapGuard,
2479
+ regionEdgesGeometry,
2480
+ REGION_ATTRIBUTE,
2481
+ buildRegionTexels,
2482
+ buildRegionAttribute,
2483
+ createPart,
2484
+ FEATURE_TYPE_RANKS,
2485
+ featureTypeRank,
2486
+ rankOwners,
2487
+ bestOwner,
2488
+ cycleOwner,
2489
+ NO_MODIFIERS,
2490
+ viewDirection,
2491
+ buildPick,
2492
+ focusForPick,
2493
+ PERSPECTIVE_FOV,
2494
+ DEFAULT_FIT_MARGIN,
2495
+ EXCLUDE_FROM_FRAME,
2496
+ defaultBounds,
2497
+ aspectRatio,
2498
+ boundsFromBox,
2499
+ contentBounds,
2500
+ perspectiveFitDistance,
2501
+ orthographicHalfHeight,
2502
+ fitDistance,
2503
+ startPosition,
2504
+ applyProjection,
2505
+ CAD_CAMERA_UP,
2506
+ cadViewDirections,
2507
+ currentViewDirection,
2508
+ ExtendedCameraControls,
2509
+ CadCameraControls,
2510
+ useViewerControls,
2511
+ Viewer,
2512
+ CONE_AXIS,
2513
+ HEAD,
2514
+ HEAD_RADIUS,
2515
+ SHAFT_RADIUS,
2516
+ arrowPlacement,
2517
+ resolveSectionPlane,
2518
+ SectionView,
2519
+ useContentBox,
2520
+ PartMesh,
2521
+ PartMeshError,
2522
+ partMeshAssets,
2523
+ loadPartGeometry,
2524
+ loadPartMesh,
2525
+ parsePartGeometry,
2526
+ smoothRegionNormals,
2527
+ engineGeometryResourceKey,
2528
+ createEngineGeometryCache,
2529
+ engineGeometryCache,
2530
+ PartReportFormatError,
2531
+ UnsupportedKernelVersionError,
2532
+ buildRegionIndex,
2533
+ MIN_KERNEL_VERSION,
2534
+ normalizePartReport,
2535
+ assertSupportedKernelVersion,
2536
+ EnginePart
2537
+ };