@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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/chunk-7NQBV7EQ.js +2537 -0
- package/dist/engine/index.d.ts +118 -0
- package/dist/engine/index.js +30 -0
- package/dist/index.d.ts +637 -0
- package/dist/index.js +676 -0
- package/dist/normalize-B0HBvzGu.d.ts +868 -0
- package/package.json +68 -0
|
@@ -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
|
+
};
|