@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.
- package/LICENSE +373 -0
- package/dist/bvh.d.ts +50 -0
- package/dist/bvh.d.ts.map +1 -0
- package/dist/bvh.js +177 -0
- package/dist/bvh.js.map +1 -0
- package/dist/camera.d.ts +17 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +72 -6
- package/dist/camera.js.map +1 -1
- package/dist/index.d.ts +51 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +587 -125
- package/dist/index.js.map +1 -1
- package/dist/math.d.ts +30 -0
- package/dist/math.d.ts.map +1 -1
- package/dist/math.js +103 -0
- package/dist/math.js.map +1 -1
- package/dist/picker.js +2 -2
- package/dist/picker.js.map +1 -1
- package/dist/pipeline.d.ts +17 -0
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +351 -49
- package/dist/pipeline.js.map +1 -1
- package/dist/raycaster.d.ts +67 -0
- package/dist/raycaster.d.ts.map +1 -0
- package/dist/raycaster.js +192 -0
- package/dist/raycaster.js.map +1 -0
- package/dist/scene.d.ts +56 -2
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +362 -26
- package/dist/scene.js.map +1 -1
- package/dist/section-plane.d.ts +14 -4
- package/dist/section-plane.d.ts.map +1 -1
- package/dist/section-plane.js +129 -53
- package/dist/section-plane.js.map +1 -1
- package/dist/snap-detector.d.ts +119 -0
- package/dist/snap-detector.d.ts.map +1 -0
- package/dist/snap-detector.js +706 -0
- package/dist/snap-detector.js.map +1 -0
- package/dist/types.d.ts +14 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/zero-copy-uploader.d.ts +145 -0
- package/dist/zero-copy-uploader.d.ts.map +1 -0
- package/dist/zero-copy-uploader.js +146 -0
- package/dist/zero-copy-uploader.js.map +1 -0
- 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
|