@ifc-lite/renderer 1.6.1 → 1.8.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.
Files changed (46) hide show
  1. package/README.md +40 -0
  2. package/dist/camera-animation.d.ts +108 -0
  3. package/dist/camera-animation.d.ts.map +1 -0
  4. package/dist/camera-animation.js +606 -0
  5. package/dist/camera-animation.js.map +1 -0
  6. package/dist/camera-controls.d.ts +75 -0
  7. package/dist/camera-controls.d.ts.map +1 -0
  8. package/dist/camera-controls.js +239 -0
  9. package/dist/camera-controls.js.map +1 -0
  10. package/dist/camera-projection.d.ts +51 -0
  11. package/dist/camera-projection.d.ts.map +1 -0
  12. package/dist/camera-projection.js +147 -0
  13. package/dist/camera-projection.js.map +1 -0
  14. package/dist/camera.d.ts +33 -45
  15. package/dist/camera.d.ts.map +1 -1
  16. package/dist/camera.js +128 -815
  17. package/dist/camera.js.map +1 -1
  18. package/dist/geometry-manager.d.ts +99 -0
  19. package/dist/geometry-manager.d.ts.map +1 -0
  20. package/dist/geometry-manager.js +387 -0
  21. package/dist/geometry-manager.js.map +1 -0
  22. package/dist/index.d.ts +7 -19
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +62 -658
  25. package/dist/index.js.map +1 -1
  26. package/dist/math.d.ts +6 -0
  27. package/dist/math.d.ts.map +1 -1
  28. package/dist/math.js +20 -0
  29. package/dist/math.js.map +1 -1
  30. package/dist/picking-manager.d.ts +31 -0
  31. package/dist/picking-manager.d.ts.map +1 -0
  32. package/dist/picking-manager.js +140 -0
  33. package/dist/picking-manager.js.map +1 -0
  34. package/dist/pipeline.d.ts +2 -0
  35. package/dist/pipeline.d.ts.map +1 -1
  36. package/dist/pipeline.js +42 -0
  37. package/dist/pipeline.js.map +1 -1
  38. package/dist/raycast-engine.d.ts +76 -0
  39. package/dist/raycast-engine.d.ts.map +1 -0
  40. package/dist/raycast-engine.js +255 -0
  41. package/dist/raycast-engine.js.map +1 -0
  42. package/dist/scene.d.ts +26 -1
  43. package/dist/scene.d.ts.map +1 -1
  44. package/dist/scene.js +134 -25
  45. package/dist/scene.js.map +1 -1
  46. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -19,6 +19,10 @@ export { FederationRegistry, federationRegistry } from './federation-registry.js
19
19
  export * from './types.js';
20
20
  // Zero-copy GPU upload (new - faster, less memory)
21
21
  export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-uploader.js';
22
+ // Extracted manager classes
23
+ export { GeometryManager } from './geometry-manager.js';
24
+ export { PickingManager } from './picking-manager.js';
25
+ export { RaycastEngine } from './raycast-engine.js';
22
26
  import { WebGPUDevice } from './device.js';
23
27
  import { RenderPipeline, InstancedRenderPipeline } from './pipeline.js';
24
28
  import { Camera } from './camera.js';
@@ -27,11 +31,9 @@ import { Picker } from './picker.js';
27
31
  import { FrustumUtils } from '@ifc-lite/spatial';
28
32
  import { SectionPlaneRenderer } from './section-plane.js';
29
33
  import { Section2DOverlayRenderer } from './section-2d-overlay.js';
30
- import { deduplicateMeshes } from '@ifc-lite/geometry';
31
- import { MathUtils } from './math.js';
32
- import { Raycaster } from './raycaster.js';
33
- import { SnapDetector } from './snap-detector.js';
34
- import { BVH } from './bvh.js';
34
+ import { GeometryManager } from './geometry-manager.js';
35
+ import { PickingManager } from './picking-manager.js';
36
+ import { RaycastEngine } from './raycast-engine.js';
35
37
  /**
36
38
  * Main renderer class
37
39
  */
@@ -45,14 +47,10 @@ export class Renderer {
45
47
  canvas;
46
48
  sectionPlaneRenderer = null;
47
49
  section2DOverlayRenderer = null;
48
- modelBounds = null;
49
- raycaster;
50
- snapDetector;
51
- bvh;
52
- // BVH cache
53
- bvhCache = null;
54
- // Performance constants
55
- BVH_THRESHOLD = 100;
50
+ // Composition: delegate to extracted managers
51
+ geometryManager;
52
+ pickingManager;
53
+ raycastEngine;
56
54
  // Error rate limiting (log at most once per second)
57
55
  lastRenderErrorTime = 0;
58
56
  RENDER_ERROR_THROTTLE_MS = 1000;
@@ -61,10 +59,10 @@ export class Renderer {
61
59
  this.device = new WebGPUDevice();
62
60
  this.camera = new Camera();
63
61
  this.scene = new Scene();
64
- this.raycaster = new Raycaster();
65
- this.snapDetector = new SnapDetector();
66
- this.bvh = new BVH();
67
- this.bvhCache = null;
62
+ // Create composition managers
63
+ this.geometryManager = new GeometryManager(this.device, this.scene);
64
+ this.pickingManager = new PickingManager(this.camera, this.scene, null, this.canvas, this.geometryManager);
65
+ this.raycastEngine = new RaycastEngine(this.camera, this.scene, this.canvas);
68
66
  }
69
67
  /**
70
68
  * Initialize renderer
@@ -86,6 +84,8 @@ export class Renderer {
86
84
  this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
87
85
  this.section2DOverlayRenderer = new Section2DOverlayRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
88
86
  this.camera.setAspect(width / height);
87
+ // Update picking manager with initialized picker
88
+ this.pickingManager.setPicker(this.picker);
89
89
  }
90
90
  /**
91
91
  * Load geometry from GeometryResult or MeshData array
@@ -94,20 +94,10 @@ export class Renderer {
94
94
  * @param geometry - Either a GeometryResult from geometry.process() or an array of MeshData
95
95
  */
96
96
  loadGeometry(geometry) {
97
- if (!this.device.isInitialized() || !this.pipeline) {
97
+ if (!this.pipeline) {
98
98
  throw new Error('Renderer not initialized. Call init() first.');
99
99
  }
100
- const meshes = Array.isArray(geometry) ? geometry : geometry.meshes;
101
- if (meshes.length === 0) {
102
- console.warn('[Renderer] loadGeometry called with empty mesh array');
103
- return;
104
- }
105
- // Use batched rendering for optimal performance
106
- const device = this.device.getDevice();
107
- this.scene.appendToBatches(meshes, device, this.pipeline, false);
108
- // Calculate and store model bounds for fitToView
109
- this.updateModelBounds(meshes);
110
- console.log(`[Renderer] Loaded ${meshes.length} meshes`);
100
+ this.geometryManager.loadGeometry(geometry, this.pipeline);
111
101
  }
112
102
  /**
113
103
  * Add multiple meshes to the scene (convenience method for streaming)
@@ -116,158 +106,34 @@ export class Renderer {
116
106
  * @param isStreaming - If true, throttles batch rebuilding for better streaming performance
117
107
  */
118
108
  addMeshes(meshes, isStreaming = false) {
119
- if (!this.device.isInitialized() || !this.pipeline) {
109
+ if (!this.pipeline) {
120
110
  throw new Error('Renderer not initialized. Call init() first.');
121
111
  }
122
- if (meshes.length === 0)
123
- return;
124
- const device = this.device.getDevice();
125
- this.scene.appendToBatches(meshes, device, this.pipeline, isStreaming);
126
- // Update model bounds incrementally
127
- this.updateModelBounds(meshes);
128
- }
129
- /**
130
- * Update model bounds from mesh data
131
- */
132
- updateModelBounds(meshes) {
133
- if (!this.modelBounds) {
134
- this.modelBounds = {
135
- min: { x: Infinity, y: Infinity, z: Infinity },
136
- max: { x: -Infinity, y: -Infinity, z: -Infinity }
137
- };
138
- }
139
- for (const mesh of meshes) {
140
- const positions = mesh.positions;
141
- for (let i = 0; i < positions.length; i += 3) {
142
- const x = positions[i];
143
- const y = positions[i + 1];
144
- const z = positions[i + 2];
145
- if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
146
- this.modelBounds.min.x = Math.min(this.modelBounds.min.x, x);
147
- this.modelBounds.min.y = Math.min(this.modelBounds.min.y, y);
148
- this.modelBounds.min.z = Math.min(this.modelBounds.min.z, z);
149
- this.modelBounds.max.x = Math.max(this.modelBounds.max.x, x);
150
- this.modelBounds.max.y = Math.max(this.modelBounds.max.y, y);
151
- this.modelBounds.max.z = Math.max(this.modelBounds.max.z, z);
152
- }
153
- }
154
- }
112
+ this.geometryManager.addMeshes(meshes, this.pipeline, isStreaming);
155
113
  }
156
114
  /**
157
115
  * Fit camera to view all loaded geometry
158
116
  */
159
117
  fitToView() {
160
- if (!this.modelBounds) {
161
- console.warn('[Renderer] fitToView called but no geometry loaded');
162
- return;
163
- }
164
- const { min, max } = this.modelBounds;
165
- // Calculate center and size
166
- const center = {
167
- x: (min.x + max.x) / 2,
168
- y: (min.y + max.y) / 2,
169
- z: (min.z + max.z) / 2
170
- };
171
- const size = Math.max(max.x - min.x, max.y - min.y, max.z - min.z);
172
- // Position camera to see entire model
173
- const distance = size * 1.5;
174
- this.camera.setPosition(center.x + distance * 0.5, center.y + distance * 0.5, center.z + distance);
175
- this.camera.setTarget(center.x, center.y, center.z);
118
+ this.geometryManager.fitToView(this.camera);
176
119
  }
177
120
  /**
178
121
  * Add mesh to scene with per-mesh GPU resources for unique colors
179
122
  */
180
123
  addMesh(mesh) {
181
- // Create per-mesh uniform buffer and bind group if not already created
182
- if (!mesh.uniformBuffer && this.pipeline && this.device.isInitialized()) {
183
- const device = this.device.getDevice();
184
- // Create uniform buffer for this mesh
185
- mesh.uniformBuffer = device.createBuffer({
186
- size: this.pipeline.getUniformBufferSize(),
187
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
188
- });
189
- // Create bind group for this mesh
190
- mesh.bindGroup = device.createBindGroup({
191
- layout: this.pipeline.getBindGroupLayout(),
192
- entries: [
193
- {
194
- binding: 0,
195
- resource: { buffer: mesh.uniformBuffer },
196
- },
197
- ],
198
- });
199
- }
200
- this.scene.addMesh(mesh);
124
+ if (!this.pipeline)
125
+ return;
126
+ this.geometryManager.addMesh(mesh, this.pipeline);
201
127
  }
202
128
  /**
203
129
  * Add instanced geometry to scene
204
130
  * Converts InstancedGeometry from geometry package to InstancedMesh for rendering
205
131
  */
206
132
  addInstancedGeometry(geometry) {
207
- if (!this.instancedPipeline || !this.device.isInitialized()) {
133
+ if (!this.instancedPipeline) {
208
134
  throw new Error('Renderer not initialized. Call init() first.');
209
135
  }
210
- const device = this.device.getDevice();
211
- // Upload positions and normals interleaved
212
- const vertexCount = geometry.positions.length / 3;
213
- const vertexData = new Float32Array(vertexCount * 6);
214
- for (let i = 0; i < vertexCount; i++) {
215
- vertexData[i * 6 + 0] = geometry.positions[i * 3 + 0];
216
- vertexData[i * 6 + 1] = geometry.positions[i * 3 + 1];
217
- vertexData[i * 6 + 2] = geometry.positions[i * 3 + 2];
218
- vertexData[i * 6 + 3] = geometry.normals[i * 3 + 0];
219
- vertexData[i * 6 + 4] = geometry.normals[i * 3 + 1];
220
- vertexData[i * 6 + 5] = geometry.normals[i * 3 + 2];
221
- }
222
- // Create vertex buffer with exact size needed (ensure it matches data size)
223
- const vertexBufferSize = vertexData.byteLength;
224
- const vertexBuffer = device.createBuffer({
225
- size: vertexBufferSize,
226
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
227
- });
228
- device.queue.writeBuffer(vertexBuffer, 0, vertexData);
229
- // Create index buffer
230
- const indexBuffer = device.createBuffer({
231
- size: geometry.indices.byteLength,
232
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
233
- });
234
- device.queue.writeBuffer(indexBuffer, 0, geometry.indices);
235
- // Create instance buffer: each instance is 80 bytes (20 floats: 16 for transform + 4 for color)
236
- const instanceCount = geometry.instance_count;
237
- const instanceData = new Float32Array(instanceCount * 20);
238
- const expressIdToInstanceIndex = new Map();
239
- for (let i = 0; i < instanceCount; i++) {
240
- const instance = geometry.get_instance(i);
241
- if (!instance)
242
- continue;
243
- const baseIdx = i * 20;
244
- // Copy transform (16 floats)
245
- instanceData.set(instance.transform, baseIdx);
246
- // Copy color (4 floats)
247
- instanceData[baseIdx + 16] = instance.color[0];
248
- instanceData[baseIdx + 17] = instance.color[1];
249
- instanceData[baseIdx + 18] = instance.color[2];
250
- instanceData[baseIdx + 19] = instance.color[3];
251
- expressIdToInstanceIndex.set(instance.expressId, i);
252
- }
253
- const instanceBuffer = device.createBuffer({
254
- size: instanceData.byteLength,
255
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
256
- });
257
- device.queue.writeBuffer(instanceBuffer, 0, instanceData);
258
- // Create and cache bind group to avoid per-frame allocation
259
- const bindGroup = this.instancedPipeline.createInstanceBindGroup(instanceBuffer);
260
- const instancedMesh = {
261
- geometryId: Number(geometry.geometryId),
262
- vertexBuffer,
263
- indexBuffer,
264
- indexCount: geometry.indices.length,
265
- instanceBuffer,
266
- instanceCount: instanceCount,
267
- expressIdToInstanceIndex,
268
- bindGroup,
269
- };
270
- this.scene.addInstancedMesh(instancedMesh);
136
+ this.geometryManager.addInstancedGeometry(geometry, this.instancedPipeline);
271
137
  }
272
138
  /**
273
139
  * Convert MeshData array to instanced meshes for optimized rendering
@@ -275,175 +141,19 @@ export class Renderer {
275
141
  * Call this in background after initial streaming completes
276
142
  */
277
143
  convertToInstanced(meshDataArray) {
278
- if (!this.instancedPipeline || !this.device.isInitialized()) {
144
+ if (!this.instancedPipeline) {
279
145
  console.warn('[Renderer] Cannot convert to instanced: renderer not initialized');
280
146
  return;
281
147
  }
282
- // Use deduplication function to group identical geometries
283
- const instancedData = deduplicateMeshes(meshDataArray);
284
- const device = this.device.getDevice();
285
- let totalInstances = 0;
286
- for (const group of instancedData) {
287
- // Create vertex buffer (interleaved positions + normals)
288
- const vertexCount = group.positions.length / 3;
289
- const vertexData = new Float32Array(vertexCount * 6);
290
- for (let i = 0; i < vertexCount; i++) {
291
- vertexData[i * 6 + 0] = group.positions[i * 3 + 0];
292
- vertexData[i * 6 + 1] = group.positions[i * 3 + 1];
293
- vertexData[i * 6 + 2] = group.positions[i * 3 + 2];
294
- vertexData[i * 6 + 3] = group.normals[i * 3 + 0];
295
- vertexData[i * 6 + 4] = group.normals[i * 3 + 1];
296
- vertexData[i * 6 + 5] = group.normals[i * 3 + 2];
297
- }
298
- const vertexBuffer = device.createBuffer({
299
- size: vertexData.byteLength,
300
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
301
- });
302
- device.queue.writeBuffer(vertexBuffer, 0, vertexData);
303
- // Create index buffer
304
- const indexBuffer = device.createBuffer({
305
- size: group.indices.byteLength,
306
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
307
- });
308
- device.queue.writeBuffer(indexBuffer, 0, group.indices);
309
- // Create instance buffer: each instance is 80 bytes (20 floats: 16 for transform + 4 for color)
310
- const instanceCount = group.instances.length;
311
- const instanceData = new Float32Array(instanceCount * 20);
312
- const expressIdToInstanceIndex = new Map();
313
- // Identity matrix for now (instances use same geometry, different colors)
314
- const identityTransform = new Float32Array([
315
- 1, 0, 0, 0,
316
- 0, 1, 0, 0,
317
- 0, 0, 1, 0,
318
- 0, 0, 0, 1,
319
- ]);
320
- for (let i = 0; i < instanceCount; i++) {
321
- const instance = group.instances[i];
322
- const baseIdx = i * 20;
323
- // Copy identity transform (16 floats)
324
- instanceData.set(identityTransform, baseIdx);
325
- // Copy color (4 floats)
326
- instanceData[baseIdx + 16] = instance.color[0];
327
- instanceData[baseIdx + 17] = instance.color[1];
328
- instanceData[baseIdx + 18] = instance.color[2];
329
- instanceData[baseIdx + 19] = instance.color[3];
330
- expressIdToInstanceIndex.set(instance.expressId, i);
331
- }
332
- const instanceBuffer = device.createBuffer({
333
- size: instanceData.byteLength,
334
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
335
- });
336
- device.queue.writeBuffer(instanceBuffer, 0, instanceData);
337
- // Create and cache bind group to avoid per-frame allocation
338
- const bindGroup = this.instancedPipeline.createInstanceBindGroup(instanceBuffer);
339
- // Convert hash string to number for geometryId
340
- const geometryId = this.hashStringToNumber(group.geometryHash);
341
- const instancedMesh = {
342
- geometryId,
343
- vertexBuffer,
344
- indexBuffer,
345
- indexCount: group.indices.length,
346
- instanceBuffer,
347
- instanceCount: instanceCount,
348
- expressIdToInstanceIndex,
349
- bindGroup,
350
- };
351
- this.scene.addInstancedMesh(instancedMesh);
352
- totalInstances += instanceCount;
353
- }
354
- // Clear regular meshes after conversion to avoid double rendering
355
- const regularMeshCount = this.scene.getMeshes().length;
356
- this.scene.clearRegularMeshes();
357
- console.log(`[Renderer] Converted ${meshDataArray.length} meshes to ${instancedData.length} instanced geometries ` +
358
- `(${totalInstances} total instances, ${(totalInstances / instancedData.length).toFixed(1)}x deduplication). ` +
359
- `Cleared ${regularMeshCount} regular meshes.`);
360
- }
361
- /**
362
- * Hash string to number for geometryId
363
- */
364
- hashStringToNumber(str) {
365
- let hash = 0;
366
- for (let i = 0; i < str.length; i++) {
367
- const char = str.charCodeAt(i);
368
- hash = ((hash << 5) - hash) + char;
369
- hash = hash & hash; // Convert to 32-bit integer
370
- }
371
- return Math.abs(hash);
372
- }
373
- /**
374
- * Create a GPU Mesh from MeshData (lazy creation for selection highlighting)
375
- * This is called on-demand when a mesh is selected, avoiding 2x buffer creation during streaming
376
- */
377
- createMeshFromData(meshData) {
378
- if (!this.device.isInitialized())
379
- return;
380
- const device = this.device.getDevice();
381
- const vertexCount = meshData.positions.length / 3;
382
- const interleaved = new Float32Array(vertexCount * 6);
383
- for (let i = 0; i < vertexCount; i++) {
384
- const base = i * 6;
385
- const posBase = i * 3;
386
- interleaved[base] = meshData.positions[posBase];
387
- interleaved[base + 1] = meshData.positions[posBase + 1];
388
- interleaved[base + 2] = meshData.positions[posBase + 2];
389
- interleaved[base + 3] = meshData.normals[posBase];
390
- interleaved[base + 4] = meshData.normals[posBase + 1];
391
- interleaved[base + 5] = meshData.normals[posBase + 2];
392
- }
393
- const vertexBuffer = device.createBuffer({
394
- size: interleaved.byteLength,
395
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
396
- });
397
- device.queue.writeBuffer(vertexBuffer, 0, interleaved);
398
- const indexBuffer = device.createBuffer({
399
- size: meshData.indices.byteLength,
400
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
401
- });
402
- device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
403
- // Add to scene with identity transform (positions already in world space)
404
- this.scene.addMesh({
405
- expressId: meshData.expressId,
406
- modelIndex: meshData.modelIndex, // Preserve modelIndex for multi-model selection
407
- vertexBuffer,
408
- indexBuffer,
409
- indexCount: meshData.indices.length,
410
- transform: MathUtils.identity(),
411
- color: meshData.color,
412
- });
148
+ this.geometryManager.convertToInstanced(meshDataArray, this.instancedPipeline);
413
149
  }
414
150
  /**
415
151
  * Ensure all meshes have GPU resources (call after adding meshes if pipeline wasn't ready)
416
152
  */
417
153
  ensureMeshResources() {
418
- if (!this.pipeline || !this.device.isInitialized())
154
+ if (!this.pipeline)
419
155
  return;
420
- const device = this.device.getDevice();
421
- let created = 0;
422
- for (const mesh of this.scene.getMeshes()) {
423
- if (!mesh.uniformBuffer) {
424
- mesh.uniformBuffer = device.createBuffer({
425
- size: this.pipeline.getUniformBufferSize(),
426
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
427
- });
428
- mesh.bindGroup = device.createBindGroup({
429
- layout: this.pipeline.getBindGroupLayout(),
430
- entries: [
431
- {
432
- binding: 0,
433
- resource: { buffer: mesh.uniformBuffer },
434
- },
435
- ],
436
- });
437
- created++;
438
- }
439
- }
440
- if (created > 0) {
441
- const totalMeshCount = this.scene.getMeshes().length;
442
- // Only log every 250 meshes or when creating many at once to reduce noise
443
- if (totalMeshCount % 250 === 0 || created > 100) {
444
- console.log(`[Renderer] Created GPU resources for ${created} new meshes (${totalMeshCount} total)`);
445
- }
446
- }
156
+ this.geometryManager.ensureMeshResources(this.pipeline);
447
157
  }
448
158
  /**
449
159
  * Render frame
@@ -594,7 +304,7 @@ export class Renderer {
594
304
  boundsMax.x = boundsMax.y = boundsMax.z = 100;
595
305
  }
596
306
  // Store bounds for section plane visual
597
- this.modelBounds = { min: boundsMin, max: boundsMax };
307
+ this.geometryManager.setModelBounds({ min: boundsMin, max: boundsMax });
598
308
  // Only calculate clipping data if section is enabled
599
309
  if (options.sectionPlane.enabled) {
600
310
  // Calculate plane normal based on semantic axis
@@ -609,7 +319,6 @@ export class Renderer {
609
319
  // Apply building rotation if present (rotate normal around Y axis)
610
320
  // Building rotation is in X-Y plane (Z is up in IFC, Y is up in WebGL)
611
321
  if (options.buildingRotation !== undefined && options.buildingRotation !== 0) {
612
- const originalNormal = [...normal];
613
322
  const cosR = Math.cos(options.buildingRotation);
614
323
  const sinR = Math.sin(options.buildingRotation);
615
324
  // Rotate normal vector around Y axis (vertical)
@@ -826,10 +535,10 @@ export class Renderer {
826
535
  // PERFORMANCE FIX: Render partially visible batches as sub-batches (not individual meshes!)
827
536
  // This is the key optimization: instead of 10,000+ individual draw calls,
828
537
  // we create cached sub-batches with only visible elements and render them as single draw calls
829
- const allMeshes = this.scene.getMeshes();
538
+ const allMeshesFromScene = this.scene.getMeshes();
830
539
  // Track existing meshes by (expressId:modelIndex) to handle multi-model expressId collisions
831
540
  // E.g., door #535 in model 0 vs beam #535 in model 1 need separate tracking
832
- const existingMeshKeys = new Set(allMeshes.map(m => `${m.expressId}:${m.modelIndex ?? 'any'}`));
541
+ const existingMeshKeys = new Set(allMeshesFromScene.map(m => `${m.expressId}:${m.modelIndex ?? 'any'}`));
833
542
  if (partiallyVisibleBatches.length > 0) {
834
543
  for (const { colorKey, visibleIds, color } of partiallyVisibleBatches) {
835
544
  // Get or create a cached sub-batch for this visibility state
@@ -850,6 +559,18 @@ export class Renderer {
850
559
  // Reset to opaque pipeline for subsequent rendering
851
560
  pass.setPipeline(this.pipeline.getPipeline());
852
561
  }
562
+ // Render color overlay batches (lens coloring) on top of ALL opaque geometry.
563
+ // Placed AFTER partial batches so depth buffer is complete for both full
564
+ // and partial batches. Uses 'equal' depth compare — only paints where
565
+ // original geometry wrote depth, so hidden entities never leak through.
566
+ const overrideBatches = this.scene.getOverrideBatches();
567
+ if (overrideBatches.length > 0) {
568
+ pass.setPipeline(this.pipeline.getOverlayPipeline());
569
+ for (const batch of overrideBatches) {
570
+ renderBatch(batch);
571
+ }
572
+ pass.setPipeline(this.pipeline.getPipeline());
573
+ }
853
574
  // Render selected meshes individually for proper highlighting
854
575
  // First, check if we have Mesh objects for selected IDs
855
576
  // If not, create them lazily from stored MeshData
@@ -872,7 +593,7 @@ export class Renderer {
872
593
  const meshKey = `${selId}:${selectedModelIndex ?? 'any'}`;
873
594
  if (!existingMeshKeys.has(meshKey) && this.scene.hasMeshData(selId, selectedModelIndex)) {
874
595
  const meshData = this.scene.getMeshData(selId, selectedModelIndex);
875
- this.createMeshFromData(meshData);
596
+ this.geometryManager.createMeshFromData(meshData);
876
597
  existingMeshKeys.add(meshKey);
877
598
  }
878
599
  }
@@ -1030,11 +751,12 @@ export class Renderer {
1030
751
  }
1031
752
  // Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
1032
753
  // Always show plane when sectionPlane options are provided (as preview or active)
1033
- if (options.sectionPlane && this.sectionPlaneRenderer && this.modelBounds) {
754
+ const modelBounds = this.geometryManager.getModelBounds();
755
+ if (options.sectionPlane && this.sectionPlaneRenderer && modelBounds) {
1034
756
  this.sectionPlaneRenderer.draw(pass, {
1035
757
  axis: options.sectionPlane.axis,
1036
758
  position: options.sectionPlane.position,
1037
- bounds: this.modelBounds,
759
+ bounds: modelBounds,
1038
760
  viewProj,
1039
761
  isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
1040
762
  min: options.sectionPlane.min,
@@ -1045,7 +767,7 @@ export class Renderer {
1045
767
  this.section2DOverlayRenderer.draw(pass, {
1046
768
  axis: options.sectionPlane.axis,
1047
769
  position: options.sectionPlane.position,
1048
- bounds: this.modelBounds,
770
+ bounds: modelBounds,
1049
771
  viewProj,
1050
772
  min: options.sectionPlane.min,
1051
773
  max: options.sectionPlane.max,
@@ -1076,112 +798,7 @@ export class Renderer {
1076
798
  * These are scaled internally to match the actual canvas pixel dimensions.
1077
799
  */
1078
800
  async pick(x, y, options) {
1079
- if (!this.picker) {
1080
- return null;
1081
- }
1082
- // Scale CSS pixel coordinates to canvas pixel coordinates
1083
- // The canvas.width may differ from CSS width due to 64-pixel alignment for WebGPU
1084
- const rect = this.canvas.getBoundingClientRect();
1085
- if (rect.width === 0 || rect.height === 0) {
1086
- return null;
1087
- }
1088
- const scaleX = this.canvas.width / rect.width;
1089
- const scaleY = this.canvas.height / rect.height;
1090
- const scaledX = x * scaleX;
1091
- const scaledY = y * scaleY;
1092
- // Skip picker during streaming for consistent performance
1093
- // Picking during streaming would be slow and incomplete anyway
1094
- if (options?.isStreaming) {
1095
- return null;
1096
- }
1097
- let meshes = this.scene.getMeshes();
1098
- const batchedMeshes = this.scene.getBatchedMeshes();
1099
- // If we have batched meshes, check if we need CPU raycasting
1100
- // This handles the case where we have SOME individual meshes (e.g., from highlighting)
1101
- // but not enough for full GPU picking coverage
1102
- if (batchedMeshes.length > 0) {
1103
- // Collect all expressIds from batched meshes
1104
- const expressIds = new Set();
1105
- for (const batch of batchedMeshes) {
1106
- for (const expressId of batch.expressIds) {
1107
- expressIds.add(expressId);
1108
- }
1109
- }
1110
- // Track existing meshes by (expressId:modelIndex) for multi-model support
1111
- // This handles expressId collisions (e.g., door #535 in model 0 vs beam #535 in model 1)
1112
- const existingMeshKeys = new Set(meshes.map(m => `${m.expressId}:${m.modelIndex ?? 'any'}`));
1113
- // Count how many meshes we'd need to create for full GPU picking
1114
- // For multi-model, count all pieces with unique (expressId, modelIndex) pairs
1115
- let toCreate = 0;
1116
- for (const expressId of expressIds) {
1117
- if (options?.hiddenIds?.has(expressId))
1118
- continue;
1119
- if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
1120
- continue;
1121
- // Get all pieces for this expressId (handles multi-model)
1122
- const pieces = this.scene.getMeshDataPieces(expressId);
1123
- if (pieces) {
1124
- for (const piece of pieces) {
1125
- const meshKey = `${expressId}:${piece.modelIndex ?? 'any'}`;
1126
- if (!existingMeshKeys.has(meshKey)) {
1127
- toCreate++;
1128
- }
1129
- }
1130
- }
1131
- }
1132
- // PERFORMANCE FIX: Use CPU raycasting for large models instead of creating GPU meshes
1133
- // GPU picking requires individual mesh buffers; for 60K+ elements this is too slow
1134
- // CPU raycasting uses bounding box filtering + triangle tests - no GPU buffers needed
1135
- const MAX_PICK_MESH_CREATION = 500;
1136
- if (toCreate > MAX_PICK_MESH_CREATION) {
1137
- // Use CPU raycasting fallback - works regardless of how many individual meshes exist
1138
- const ray = this.camera.unprojectToRay(scaledX, scaledY, this.canvas.width, this.canvas.height);
1139
- const hit = this.scene.raycast(ray.origin, ray.direction, options?.hiddenIds, options?.isolatedIds);
1140
- if (!hit)
1141
- return null;
1142
- // CPU raycasting returns expressId and modelIndex
1143
- return {
1144
- expressId: hit.expressId,
1145
- modelIndex: hit.modelIndex,
1146
- };
1147
- }
1148
- // For smaller models, create GPU meshes for picking
1149
- // Only create meshes for VISIBLE elements (not hidden, and either no isolation or in isolated set)
1150
- // For multi-model support: create meshes for ALL (expressId, modelIndex) pairs
1151
- for (const expressId of expressIds) {
1152
- // Skip if hidden
1153
- if (options?.hiddenIds?.has(expressId))
1154
- continue;
1155
- // Skip if isolation is active and this entity is not isolated
1156
- if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
1157
- continue;
1158
- // Get all pieces for this expressId (handles multi-model)
1159
- const pieces = this.scene.getMeshDataPieces(expressId);
1160
- if (pieces) {
1161
- for (const piece of pieces) {
1162
- const meshKey = `${piece.expressId}:${piece.modelIndex ?? 'any'}`;
1163
- // Skip if mesh already exists for this (expressId, modelIndex) pair
1164
- if (existingMeshKeys.has(meshKey))
1165
- continue;
1166
- this.createMeshFromData(piece);
1167
- existingMeshKeys.add(meshKey);
1168
- }
1169
- }
1170
- }
1171
- // Get updated meshes list (includes newly created ones)
1172
- meshes = this.scene.getMeshes();
1173
- }
1174
- // Apply visibility filtering to meshes before picking
1175
- // This ensures users can only select elements that are actually visible
1176
- if (options?.hiddenIds && options.hiddenIds.size > 0) {
1177
- meshes = meshes.filter(mesh => !options.hiddenIds.has(mesh.expressId));
1178
- }
1179
- if (options?.isolatedIds !== null && options?.isolatedIds !== undefined) {
1180
- meshes = meshes.filter(mesh => options.isolatedIds.has(mesh.expressId));
1181
- }
1182
- const viewProj = this.camera.getViewProjMatrix().m;
1183
- const result = await this.picker.pick(scaledX, scaledY, this.canvas.width, this.canvas.height, meshes, viewProj);
1184
- return result;
801
+ return this.pickingManager.pick(x, y, options);
1185
802
  }
1186
803
  /**
1187
804
  * Raycast into the scene to get precise 3D intersection point
@@ -1191,103 +808,7 @@ export class Renderer {
1191
808
  * These are scaled internally to match the actual canvas pixel dimensions.
1192
809
  */
1193
810
  raycastScene(x, y, options) {
1194
- try {
1195
- // Scale CSS pixel coordinates to canvas pixel coordinates
1196
- // The canvas.width may differ from CSS width due to 64-pixel alignment for WebGPU
1197
- const rect = this.canvas.getBoundingClientRect();
1198
- if (rect.width === 0 || rect.height === 0) {
1199
- return null;
1200
- }
1201
- const scaleX = this.canvas.width / rect.width;
1202
- const scaleY = this.canvas.height / rect.height;
1203
- const scaledX = x * scaleX;
1204
- const scaledY = y * scaleY;
1205
- // Create ray from screen coordinates
1206
- const ray = this.camera.unprojectToRay(scaledX, scaledY, this.canvas.width, this.canvas.height);
1207
- // Get all mesh data from scene
1208
- const allMeshData = [];
1209
- const meshes = this.scene.getMeshes();
1210
- const batchedMeshes = this.scene.getBatchedMeshes();
1211
- // Collect mesh data from regular meshes
1212
- for (const mesh of meshes) {
1213
- const meshData = this.scene.getMeshData(mesh.expressId);
1214
- if (meshData) {
1215
- // Apply visibility filtering
1216
- if (options?.hiddenIds?.has(meshData.expressId))
1217
- continue;
1218
- if (options?.isolatedIds !== null &&
1219
- options?.isolatedIds !== undefined &&
1220
- !options.isolatedIds.has(meshData.expressId)) {
1221
- continue;
1222
- }
1223
- allMeshData.push(meshData);
1224
- }
1225
- }
1226
- // Collect mesh data from batched meshes
1227
- for (const batch of batchedMeshes) {
1228
- for (const expressId of batch.expressIds) {
1229
- const meshData = this.scene.getMeshData(expressId);
1230
- if (meshData) {
1231
- // Apply visibility filtering
1232
- if (options?.hiddenIds?.has(meshData.expressId))
1233
- continue;
1234
- if (options?.isolatedIds !== null &&
1235
- options?.isolatedIds !== undefined &&
1236
- !options.isolatedIds.has(meshData.expressId)) {
1237
- continue;
1238
- }
1239
- allMeshData.push(meshData);
1240
- }
1241
- }
1242
- }
1243
- if (allMeshData.length === 0) {
1244
- return null;
1245
- }
1246
- // Use BVH for performance if we have many meshes
1247
- let meshesToTest = allMeshData;
1248
- if (allMeshData.length > this.BVH_THRESHOLD) {
1249
- // Check if BVH needs rebuilding
1250
- const needsRebuild = !this.bvhCache ||
1251
- !this.bvhCache.isBuilt ||
1252
- this.bvhCache.meshCount !== allMeshData.length;
1253
- if (needsRebuild) {
1254
- // Build BVH only when needed
1255
- this.bvh.build(allMeshData);
1256
- this.bvhCache = {
1257
- meshCount: allMeshData.length,
1258
- meshData: allMeshData,
1259
- isBuilt: true,
1260
- };
1261
- }
1262
- // Use BVH to filter meshes
1263
- const meshIndices = this.bvh.getMeshesForRay(ray, allMeshData);
1264
- meshesToTest = meshIndices.map(i => allMeshData[i]);
1265
- }
1266
- // Perform raycasting
1267
- const intersection = this.raycaster.raycast(ray, meshesToTest);
1268
- if (!intersection) {
1269
- return null;
1270
- }
1271
- // Detect snap targets if requested
1272
- // Pass meshes near the ray to detect edges even when partially occluded
1273
- let snapTarget;
1274
- if (options?.snapOptions) {
1275
- const cameraPos = this.camera.getPosition();
1276
- const cameraFov = this.camera.getFOV();
1277
- // Pass meshes that are near the ray (from BVH or all meshes if BVH not used)
1278
- // This allows detecting edges even when they're behind other objects
1279
- snapTarget = this.snapDetector.detectSnapTarget(ray, meshesToTest, // Pass all meshes near the ray
1280
- intersection, { position: cameraPos, fov: cameraFov }, this.canvas.height, options.snapOptions) || undefined;
1281
- }
1282
- return {
1283
- intersection,
1284
- snap: snapTarget,
1285
- };
1286
- }
1287
- catch (error) {
1288
- console.error('Raycast error:', error);
1289
- return null;
1290
- }
811
+ return this.raycastEngine.raycastScene(x, y, options);
1291
812
  }
1292
813
  /**
1293
814
  * Raycast with magnetic edge snapping behavior
@@ -1297,149 +818,31 @@ export class Renderer {
1297
818
  * These are scaled internally to match the actual canvas pixel dimensions.
1298
819
  */
1299
820
  raycastSceneMagnetic(x, y, currentEdgeLock, options) {
1300
- try {
1301
- // Scale CSS pixel coordinates to canvas pixel coordinates
1302
- // The canvas.width may differ from CSS width due to 64-pixel alignment for WebGPU
1303
- const rect = this.canvas.getBoundingClientRect();
1304
- if (rect.width === 0 || rect.height === 0) {
1305
- return {
1306
- intersection: null,
1307
- snapTarget: null,
1308
- edgeLock: {
1309
- edge: null,
1310
- meshExpressId: null,
1311
- edgeT: 0,
1312
- shouldLock: false,
1313
- shouldRelease: true,
1314
- isCorner: false,
1315
- cornerValence: 0,
1316
- },
1317
- };
1318
- }
1319
- const scaleX = this.canvas.width / rect.width;
1320
- const scaleY = this.canvas.height / rect.height;
1321
- const scaledX = x * scaleX;
1322
- const scaledY = y * scaleY;
1323
- // Create ray from screen coordinates
1324
- const ray = this.camera.unprojectToRay(scaledX, scaledY, this.canvas.width, this.canvas.height);
1325
- // Get all mesh data from scene
1326
- const allMeshData = [];
1327
- const meshes = this.scene.getMeshes();
1328
- const batchedMeshes = this.scene.getBatchedMeshes();
1329
- // Collect mesh data from regular meshes
1330
- for (const mesh of meshes) {
1331
- const meshData = this.scene.getMeshData(mesh.expressId);
1332
- if (meshData) {
1333
- if (options?.hiddenIds?.has(meshData.expressId))
1334
- continue;
1335
- if (options?.isolatedIds !== null &&
1336
- options?.isolatedIds !== undefined &&
1337
- !options.isolatedIds.has(meshData.expressId)) {
1338
- continue;
1339
- }
1340
- allMeshData.push(meshData);
1341
- }
1342
- }
1343
- // Collect mesh data from batched meshes
1344
- for (const batch of batchedMeshes) {
1345
- for (const expressId of batch.expressIds) {
1346
- const meshData = this.scene.getMeshData(expressId);
1347
- if (meshData) {
1348
- if (options?.hiddenIds?.has(meshData.expressId))
1349
- continue;
1350
- if (options?.isolatedIds !== null &&
1351
- options?.isolatedIds !== undefined &&
1352
- !options.isolatedIds.has(meshData.expressId)) {
1353
- continue;
1354
- }
1355
- allMeshData.push(meshData);
1356
- }
1357
- }
1358
- }
1359
- if (allMeshData.length === 0) {
1360
- return {
1361
- intersection: null,
1362
- snapTarget: null,
1363
- edgeLock: {
1364
- edge: null,
1365
- meshExpressId: null,
1366
- edgeT: 0,
1367
- shouldLock: false,
1368
- shouldRelease: true,
1369
- isCorner: false,
1370
- cornerValence: 0,
1371
- },
1372
- };
1373
- }
1374
- // Use BVH for performance if we have many meshes
1375
- let meshesToTest = allMeshData;
1376
- if (allMeshData.length > this.BVH_THRESHOLD) {
1377
- const needsRebuild = !this.bvhCache ||
1378
- !this.bvhCache.isBuilt ||
1379
- this.bvhCache.meshCount !== allMeshData.length;
1380
- if (needsRebuild) {
1381
- this.bvh.build(allMeshData);
1382
- this.bvhCache = {
1383
- meshCount: allMeshData.length,
1384
- meshData: allMeshData,
1385
- isBuilt: true,
1386
- };
1387
- }
1388
- const meshIndices = this.bvh.getMeshesForRay(ray, allMeshData);
1389
- meshesToTest = meshIndices.map(i => allMeshData[i]);
1390
- }
1391
- // Perform raycasting
1392
- const intersection = this.raycaster.raycast(ray, meshesToTest);
1393
- // Use magnetic snap detection
1394
- const cameraPos = this.camera.getPosition();
1395
- const cameraFov = this.camera.getFOV();
1396
- const magneticResult = this.snapDetector.detectMagneticSnap(ray, meshesToTest, intersection, { position: cameraPos, fov: cameraFov }, this.canvas.height, currentEdgeLock, options?.snapOptions || {});
1397
- return {
1398
- intersection,
1399
- ...magneticResult,
1400
- };
1401
- }
1402
- catch (error) {
1403
- console.error('Magnetic raycast error:', error);
1404
- return {
1405
- intersection: null,
1406
- snapTarget: null,
1407
- edgeLock: {
1408
- edge: null,
1409
- meshExpressId: null,
1410
- edgeT: 0,
1411
- shouldLock: false,
1412
- shouldRelease: true,
1413
- isCorner: false,
1414
- cornerValence: 0,
1415
- },
1416
- };
1417
- }
821
+ return this.raycastEngine.raycastSceneMagnetic(x, y, currentEdgeLock, options);
1418
822
  }
1419
823
  /**
1420
824
  * Invalidate BVH cache (call when geometry changes)
1421
825
  */
1422
826
  invalidateBVHCache() {
1423
- this.bvhCache = null;
827
+ this.raycastEngine.invalidateBVHCache();
1424
828
  }
1425
829
  /**
1426
830
  * Get the raycaster instance (for advanced usage)
1427
831
  */
1428
832
  getRaycaster() {
1429
- return this.raycaster;
833
+ return this.raycastEngine.getRaycaster();
1430
834
  }
1431
835
  /**
1432
836
  * Get the snap detector instance (for advanced usage)
1433
837
  */
1434
838
  getSnapDetector() {
1435
- return this.snapDetector;
839
+ return this.raycastEngine.getSnapDetector();
1436
840
  }
1437
841
  /**
1438
842
  * Clear all caches (call when geometry changes)
1439
843
  */
1440
844
  clearCaches() {
1441
- this.invalidateBVHCache();
1442
- this.snapDetector.clearCache();
845
+ this.raycastEngine.clearCaches();
1443
846
  }
1444
847
  /**
1445
848
  * Resize canvas
@@ -1469,12 +872,13 @@ export class Renderer {
1469
872
  // minVal = options.sectionPlane.min ?? boundsMin[axisIdx]
1470
873
  // maxVal = options.sectionPlane.max ?? boundsMax[axisIdx]
1471
874
  const axisIdx = axis === 'side' ? 'x' : axis === 'down' ? 'y' : 'z';
875
+ const modelBounds = this.geometryManager.getModelBounds();
1472
876
  // Allow upload if either sectionRange has both values, or modelBounds exists as fallback
1473
877
  const hasFullRange = sectionRange?.min !== undefined && sectionRange?.max !== undefined;
1474
- if (!hasFullRange && !this.modelBounds)
878
+ if (!hasFullRange && !modelBounds)
1475
879
  return;
1476
- const minVal = sectionRange?.min ?? this.modelBounds.min[axisIdx];
1477
- const maxVal = sectionRange?.max ?? this.modelBounds.max[axisIdx];
880
+ const minVal = sectionRange?.min ?? modelBounds.min[axisIdx];
881
+ const maxVal = sectionRange?.max ?? modelBounds.max[axisIdx];
1478
882
  const planePosition = minVal + (position / 100) * (maxVal - minVal);
1479
883
  this.section2DOverlayRenderer.uploadDrawing(polygons, lines, axis, planePosition, flipped);
1480
884
  }