@ifc-lite/renderer 1.1.7 → 1.2.1

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.
Files changed (46) hide show
  1. package/LICENSE +373 -0
  2. package/dist/bvh.d.ts +50 -0
  3. package/dist/bvh.d.ts.map +1 -0
  4. package/dist/bvh.js +177 -0
  5. package/dist/bvh.js.map +1 -0
  6. package/dist/camera.d.ts +17 -0
  7. package/dist/camera.d.ts.map +1 -1
  8. package/dist/camera.js +72 -6
  9. package/dist/camera.js.map +1 -1
  10. package/dist/index.d.ts +51 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +587 -125
  13. package/dist/index.js.map +1 -1
  14. package/dist/math.d.ts +30 -0
  15. package/dist/math.d.ts.map +1 -1
  16. package/dist/math.js +103 -0
  17. package/dist/math.js.map +1 -1
  18. package/dist/picker.js +2 -2
  19. package/dist/picker.js.map +1 -1
  20. package/dist/pipeline.d.ts +17 -0
  21. package/dist/pipeline.d.ts.map +1 -1
  22. package/dist/pipeline.js +351 -49
  23. package/dist/pipeline.js.map +1 -1
  24. package/dist/raycaster.d.ts +67 -0
  25. package/dist/raycaster.d.ts.map +1 -0
  26. package/dist/raycaster.js +192 -0
  27. package/dist/raycaster.js.map +1 -0
  28. package/dist/scene.d.ts +56 -2
  29. package/dist/scene.d.ts.map +1 -1
  30. package/dist/scene.js +362 -26
  31. package/dist/scene.js.map +1 -1
  32. package/dist/section-plane.d.ts +14 -4
  33. package/dist/section-plane.d.ts.map +1 -1
  34. package/dist/section-plane.js +129 -53
  35. package/dist/section-plane.js.map +1 -1
  36. package/dist/snap-detector.d.ts +119 -0
  37. package/dist/snap-detector.d.ts.map +1 -0
  38. package/dist/snap-detector.js +706 -0
  39. package/dist/snap-detector.js.map +1 -0
  40. package/dist/types.d.ts +14 -1
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/zero-copy-uploader.d.ts +145 -0
  43. package/dist/zero-copy-uploader.d.ts.map +1 -0
  44. package/dist/zero-copy-uploader.js +146 -0
  45. package/dist/zero-copy-uploader.js.map +1 -0
  46. package/package.json +11 -10
@@ -0,0 +1,706 @@
1
+ import { Raycaster } from './raycaster';
2
+ export var SnapType;
3
+ (function (SnapType) {
4
+ SnapType["VERTEX"] = "vertex";
5
+ SnapType["EDGE"] = "edge";
6
+ SnapType["FACE"] = "face";
7
+ SnapType["FACE_CENTER"] = "face_center";
8
+ })(SnapType || (SnapType = {}));
9
+ // Magnetic snapping configuration constants
10
+ const MAGNETIC_CONFIG = {
11
+ // Edge attraction zone = base radius × this multiplier
12
+ EDGE_ATTRACTION_MULTIPLIER: 3.0,
13
+ // Corner attraction zone = edge zone × this multiplier
14
+ CORNER_ATTRACTION_MULTIPLIER: 2.0,
15
+ // Confidence boost per connected edge at corner
16
+ CORNER_CONFIDENCE_BOOST: 0.15,
17
+ // Must move perpendicular × this factor to escape locked edge
18
+ EDGE_ESCAPE_MULTIPLIER: 2.5,
19
+ // Corner escape requires even more movement
20
+ CORNER_ESCAPE_MULTIPLIER: 3.5,
21
+ // Lock strength growth per frame while locked
22
+ LOCK_STRENGTH_GROWTH: 0.05,
23
+ // Maximum lock strength
24
+ MAX_LOCK_STRENGTH: 1.5,
25
+ // Minimum edges at vertex for corner detection
26
+ MIN_CORNER_VALENCE: 2,
27
+ // Distance threshold for corner detection (percentage of edge length)
28
+ CORNER_THRESHOLD: 0.08,
29
+ };
30
+ export class SnapDetector {
31
+ raycaster = new Raycaster();
32
+ defaultOptions = {
33
+ snapToVertices: true,
34
+ snapToEdges: true,
35
+ snapToFaces: true,
36
+ snapRadius: 0.1, // 10cm in world units (meters)
37
+ screenSnapRadius: 20, // pixels
38
+ };
39
+ // Cache for processed mesh geometry (vertices and edges)
40
+ geometryCache = new Map();
41
+ /**
42
+ * Detect best snap target near cursor
43
+ */
44
+ detectSnapTarget(ray, meshes, intersection, camera, screenHeight, options = {}) {
45
+ const opts = { ...this.defaultOptions, ...options };
46
+ if (!intersection) {
47
+ return null;
48
+ }
49
+ const targets = [];
50
+ // Calculate world-space snap radius based on screen-space radius and distance
51
+ const distanceToCamera = this.distance(camera.position, intersection.point);
52
+ const worldSnapRadius = this.screenToWorldRadius(opts.screenSnapRadius, distanceToCamera, camera.fov, screenHeight);
53
+ // Only check the intersected mesh for snap targets (performance optimization)
54
+ // Checking all meshes was causing severe framerate drops with large models
55
+ const intersectedMesh = meshes[intersection.meshIndex];
56
+ if (intersectedMesh) {
57
+ // Detect vertices
58
+ if (opts.snapToVertices) {
59
+ targets.push(...this.findVertices(intersectedMesh, intersection.point, worldSnapRadius));
60
+ }
61
+ // Detect edges
62
+ if (opts.snapToEdges) {
63
+ targets.push(...this.findEdges(intersectedMesh, intersection.point, worldSnapRadius));
64
+ }
65
+ // Detect faces
66
+ if (opts.snapToFaces) {
67
+ targets.push(...this.findFaces(intersectedMesh, intersection, worldSnapRadius));
68
+ }
69
+ }
70
+ // Return best target
71
+ return this.getBestSnapTarget(targets, intersection.point);
72
+ }
73
+ /**
74
+ * Detect snap target with magnetic edge locking behavior
75
+ * This provides the "stick and slide along edges" experience
76
+ */
77
+ detectMagneticSnap(ray, meshes, intersection, camera, screenHeight, currentEdgeLock, options = {}) {
78
+ const opts = { ...this.defaultOptions, ...options };
79
+ // Default result when no intersection
80
+ if (!intersection) {
81
+ return {
82
+ snapTarget: null,
83
+ edgeLock: {
84
+ edge: null,
85
+ meshExpressId: null,
86
+ edgeT: 0,
87
+ shouldLock: false,
88
+ shouldRelease: true,
89
+ isCorner: false,
90
+ cornerValence: 0,
91
+ },
92
+ };
93
+ }
94
+ const distanceToCamera = this.distance(camera.position, intersection.point);
95
+ const worldSnapRadius = this.screenToWorldRadius(opts.screenSnapRadius, distanceToCamera, camera.fov, screenHeight);
96
+ const intersectedMesh = meshes[intersection.meshIndex];
97
+ if (!intersectedMesh) {
98
+ return {
99
+ snapTarget: null,
100
+ edgeLock: {
101
+ edge: null,
102
+ meshExpressId: null,
103
+ edgeT: 0,
104
+ shouldLock: false,
105
+ shouldRelease: true,
106
+ isCorner: false,
107
+ cornerValence: 0,
108
+ },
109
+ };
110
+ }
111
+ const cache = this.getGeometryCache(intersectedMesh);
112
+ // If edge snapping is disabled, skip edge logic entirely
113
+ if (!opts.snapToEdges) {
114
+ // Just return face/vertex snap as fallback
115
+ const targets = [];
116
+ if (opts.snapToFaces) {
117
+ targets.push(...this.findFaces(intersectedMesh, intersection, worldSnapRadius));
118
+ }
119
+ if (opts.snapToVertices) {
120
+ targets.push(...this.findVertices(intersectedMesh, intersection.point, worldSnapRadius));
121
+ }
122
+ return {
123
+ snapTarget: this.getBestSnapTarget(targets, intersection.point),
124
+ edgeLock: {
125
+ edge: null,
126
+ meshExpressId: null,
127
+ edgeT: 0,
128
+ shouldLock: false,
129
+ shouldRelease: true, // Release any existing lock when edge snapping disabled
130
+ isCorner: false,
131
+ cornerValence: 0,
132
+ },
133
+ };
134
+ }
135
+ // Track whether we're releasing from a previous lock
136
+ let wasLockReleased = false;
137
+ // If we have an active edge lock, try to maintain it
138
+ if (currentEdgeLock.edge && currentEdgeLock.meshExpressId === intersectedMesh.expressId) {
139
+ const lockResult = this.maintainEdgeLock(intersection.point, currentEdgeLock, cache, worldSnapRadius, intersectedMesh.expressId);
140
+ if (!lockResult.edgeLock.shouldRelease) {
141
+ // Still locked - return the sliding position
142
+ return lockResult;
143
+ }
144
+ // Lock was released - continue to find new edges but remember we released
145
+ wasLockReleased = true;
146
+ }
147
+ // No active lock or lock released - find best snap target with magnetic behavior
148
+ const edgeRadius = worldSnapRadius * MAGNETIC_CONFIG.EDGE_ATTRACTION_MULTIPLIER;
149
+ const cornerRadius = edgeRadius * MAGNETIC_CONFIG.CORNER_ATTRACTION_MULTIPLIER;
150
+ // Compute view direction for visibility filtering
151
+ const viewDir = {
152
+ x: intersection.point.x - camera.position.x,
153
+ y: intersection.point.y - camera.position.y,
154
+ z: intersection.point.z - camera.position.z,
155
+ };
156
+ const viewLen = Math.sqrt(viewDir.x * viewDir.x + viewDir.y * viewDir.y + viewDir.z * viewDir.z);
157
+ if (viewLen > 0) {
158
+ viewDir.x /= viewLen;
159
+ viewDir.y /= viewLen;
160
+ viewDir.z /= viewLen;
161
+ }
162
+ // Find all nearby edges (filtered for visibility)
163
+ const nearbyEdges = [];
164
+ for (const edge of cache.edges) {
165
+ const result = this.closestPointOnEdgeWithT(intersection.point, edge.v0, edge.v1);
166
+ if (result.distance < edgeRadius) {
167
+ // Visibility check: edge should be on front-facing side
168
+ // Compute vector from intersection point to edge closest point
169
+ const toEdge = {
170
+ x: result.point.x - intersection.point.x,
171
+ y: result.point.y - intersection.point.y,
172
+ z: result.point.z - intersection.point.z,
173
+ };
174
+ // Check if edge point is roughly on the visible side (dot with normal should be <= small positive)
175
+ // Edges that are clearly behind the surface are filtered out
176
+ const dotWithNormal = toEdge.x * intersection.normal.x + toEdge.y * intersection.normal.y + toEdge.z * intersection.normal.z;
177
+ // Allow edges that are on the surface or slightly in front (tolerance for edge proximity)
178
+ // Filter out edges that are clearly behind the intersected surface
179
+ if (dotWithNormal <= edgeRadius * 0.5) {
180
+ nearbyEdges.push({
181
+ edge,
182
+ closestPoint: result.point,
183
+ distance: result.distance,
184
+ t: result.t,
185
+ });
186
+ }
187
+ }
188
+ }
189
+ // No nearby edges - use best available snap (faces/vertices)
190
+ if (nearbyEdges.length === 0) {
191
+ const candidates = [];
192
+ if (opts.snapToFaces) {
193
+ candidates.push(...this.findFaces(intersectedMesh, intersection, worldSnapRadius));
194
+ }
195
+ if (opts.snapToVertices) {
196
+ candidates.push(...this.findVertices(intersectedMesh, intersection.point, worldSnapRadius));
197
+ }
198
+ return {
199
+ snapTarget: this.getBestSnapTarget(candidates, intersection.point),
200
+ edgeLock: {
201
+ edge: null,
202
+ meshExpressId: null,
203
+ edgeT: 0,
204
+ shouldLock: false,
205
+ shouldRelease: wasLockReleased, // Propagate release signal from maintainEdgeLock
206
+ isCorner: false,
207
+ cornerValence: 0,
208
+ },
209
+ };
210
+ }
211
+ // Sort by distance - prefer closest edge
212
+ nearbyEdges.sort((a, b) => a.distance - b.distance);
213
+ const bestEdge = nearbyEdges[0];
214
+ // Check if we're at a corner (near edge endpoint with high valence)
215
+ const cornerInfo = this.detectCorner(bestEdge.edge, bestEdge.t, cache, cornerRadius, intersection.point);
216
+ // Determine snap target
217
+ let snapTarget;
218
+ if (cornerInfo.isCorner && cornerInfo.valence >= MAGNETIC_CONFIG.MIN_CORNER_VALENCE) {
219
+ // Corner snap - snap to vertex
220
+ const cornerVertex = bestEdge.t < 0.5 ? bestEdge.edge.v0 : bestEdge.edge.v1;
221
+ snapTarget = {
222
+ type: SnapType.VERTEX,
223
+ position: cornerVertex,
224
+ expressId: intersectedMesh.expressId,
225
+ confidence: Math.min(1, 0.99 + cornerInfo.valence * MAGNETIC_CONFIG.CORNER_CONFIDENCE_BOOST),
226
+ metadata: { vertices: [bestEdge.edge.v0, bestEdge.edge.v1] },
227
+ };
228
+ }
229
+ else {
230
+ // Edge snap - snap to closest point on edge
231
+ snapTarget = {
232
+ type: SnapType.EDGE,
233
+ position: bestEdge.closestPoint,
234
+ expressId: intersectedMesh.expressId,
235
+ confidence: 0.999 * (1.0 - bestEdge.distance / edgeRadius),
236
+ metadata: { vertices: [bestEdge.edge.v0, bestEdge.edge.v1], edgeIndex: bestEdge.edge.index },
237
+ };
238
+ }
239
+ return {
240
+ snapTarget,
241
+ edgeLock: {
242
+ edge: { v0: bestEdge.edge.v0, v1: bestEdge.edge.v1 },
243
+ meshExpressId: intersectedMesh.expressId,
244
+ edgeT: bestEdge.t,
245
+ shouldLock: true,
246
+ shouldRelease: false,
247
+ isCorner: cornerInfo.isCorner,
248
+ cornerValence: cornerInfo.valence,
249
+ },
250
+ };
251
+ }
252
+ /**
253
+ * Maintain an existing edge lock - slide along edge or release if moved away
254
+ */
255
+ maintainEdgeLock(point, currentLock, cache, worldSnapRadius, meshExpressId) {
256
+ if (!currentLock.edge) {
257
+ return {
258
+ snapTarget: null,
259
+ edgeLock: {
260
+ edge: null,
261
+ meshExpressId: null,
262
+ edgeT: 0,
263
+ shouldLock: false,
264
+ shouldRelease: true,
265
+ isCorner: false,
266
+ cornerValence: 0,
267
+ },
268
+ };
269
+ }
270
+ const { v0, v1 } = currentLock.edge;
271
+ // Project point onto the locked edge
272
+ const result = this.closestPointOnEdgeWithT(point, v0, v1);
273
+ // Calculate perpendicular distance (distance from point to edge line)
274
+ const perpDistance = result.distance;
275
+ // Calculate escape threshold based on lock strength
276
+ const escapeMultiplier = MAGNETIC_CONFIG.EDGE_ESCAPE_MULTIPLIER * (1 + currentLock.lockStrength * 0.5);
277
+ const escapeThreshold = worldSnapRadius * escapeMultiplier;
278
+ // Check if we should release the lock
279
+ if (perpDistance > escapeThreshold) {
280
+ return {
281
+ snapTarget: null,
282
+ edgeLock: {
283
+ edge: null,
284
+ meshExpressId: null,
285
+ edgeT: 0,
286
+ shouldLock: false,
287
+ shouldRelease: true,
288
+ isCorner: false,
289
+ cornerValence: 0,
290
+ },
291
+ };
292
+ }
293
+ // Still locked - calculate position along edge
294
+ const edgeT = Math.max(0, Math.min(1, result.t));
295
+ // Check for corner at current position
296
+ const cornerRadius = worldSnapRadius * MAGNETIC_CONFIG.EDGE_ATTRACTION_MULTIPLIER * MAGNETIC_CONFIG.CORNER_ATTRACTION_MULTIPLIER;
297
+ // Find the matching edge in cache to get proper index
298
+ let matchingEdge = cache.edges.find(e => (this.vecEquals(e.v0, v0) && this.vecEquals(e.v1, v1)) ||
299
+ (this.vecEquals(e.v0, v1) && this.vecEquals(e.v1, v0)));
300
+ const edgeForCorner = matchingEdge || { v0, v1, index: -1 };
301
+ const cornerInfo = this.detectCorner(edgeForCorner, edgeT, cache, cornerRadius, point);
302
+ // Calculate snap position (on the edge)
303
+ const snapPosition = {
304
+ x: v0.x + (v1.x - v0.x) * edgeT,
305
+ y: v0.y + (v1.y - v0.y) * edgeT,
306
+ z: v0.z + (v1.z - v0.z) * edgeT,
307
+ };
308
+ // Determine snap type
309
+ let snapType;
310
+ let confidence;
311
+ if (cornerInfo.isCorner && cornerInfo.valence >= MAGNETIC_CONFIG.MIN_CORNER_VALENCE) {
312
+ snapType = SnapType.VERTEX;
313
+ confidence = Math.min(1, 0.99 + cornerInfo.valence * MAGNETIC_CONFIG.CORNER_CONFIDENCE_BOOST);
314
+ // Snap to exact corner vertex
315
+ if (edgeT < MAGNETIC_CONFIG.CORNER_THRESHOLD) {
316
+ snapPosition.x = v0.x;
317
+ snapPosition.y = v0.y;
318
+ snapPosition.z = v0.z;
319
+ }
320
+ else if (edgeT > 1 - MAGNETIC_CONFIG.CORNER_THRESHOLD) {
321
+ snapPosition.x = v1.x;
322
+ snapPosition.y = v1.y;
323
+ snapPosition.z = v1.z;
324
+ }
325
+ }
326
+ else {
327
+ snapType = SnapType.EDGE;
328
+ // Clamp confidence to 0-1 range (can go negative if perpDistance exceeds attraction radius)
329
+ const rawConfidence = 0.999 * (1.0 - perpDistance / (worldSnapRadius * MAGNETIC_CONFIG.EDGE_ATTRACTION_MULTIPLIER));
330
+ confidence = Math.max(0, Math.min(1, rawConfidence));
331
+ }
332
+ return {
333
+ snapTarget: {
334
+ type: snapType,
335
+ position: snapPosition,
336
+ expressId: meshExpressId,
337
+ confidence,
338
+ metadata: { vertices: [v0, v1] },
339
+ },
340
+ edgeLock: {
341
+ edge: { v0, v1 },
342
+ meshExpressId,
343
+ edgeT,
344
+ shouldLock: true,
345
+ shouldRelease: false,
346
+ isCorner: cornerInfo.isCorner,
347
+ cornerValence: cornerInfo.valence,
348
+ },
349
+ };
350
+ }
351
+ /**
352
+ * Detect if position is at a corner (vertex with multiple edges)
353
+ */
354
+ detectCorner(edge, t, cache, radius, point) {
355
+ // Check if we're near either endpoint
356
+ const nearV0 = t < MAGNETIC_CONFIG.CORNER_THRESHOLD;
357
+ const nearV1 = t > 1 - MAGNETIC_CONFIG.CORNER_THRESHOLD;
358
+ if (!nearV0 && !nearV1) {
359
+ return { isCorner: false, valence: 0, vertex: null };
360
+ }
361
+ const vertex = nearV0 ? edge.v0 : edge.v1;
362
+ const vertexKey = `${vertex.x.toFixed(4)}_${vertex.y.toFixed(4)}_${vertex.z.toFixed(4)}`;
363
+ // Get valence from cache
364
+ const valence = cache.vertexValence.get(vertexKey) || 0;
365
+ // Also check distance to vertex
366
+ const distToVertex = this.distance(point, vertex);
367
+ const isCloseEnough = distToVertex < radius;
368
+ return {
369
+ isCorner: isCloseEnough && valence >= MAGNETIC_CONFIG.MIN_CORNER_VALENCE,
370
+ valence,
371
+ vertex,
372
+ };
373
+ }
374
+ /**
375
+ * Get closest point on edge segment with parameter t (0-1)
376
+ */
377
+ closestPointOnEdgeWithT(point, v0, v1) {
378
+ const dx = v1.x - v0.x;
379
+ const dy = v1.y - v0.y;
380
+ const dz = v1.z - v0.z;
381
+ const lengthSq = dx * dx + dy * dy + dz * dz;
382
+ if (lengthSq < 0.0000001) {
383
+ // Degenerate edge
384
+ return { point: v0, distance: this.distance(point, v0), t: 0 };
385
+ }
386
+ // Project point onto line
387
+ const t = Math.max(0, Math.min(1, ((point.x - v0.x) * dx + (point.y - v0.y) * dy + (point.z - v0.z) * dz) / lengthSq));
388
+ const closest = {
389
+ x: v0.x + dx * t,
390
+ y: v0.y + dy * t,
391
+ z: v0.z + dz * t,
392
+ };
393
+ return {
394
+ point: closest,
395
+ distance: this.distance(point, closest),
396
+ t,
397
+ };
398
+ }
399
+ /**
400
+ * Check if two vectors are approximately equal
401
+ */
402
+ vecEquals(a, b, epsilon = 0.0001) {
403
+ return (Math.abs(a.x - b.x) < epsilon &&
404
+ Math.abs(a.y - b.y) < epsilon &&
405
+ Math.abs(a.z - b.z) < epsilon);
406
+ }
407
+ /**
408
+ * Get or compute geometry cache for a mesh
409
+ */
410
+ getGeometryCache(mesh) {
411
+ const cached = this.geometryCache.get(mesh.expressId);
412
+ if (cached) {
413
+ return cached;
414
+ }
415
+ // Compute and cache vertices
416
+ const positions = mesh.positions;
417
+ // Validate input
418
+ if (!positions || positions.length === 0) {
419
+ const emptyCache = {
420
+ vertices: [],
421
+ edges: [],
422
+ vertexValence: new Map(),
423
+ vertexEdges: new Map(),
424
+ };
425
+ this.geometryCache.set(mesh.expressId, emptyCache);
426
+ return emptyCache;
427
+ }
428
+ const vertexMap = new Map();
429
+ for (let i = 0; i < positions.length; i += 3) {
430
+ const vertex = {
431
+ x: positions[i],
432
+ y: positions[i + 1],
433
+ z: positions[i + 2],
434
+ };
435
+ // Skip invalid vertices
436
+ if (!isFinite(vertex.x) || !isFinite(vertex.y) || !isFinite(vertex.z)) {
437
+ continue;
438
+ }
439
+ // Use reduced precision for deduplication
440
+ const key = `${vertex.x.toFixed(4)}_${vertex.y.toFixed(4)}_${vertex.z.toFixed(4)}`;
441
+ vertexMap.set(key, vertex);
442
+ }
443
+ const vertices = Array.from(vertexMap.values());
444
+ // Compute and cache edges + vertex valence for corner detection
445
+ // Filter out internal triangulation edges (diagonals) - only keep real model edges
446
+ const edges = [];
447
+ const vertexValence = new Map();
448
+ const vertexEdges = new Map();
449
+ const indices = mesh.indices;
450
+ if (indices) {
451
+ // First pass: collect edges and their adjacent triangle normals
452
+ const edgeData = new Map();
453
+ // Helper to compute triangle normal
454
+ const computeTriangleNormal = (i) => {
455
+ const i0 = indices[i] * 3;
456
+ const i1 = indices[i + 1] * 3;
457
+ const i2 = indices[i + 2] * 3;
458
+ const ax = positions[i1] - positions[i0];
459
+ const ay = positions[i1 + 1] - positions[i0 + 1];
460
+ const az = positions[i1 + 2] - positions[i0 + 2];
461
+ const bx = positions[i2] - positions[i0];
462
+ const by = positions[i2 + 1] - positions[i0 + 1];
463
+ const bz = positions[i2 + 2] - positions[i0 + 2];
464
+ // Cross product
465
+ const nx = ay * bz - az * by;
466
+ const ny = az * bx - ax * bz;
467
+ const nz = ax * by - ay * bx;
468
+ // Normalize
469
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
470
+ return len > 0 ? { x: nx / len, y: ny / len, z: nz / len } : { x: 0, y: 1, z: 0 };
471
+ };
472
+ for (let i = 0; i < indices.length; i += 3) {
473
+ const triNormal = computeTriangleNormal(i);
474
+ const triangleEdges = [
475
+ [indices[i], indices[i + 1]],
476
+ [indices[i + 1], indices[i + 2]],
477
+ [indices[i + 2], indices[i]],
478
+ ];
479
+ for (const [idx0, idx1] of triangleEdges) {
480
+ const i0 = idx0 * 3;
481
+ const i1 = idx1 * 3;
482
+ const v0 = {
483
+ x: positions[i0],
484
+ y: positions[i0 + 1],
485
+ z: positions[i0 + 2],
486
+ };
487
+ const v1 = {
488
+ x: positions[i1],
489
+ y: positions[i1 + 1],
490
+ z: positions[i1 + 2],
491
+ };
492
+ // Create canonical edge key (smaller index first)
493
+ const key = idx0 < idx1 ? `${idx0}_${idx1}` : `${idx1}_${idx0}`;
494
+ if (!edgeData.has(key)) {
495
+ edgeData.set(key, { v0, v1, idx0, idx1, normals: [triNormal] });
496
+ }
497
+ else {
498
+ edgeData.get(key).normals.push(triNormal);
499
+ }
500
+ }
501
+ }
502
+ // Second pass: filter to only real edges (boundary or crease edges)
503
+ // Skip internal triangulation edges (shared by coplanar triangles)
504
+ const COPLANAR_THRESHOLD = 0.98; // Dot product threshold for coplanar check
505
+ for (const [key, data] of edgeData) {
506
+ const { v0, v1, normals } = data;
507
+ // Boundary edge: only one triangle uses it - always a real edge
508
+ if (normals.length === 1) {
509
+ const edgeIndex = edges.length;
510
+ edges.push({ v0, v1, index: edgeIndex });
511
+ // Track vertex valence
512
+ const v0Key = `${v0.x.toFixed(4)}_${v0.y.toFixed(4)}_${v0.z.toFixed(4)}`;
513
+ const v1Key = `${v1.x.toFixed(4)}_${v1.y.toFixed(4)}_${v1.z.toFixed(4)}`;
514
+ vertexValence.set(v0Key, (vertexValence.get(v0Key) || 0) + 1);
515
+ vertexValence.set(v1Key, (vertexValence.get(v1Key) || 0) + 1);
516
+ if (!vertexEdges.has(v0Key))
517
+ vertexEdges.set(v0Key, []);
518
+ if (!vertexEdges.has(v1Key))
519
+ vertexEdges.set(v1Key, []);
520
+ vertexEdges.get(v0Key).push(edgeIndex);
521
+ vertexEdges.get(v1Key).push(edgeIndex);
522
+ continue;
523
+ }
524
+ // Shared edge: check if triangles are coplanar (internal triangulation edge)
525
+ if (normals.length >= 2) {
526
+ const n1 = normals[0];
527
+ const n2 = normals[1];
528
+ const dot = Math.abs(n1.x * n2.x + n1.y * n2.y + n1.z * n2.z);
529
+ // If normals are nearly parallel, triangles are coplanar - skip this edge
530
+ // (it's an internal triangulation diagonal, not a real model edge)
531
+ if (dot > COPLANAR_THRESHOLD) {
532
+ continue; // Skip internal edge
533
+ }
534
+ // Crease edge: triangles meet at an angle - this is a real edge
535
+ const edgeIndex = edges.length;
536
+ edges.push({ v0, v1, index: edgeIndex });
537
+ // Track vertex valence
538
+ const v0Key = `${v0.x.toFixed(4)}_${v0.y.toFixed(4)}_${v0.z.toFixed(4)}`;
539
+ const v1Key = `${v1.x.toFixed(4)}_${v1.y.toFixed(4)}_${v1.z.toFixed(4)}`;
540
+ vertexValence.set(v0Key, (vertexValence.get(v0Key) || 0) + 1);
541
+ vertexValence.set(v1Key, (vertexValence.get(v1Key) || 0) + 1);
542
+ if (!vertexEdges.has(v0Key))
543
+ vertexEdges.set(v0Key, []);
544
+ if (!vertexEdges.has(v1Key))
545
+ vertexEdges.set(v1Key, []);
546
+ vertexEdges.get(v0Key).push(edgeIndex);
547
+ vertexEdges.get(v1Key).push(edgeIndex);
548
+ }
549
+ }
550
+ }
551
+ const cache = { vertices, edges, vertexValence, vertexEdges };
552
+ this.geometryCache.set(mesh.expressId, cache);
553
+ return cache;
554
+ }
555
+ /**
556
+ * Find vertices near point
557
+ */
558
+ findVertices(mesh, point, radius) {
559
+ const targets = [];
560
+ const cache = this.getGeometryCache(mesh);
561
+ // Find vertices within radius - ONLY when VERY close for smooth edge sliding
562
+ for (const vertex of cache.vertices) {
563
+ const dist = this.distance(vertex, point);
564
+ // Only snap to vertices when within 20% of snap radius (very tight) to avoid sticky behavior
565
+ if (dist < radius * 0.2) {
566
+ targets.push({
567
+ type: SnapType.VERTEX,
568
+ position: vertex,
569
+ expressId: mesh.expressId,
570
+ confidence: 0.95 - dist / (radius * 0.2), // Lower than edges, only wins when VERY close
571
+ });
572
+ }
573
+ }
574
+ return targets;
575
+ }
576
+ /**
577
+ * Find edges near point
578
+ */
579
+ findEdges(mesh, point, radius) {
580
+ const targets = [];
581
+ const cache = this.getGeometryCache(mesh);
582
+ // Use MUCH larger radius for edges - very forgiving, cursor "jumps" to edges
583
+ const edgeRadius = radius * 3.0; // Tripled for easy detection
584
+ // Find edges near point using cached data
585
+ for (const edge of cache.edges) {
586
+ const closestPoint = this.raycaster.closestPointOnSegment(point, edge.v0, edge.v1);
587
+ const dist = this.distance(closestPoint, point);
588
+ if (dist < edgeRadius) {
589
+ // Edge snap - ABSOLUTE HIGHEST priority for smooth sliding along edges
590
+ // Maximum confidence ensures edges ALWAYS win over vertices/faces
591
+ targets.push({
592
+ type: SnapType.EDGE,
593
+ position: closestPoint,
594
+ expressId: mesh.expressId,
595
+ confidence: 0.999 * (1.0 - dist / edgeRadius), // Nearly perfect priority for edges
596
+ metadata: { vertices: [edge.v0, edge.v1], edgeIndex: edge.index },
597
+ });
598
+ }
599
+ }
600
+ return targets;
601
+ }
602
+ /**
603
+ * Clear geometry cache (call when meshes change)
604
+ */
605
+ clearCache() {
606
+ this.geometryCache.clear();
607
+ }
608
+ /**
609
+ * Find faces/planes near intersection
610
+ */
611
+ findFaces(mesh, intersection, radius) {
612
+ const targets = [];
613
+ // Add the intersected face
614
+ targets.push({
615
+ type: SnapType.FACE,
616
+ position: intersection.point,
617
+ normal: intersection.normal,
618
+ expressId: mesh.expressId,
619
+ confidence: 0.5, // Lower priority than vertices/edges
620
+ metadata: { faceIndex: intersection.triangleIndex },
621
+ });
622
+ // Calculate face center (centroid of triangle)
623
+ const positions = mesh.positions;
624
+ const indices = mesh.indices;
625
+ if (indices) {
626
+ const triIndex = intersection.triangleIndex * 3;
627
+ const i0 = indices[triIndex] * 3;
628
+ const i1 = indices[triIndex + 1] * 3;
629
+ const i2 = indices[triIndex + 2] * 3;
630
+ const v0 = {
631
+ x: positions[i0],
632
+ y: positions[i0 + 1],
633
+ z: positions[i0 + 2],
634
+ };
635
+ const v1 = {
636
+ x: positions[i1],
637
+ y: positions[i1 + 1],
638
+ z: positions[i1 + 2],
639
+ };
640
+ const v2 = {
641
+ x: positions[i2],
642
+ y: positions[i2 + 1],
643
+ z: positions[i2 + 2],
644
+ };
645
+ const center = {
646
+ x: (v0.x + v1.x + v2.x) / 3,
647
+ y: (v0.y + v1.y + v2.y) / 3,
648
+ z: (v0.z + v1.z + v2.z) / 3,
649
+ };
650
+ const dist = this.distance(center, intersection.point);
651
+ if (dist < radius) {
652
+ targets.push({
653
+ type: SnapType.FACE_CENTER,
654
+ position: center,
655
+ normal: intersection.normal,
656
+ expressId: mesh.expressId,
657
+ confidence: 0.7 * (1.0 - dist / radius),
658
+ metadata: { faceIndex: intersection.triangleIndex },
659
+ });
660
+ }
661
+ }
662
+ return targets;
663
+ }
664
+ /**
665
+ * Select best snap target based on confidence and priority
666
+ */
667
+ getBestSnapTarget(targets, cursorPoint) {
668
+ if (targets.length === 0)
669
+ return null;
670
+ // Priority order: vertex > edge > face_center > face
671
+ const priorityMap = {
672
+ [SnapType.VERTEX]: 4,
673
+ [SnapType.EDGE]: 3,
674
+ [SnapType.FACE_CENTER]: 2,
675
+ [SnapType.FACE]: 1,
676
+ };
677
+ // Sort by priority then confidence
678
+ targets.sort((a, b) => {
679
+ const priorityDiff = priorityMap[b.type] - priorityMap[a.type];
680
+ if (priorityDiff !== 0)
681
+ return priorityDiff;
682
+ return b.confidence - a.confidence;
683
+ });
684
+ return targets[0];
685
+ }
686
+ /**
687
+ * Convert screen-space radius to world-space radius
688
+ */
689
+ screenToWorldRadius(screenRadius, distance, fov, screenHeight) {
690
+ // Calculate world height at distance
691
+ const fovRadians = (fov * Math.PI) / 180;
692
+ const worldHeight = 2 * distance * Math.tan(fovRadians / 2);
693
+ // Convert screen pixels to world units
694
+ return (screenRadius / screenHeight) * worldHeight;
695
+ }
696
+ /**
697
+ * Vector utilities
698
+ */
699
+ distance(a, b) {
700
+ const dx = a.x - b.x;
701
+ const dy = a.y - b.y;
702
+ const dz = a.z - b.z;
703
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
704
+ }
705
+ }
706
+ //# sourceMappingURL=snap-detector.js.map