@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
package/dist/index.js CHANGED
@@ -11,7 +11,12 @@ export { Scene } from './scene.js';
11
11
  export { Picker } from './picker.js';
12
12
  export { MathUtils } from './math.js';
13
13
  export { SectionPlaneRenderer } from './section-plane.js';
14
+ export { Raycaster } from './raycaster.js';
15
+ export { SnapDetector, SnapType } from './snap-detector.js';
16
+ export { BVH } from './bvh.js';
14
17
  export * from './types.js';
18
+ // Zero-copy GPU upload (new - faster, less memory)
19
+ export { ZeroCopyGpuUploader, createZeroCopyUploader, } from './zero-copy-uploader.js';
15
20
  import { WebGPUDevice } from './device.js';
16
21
  import { RenderPipeline, InstancedRenderPipeline } from './pipeline.js';
17
22
  import { Camera } from './camera.js';
@@ -21,6 +26,9 @@ import { FrustumUtils } from '@ifc-lite/spatial';
21
26
  import { SectionPlaneRenderer } from './section-plane.js';
22
27
  import { deduplicateMeshes } from '@ifc-lite/geometry';
23
28
  import { MathUtils } from './math.js';
29
+ import { Raycaster } from './raycaster.js';
30
+ import { SnapDetector } from './snap-detector.js';
31
+ import { BVH } from './bvh.js';
24
32
  /**
25
33
  * Main renderer class
26
34
  */
@@ -34,11 +42,22 @@ export class Renderer {
34
42
  canvas;
35
43
  sectionPlaneRenderer = null;
36
44
  modelBounds = null;
45
+ raycaster;
46
+ snapDetector;
47
+ bvh;
48
+ // BVH cache
49
+ bvhCache = null;
50
+ // Performance constants
51
+ BVH_THRESHOLD = 100;
37
52
  constructor(canvas) {
38
53
  this.canvas = canvas;
39
54
  this.device = new WebGPUDevice();
40
55
  this.camera = new Camera();
41
56
  this.scene = new Scene();
57
+ this.raycaster = new Raycaster();
58
+ this.snapDetector = new SnapDetector();
59
+ this.bvh = new BVH();
60
+ this.bvhCache = null;
42
61
  }
43
62
  /**
44
63
  * Initialize renderer
@@ -57,7 +76,7 @@ export class Renderer {
57
76
  this.pipeline = new RenderPipeline(this.device, width, height);
58
77
  this.instancedPipeline = new InstancedRenderPipeline(this.device, width, height);
59
78
  this.picker = new Picker(this.device, width, height);
60
- this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat());
79
+ this.sectionPlaneRenderer = new SectionPlaneRenderer(this.device.getDevice(), this.device.getFormat(), this.pipeline.getSampleCount());
61
80
  this.camera.setAspect(width / height);
62
81
  }
63
82
  /**
@@ -366,44 +385,17 @@ export class Renderer {
366
385
  }
367
386
  const device = this.device.getDevice();
368
387
  const viewProj = this.camera.getViewProjMatrix().m;
369
- // Ensure all meshes have GPU resources (in case they were added before pipeline was ready)
370
- this.ensureMeshResources();
371
388
  let meshes = this.scene.getMeshes();
372
389
  // Check if visibility filtering is active
373
390
  const hasHiddenFilter = options.hiddenIds && options.hiddenIds.size > 0;
374
391
  const hasIsolatedFilter = options.isolatedIds !== null && options.isolatedIds !== undefined;
375
392
  const hasVisibilityFiltering = hasHiddenFilter || hasIsolatedFilter;
376
- // When using batched rendering with visibility filtering, we need individual meshes
377
- // Create them lazily from stored MeshData for visible elements only
378
- const batchedMeshes = this.scene.getBatchedMeshes();
379
- if (hasVisibilityFiltering && batchedMeshes.length > 0 && meshes.length === 0) {
380
- // Collect all expressIds from batched meshes
381
- const allExpressIds = new Set();
382
- for (const batch of batchedMeshes) {
383
- for (const expressId of batch.expressIds) {
384
- allExpressIds.add(expressId);
385
- }
386
- }
387
- // Filter to get visible expressIds
388
- const visibleExpressIds = [];
389
- for (const expressId of allExpressIds) {
390
- const isHidden = options.hiddenIds?.has(expressId) ?? false;
391
- const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
392
- if (!isHidden && isIsolated) {
393
- visibleExpressIds.push(expressId);
394
- }
395
- }
396
- // Create individual meshes for visible elements only
397
- const existingMeshIds = new Set(meshes.map(m => m.expressId));
398
- for (const expressId of visibleExpressIds) {
399
- if (!existingMeshIds.has(expressId) && this.scene.hasMeshData(expressId)) {
400
- const meshData = this.scene.getMeshData(expressId);
401
- this.createMeshFromData(meshData);
402
- }
403
- }
404
- // Get updated meshes list
405
- meshes = this.scene.getMeshes();
406
- }
393
+ // PERFORMANCE FIX: Use batch-level visibility filtering instead of creating individual meshes
394
+ // Only create individual meshes for selected elements (for highlighting)
395
+ // Batches are filtered at render time - fully visible batches render normally,
396
+ // partially visible batches are skipped (their visible elements will be in other batches or individual meshes)
397
+ // Ensure all existing meshes have GPU resources
398
+ this.ensureMeshResources();
407
399
  // Frustum culling (if enabled and spatial index available)
408
400
  if (options.enableFrustumCulling && options.spatialIndex) {
409
401
  try {
@@ -467,50 +459,64 @@ export class Renderer {
467
459
  const allMeshes = [...opaqueMeshes, ...transparentMeshes];
468
460
  const selectedId = options.selectedId;
469
461
  const selectedIds = options.selectedIds;
470
- // Calculate section plane parameters if enabled
462
+ // Calculate section plane parameters and model bounds
463
+ // Always calculate bounds when sectionPlane is provided (for preview and active mode)
471
464
  let sectionPlaneData;
472
- if (options.sectionPlane?.enabled) {
473
- // Calculate plane normal based on axis
474
- const normal = [0, 0, 0];
475
- if (options.sectionPlane.axis === 'x')
476
- normal[0] = 1;
477
- else if (options.sectionPlane.axis === 'y')
478
- normal[1] = 1;
479
- else
480
- normal[2] = 1;
481
- // Get model bounds for calculating plane position and visual
465
+ if (options.sectionPlane) {
466
+ // Get model bounds from ALL geometry sources: individual meshes AND batched meshes
482
467
  const boundsMin = { x: Infinity, y: Infinity, z: Infinity };
483
468
  const boundsMax = { x: -Infinity, y: -Infinity, z: -Infinity };
484
- if (meshes.length > 0) {
485
- for (const mesh of meshes) {
486
- if (mesh.bounds) {
487
- boundsMin.x = Math.min(boundsMin.x, mesh.bounds.min[0]);
488
- boundsMin.y = Math.min(boundsMin.y, mesh.bounds.min[1]);
489
- boundsMin.z = Math.min(boundsMin.z, mesh.bounds.min[2]);
490
- boundsMax.x = Math.max(boundsMax.x, mesh.bounds.max[0]);
491
- boundsMax.y = Math.max(boundsMax.y, mesh.bounds.max[1]);
492
- boundsMax.z = Math.max(boundsMax.z, mesh.bounds.max[2]);
493
- }
469
+ // Check individual meshes
470
+ for (const mesh of meshes) {
471
+ if (mesh.bounds) {
472
+ boundsMin.x = Math.min(boundsMin.x, mesh.bounds.min[0]);
473
+ boundsMin.y = Math.min(boundsMin.y, mesh.bounds.min[1]);
474
+ boundsMin.z = Math.min(boundsMin.z, mesh.bounds.min[2]);
475
+ boundsMax.x = Math.max(boundsMax.x, mesh.bounds.max[0]);
476
+ boundsMax.y = Math.max(boundsMax.y, mesh.bounds.max[1]);
477
+ boundsMax.z = Math.max(boundsMax.z, mesh.bounds.max[2]);
494
478
  }
495
- if (!Number.isFinite(boundsMin.x)) {
496
- boundsMin.x = boundsMin.y = boundsMin.z = -100;
497
- boundsMax.x = boundsMax.y = boundsMax.z = 100;
479
+ }
480
+ // Check batched meshes (most geometry is here!)
481
+ const batchedMeshes = this.scene.getBatchedMeshes();
482
+ for (const batch of batchedMeshes) {
483
+ if (batch.bounds) {
484
+ boundsMin.x = Math.min(boundsMin.x, batch.bounds.min[0]);
485
+ boundsMin.y = Math.min(boundsMin.y, batch.bounds.min[1]);
486
+ boundsMin.z = Math.min(boundsMin.z, batch.bounds.min[2]);
487
+ boundsMax.x = Math.max(boundsMax.x, batch.bounds.max[0]);
488
+ boundsMax.y = Math.max(boundsMax.y, batch.bounds.max[1]);
489
+ boundsMax.z = Math.max(boundsMax.z, batch.bounds.max[2]);
498
490
  }
499
491
  }
500
- else {
492
+ // Fallback if no bounds found
493
+ if (!Number.isFinite(boundsMin.x)) {
501
494
  boundsMin.x = boundsMin.y = boundsMin.z = -100;
502
495
  boundsMax.x = boundsMax.y = boundsMax.z = 100;
503
496
  }
504
497
  // Store bounds for section plane visual
505
498
  this.modelBounds = { min: boundsMin, max: boundsMax };
506
- // Get axis-specific range
507
- const axisIdx = options.sectionPlane.axis === 'x' ? 'x' : options.sectionPlane.axis === 'y' ? 'y' : 'z';
508
- const minVal = boundsMin[axisIdx];
509
- const maxVal = boundsMax[axisIdx];
510
- // Calculate plane distance from position percentage
511
- const range = maxVal - minVal;
512
- const distance = minVal + (options.sectionPlane.position / 100) * range;
513
- sectionPlaneData = { normal, distance, enabled: true };
499
+ // Only calculate clipping data if section is enabled
500
+ if (options.sectionPlane.enabled) {
501
+ // Calculate plane normal based on semantic axis
502
+ // down = Y axis (horizontal cut), front = Z axis, side = X axis
503
+ const normal = [0, 0, 0];
504
+ if (options.sectionPlane.axis === 'side')
505
+ normal[0] = 1; // X axis
506
+ else if (options.sectionPlane.axis === 'down')
507
+ normal[1] = 1; // Y axis (horizontal)
508
+ else
509
+ normal[2] = 1; // Z axis (front)
510
+ // Get axis-specific range based on semantic axis
511
+ // Use min/max overrides from sectionPlane if provided (storey-based range)
512
+ const axisIdx = options.sectionPlane.axis === 'side' ? 'x' : options.sectionPlane.axis === 'down' ? 'y' : 'z';
513
+ const minVal = options.sectionPlane.min ?? boundsMin[axisIdx];
514
+ const maxVal = options.sectionPlane.max ?? boundsMax[axisIdx];
515
+ // Calculate plane distance from position percentage
516
+ const range = maxVal - minVal;
517
+ const distance = minVal + (options.sectionPlane.position / 100) * range;
518
+ sectionPlaneData = { normal, distance, enabled: true };
519
+ }
514
520
  }
515
521
  for (const mesh of allMeshes) {
516
522
  if (mesh.uniformBuffer) {
@@ -551,30 +557,92 @@ export class Renderer {
551
557
  }
552
558
  // Now record draw commands
553
559
  const encoder = device.createCommandEncoder();
560
+ // Set up MSAA rendering if enabled
561
+ const msaaView = this.pipeline.getMultisampleTextureView();
562
+ const useMSAA = msaaView !== null && this.pipeline.getSampleCount() > 1;
554
563
  const pass = encoder.beginRenderPass({
555
564
  colorAttachments: [
556
565
  {
557
- view: textureView,
566
+ // If MSAA enabled: render to multisample texture, resolve to swap chain
567
+ // If MSAA disabled: render directly to swap chain
568
+ view: useMSAA ? msaaView : textureView,
569
+ resolveTarget: useMSAA ? textureView : undefined,
558
570
  loadOp: 'clear',
559
571
  clearValue: clearColor,
560
- storeOp: 'store',
572
+ storeOp: useMSAA ? 'discard' : 'store', // Discard MSAA buffer after resolve
561
573
  },
562
574
  ],
563
575
  depthStencilAttachment: {
564
576
  view: this.pipeline.getDepthTextureView(),
565
- depthClearValue: 1.0,
577
+ depthClearValue: 0.0, // Reverse-Z: clear to 0.0 (far plane)
566
578
  depthLoadOp: 'clear',
567
579
  depthStoreOp: 'store',
568
580
  },
569
581
  });
570
582
  pass.setPipeline(this.pipeline.getPipeline());
571
583
  // Check if we have batched meshes (preferred for performance)
572
- // When visibility filtering is active, we need to render individual meshes instead of batches
573
- // because batches merge geometry by color and can't be partially rendered
574
584
  const allBatchedMeshes = this.scene.getBatchedMeshes();
575
- if (allBatchedMeshes.length > 0 && !hasVisibilityFiltering) {
576
- // Separate batches into selected and non-selected
577
- const nonSelectedBatches = [];
585
+ // PERFORMANCE FIX: Always use batch rendering when we have batches
586
+ // Apply visibility filtering at the BATCH level instead of creating individual meshes
587
+ // This keeps draw calls at ~50-200 instead of 60K+
588
+ if (allBatchedMeshes.length > 0) {
589
+ // Pre-compute visibility for each batch (only when filtering is active)
590
+ // A batch is visible if ANY of its elements are visible
591
+ // A batch is fully visible if ALL of its elements are visible
592
+ const batchVisibility = new Map();
593
+ if (hasVisibilityFiltering) {
594
+ for (const batch of allBatchedMeshes) {
595
+ let visibleCount = 0;
596
+ const total = batch.expressIds.length;
597
+ for (const expressId of batch.expressIds) {
598
+ const isHidden = options.hiddenIds?.has(expressId) ?? false;
599
+ const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
600
+ if (!isHidden && isIsolated) {
601
+ visibleCount++;
602
+ }
603
+ }
604
+ batchVisibility.set(batch.colorKey, {
605
+ visible: visibleCount > 0,
606
+ fullyVisible: visibleCount === total,
607
+ });
608
+ }
609
+ }
610
+ // Separate batches into opaque and transparent, filtering by visibility
611
+ // IMPORTANT: Only render FULLY visible batches - partially visible batches
612
+ // need individual mesh rendering to show only the visible elements
613
+ const opaqueBatches = [];
614
+ const transparentBatches = [];
615
+ // Collect visible expressIds from partially visible batches
616
+ // These need individual mesh rendering instead of batch rendering
617
+ const partiallyVisibleExpressIds = new Set();
618
+ for (const batch of allBatchedMeshes) {
619
+ // Check visibility
620
+ if (hasVisibilityFiltering) {
621
+ const vis = batchVisibility.get(batch.colorKey);
622
+ if (!vis || !vis.visible)
623
+ continue; // Skip completely hidden batches
624
+ // FIX: Skip partially visible batches - their visible elements
625
+ // will be rendered individually below
626
+ if (!vis.fullyVisible) {
627
+ // Collect the visible expressIds from this batch
628
+ for (const expressId of batch.expressIds) {
629
+ const isHidden = options.hiddenIds?.has(expressId) ?? false;
630
+ const isIsolated = !hasIsolatedFilter || options.isolatedIds.has(expressId);
631
+ if (!isHidden && isIsolated) {
632
+ partiallyVisibleExpressIds.add(expressId);
633
+ }
634
+ }
635
+ continue; // Don't add batch to render list
636
+ }
637
+ }
638
+ const alpha = batch.color[3];
639
+ if (alpha < 0.99) {
640
+ transparentBatches.push(batch);
641
+ }
642
+ else {
643
+ opaqueBatches.push(batch);
644
+ }
645
+ }
578
646
  const selectedExpressIds = new Set();
579
647
  if (selectedId !== undefined && selectedId !== null) {
580
648
  selectedExpressIds.add(selectedId);
@@ -584,11 +652,10 @@ export class Renderer {
584
652
  selectedExpressIds.add(id);
585
653
  }
586
654
  }
587
- // Render ALL batches normally (non-selected meshes will render normally)
588
- // Selected meshes will be rendered individually on top with highlight
589
- for (const batch of allBatchedMeshes) {
655
+ // Helper function to render a batch
656
+ const renderBatch = (batch) => {
590
657
  if (!batch.bindGroup || !batch.uniformBuffer)
591
- continue;
658
+ return;
592
659
  // Update uniform buffer for this batch
593
660
  const buffer = new Float32Array(48);
594
661
  const flagBuffer = new Uint32Array(buffer.buffer, 176, 4);
@@ -621,21 +688,109 @@ export class Renderer {
621
688
  pass.setVertexBuffer(0, batch.vertexBuffer);
622
689
  pass.setIndexBuffer(batch.indexBuffer, 'uint32');
623
690
  pass.drawIndexed(batch.indexCount);
691
+ };
692
+ // Render opaque batches first with opaque pipeline
693
+ pass.setPipeline(this.pipeline.getPipeline());
694
+ for (const batch of opaqueBatches) {
695
+ renderBatch(batch);
696
+ }
697
+ // Render partially visible elements individually (from batches that had mixed visibility)
698
+ // This is the FIX for hide/isolate showing wrong batches
699
+ const allMeshes = this.scene.getMeshes();
700
+ const existingMeshIds = new Set(allMeshes.map(m => m.expressId));
701
+ if (partiallyVisibleExpressIds.size > 0) {
702
+ // Create GPU resources lazily for partially visible meshes
703
+ for (const pvId of partiallyVisibleExpressIds) {
704
+ if (!existingMeshIds.has(pvId) && this.scene.hasMeshData(pvId)) {
705
+ const meshData = this.scene.getMeshData(pvId);
706
+ this.createMeshFromData(meshData);
707
+ existingMeshIds.add(pvId);
708
+ }
709
+ }
710
+ // Get partially visible meshes and render them
711
+ const partialMeshes = this.scene.getMeshes().filter(mesh => partiallyVisibleExpressIds.has(mesh.expressId));
712
+ // Ensure partial meshes have uniform buffers and bind groups
713
+ for (const mesh of partialMeshes) {
714
+ if (!mesh.uniformBuffer && this.pipeline) {
715
+ mesh.uniformBuffer = device.createBuffer({
716
+ size: this.pipeline.getUniformBufferSize(),
717
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
718
+ });
719
+ mesh.bindGroup = device.createBindGroup({
720
+ layout: this.pipeline.getBindGroupLayout(),
721
+ entries: [
722
+ {
723
+ binding: 0,
724
+ resource: { buffer: mesh.uniformBuffer },
725
+ },
726
+ ],
727
+ });
728
+ }
729
+ }
730
+ // Render partially visible meshes (not selected, normal rendering)
731
+ for (const mesh of partialMeshes) {
732
+ if (!mesh.bindGroup || !mesh.uniformBuffer) {
733
+ continue;
734
+ }
735
+ const buffer = new Float32Array(48);
736
+ const flagBuffer = new Uint32Array(buffer.buffer, 176, 4);
737
+ buffer.set(viewProj, 0);
738
+ buffer.set(mesh.transform.m, 16);
739
+ buffer.set(mesh.color, 32);
740
+ buffer[36] = mesh.material?.metallic ?? 0.0;
741
+ buffer[37] = mesh.material?.roughness ?? 0.6;
742
+ // Section plane data
743
+ if (sectionPlaneData) {
744
+ buffer[40] = sectionPlaneData.normal[0];
745
+ buffer[41] = sectionPlaneData.normal[1];
746
+ buffer[42] = sectionPlaneData.normal[2];
747
+ buffer[43] = sectionPlaneData.distance;
748
+ }
749
+ // Flags (not selected)
750
+ flagBuffer[0] = 0;
751
+ flagBuffer[1] = sectionPlaneData?.enabled ? 1 : 0;
752
+ flagBuffer[2] = 0;
753
+ flagBuffer[3] = 0;
754
+ device.queue.writeBuffer(mesh.uniformBuffer, 0, buffer);
755
+ // Use opaque or transparent pipeline based on alpha
756
+ const isTransparent = mesh.color[3] < 0.99;
757
+ if (isTransparent) {
758
+ pass.setPipeline(this.pipeline.getTransparentPipeline());
759
+ }
760
+ else {
761
+ pass.setPipeline(this.pipeline.getPipeline());
762
+ }
763
+ pass.setBindGroup(0, mesh.bindGroup);
764
+ pass.setVertexBuffer(0, mesh.vertexBuffer);
765
+ pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
766
+ pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
767
+ }
624
768
  }
625
769
  // Render selected meshes individually for proper highlighting
626
770
  // First, check if we have Mesh objects for selected IDs
627
771
  // If not, create them lazily from stored MeshData
628
- const allMeshes = this.scene.getMeshes();
629
- const existingMeshIds = new Set(allMeshes.map(m => m.expressId));
630
- // Create GPU resources lazily for selected meshes that don't have them yet
631
- for (const selectedId of selectedExpressIds) {
632
- if (!existingMeshIds.has(selectedId) && this.scene.hasMeshData(selectedId)) {
633
- const meshData = this.scene.getMeshData(selectedId);
772
+ // FIX: Filter selected IDs by visibility BEFORE creating GPU resources
773
+ // This ensures highlights don't appear for hidden elements
774
+ const visibleSelectedIds = new Set();
775
+ for (const selId of selectedExpressIds) {
776
+ // Skip if hidden
777
+ if (options.hiddenIds?.has(selId))
778
+ continue;
779
+ // Skip if isolation is active and this entity is not isolated
780
+ if (hasIsolatedFilter && !options.isolatedIds.has(selId))
781
+ continue;
782
+ visibleSelectedIds.add(selId);
783
+ }
784
+ // Create GPU resources lazily for visible selected meshes that don't have them yet
785
+ for (const selId of visibleSelectedIds) {
786
+ if (!existingMeshIds.has(selId) && this.scene.hasMeshData(selId)) {
787
+ const meshData = this.scene.getMeshData(selId);
634
788
  this.createMeshFromData(meshData);
789
+ existingMeshIds.add(selId);
635
790
  }
636
791
  }
637
- // Now get selected meshes (includes newly created ones)
638
- const selectedMeshes = this.scene.getMeshes().filter(mesh => selectedExpressIds.has(mesh.expressId));
792
+ // Now get selected meshes (only visible ones)
793
+ const selectedMeshes = this.scene.getMeshes().filter(mesh => visibleSelectedIds.has(mesh.expressId));
639
794
  // Ensure selected meshes have uniform buffers and bind groups
640
795
  for (const mesh of selectedMeshes) {
641
796
  if (!mesh.uniformBuffer && this.pipeline) {
@@ -686,9 +841,49 @@ export class Renderer {
686
841
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
687
842
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
688
843
  }
844
+ // Render transparent BATCHED meshes with transparent pipeline (after opaque batches and selections)
845
+ if (transparentBatches.length > 0) {
846
+ pass.setPipeline(this.pipeline.getTransparentPipeline());
847
+ for (const batch of transparentBatches) {
848
+ renderBatch(batch);
849
+ }
850
+ }
851
+ // Render transparent individual meshes with transparent pipeline
852
+ if (transparentMeshes.length > 0) {
853
+ pass.setPipeline(this.pipeline.getTransparentPipeline());
854
+ for (const mesh of transparentMeshes) {
855
+ if (!mesh.bindGroup || !mesh.uniformBuffer) {
856
+ continue;
857
+ }
858
+ const buffer = new Float32Array(48);
859
+ const flagBuffer = new Uint32Array(buffer.buffer, 176, 4);
860
+ buffer.set(viewProj, 0);
861
+ buffer.set(mesh.transform.m, 16);
862
+ buffer.set(mesh.color, 32);
863
+ buffer[36] = mesh.material?.metallic ?? 0.0;
864
+ buffer[37] = mesh.material?.roughness ?? 0.6;
865
+ // Section plane data
866
+ if (sectionPlaneData) {
867
+ buffer[40] = sectionPlaneData.normal[0];
868
+ buffer[41] = sectionPlaneData.normal[1];
869
+ buffer[42] = sectionPlaneData.normal[2];
870
+ buffer[43] = sectionPlaneData.distance;
871
+ }
872
+ // Flags (not selected, transparent)
873
+ flagBuffer[0] = 0;
874
+ flagBuffer[1] = sectionPlaneData?.enabled ? 1 : 0;
875
+ flagBuffer[2] = 0;
876
+ flagBuffer[3] = 0;
877
+ device.queue.writeBuffer(mesh.uniformBuffer, 0, buffer);
878
+ pass.setBindGroup(0, mesh.bindGroup);
879
+ pass.setVertexBuffer(0, mesh.vertexBuffer);
880
+ pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
881
+ pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
882
+ }
883
+ }
689
884
  }
690
885
  else {
691
- // Fallback: render individual meshes (slower but works)
886
+ // Fallback: render individual meshes (only when no batches exist)
692
887
  // Render opaque meshes with per-mesh bind groups
693
888
  for (const mesh of opaqueMeshes) {
694
889
  if (mesh.bindGroup) {
@@ -701,17 +896,20 @@ export class Renderer {
701
896
  pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
702
897
  pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
703
898
  }
704
- // Render transparent meshes with per-mesh bind groups
705
- for (const mesh of transparentMeshes) {
706
- if (mesh.bindGroup) {
707
- pass.setBindGroup(0, mesh.bindGroup);
708
- }
709
- else {
710
- pass.setBindGroup(0, this.pipeline.getBindGroup());
899
+ // Render transparent meshes with transparent pipeline (alpha blending)
900
+ if (transparentMeshes.length > 0) {
901
+ pass.setPipeline(this.pipeline.getTransparentPipeline());
902
+ for (const mesh of transparentMeshes) {
903
+ if (mesh.bindGroup) {
904
+ pass.setBindGroup(0, mesh.bindGroup);
905
+ }
906
+ else {
907
+ pass.setBindGroup(0, this.pipeline.getBindGroup());
908
+ }
909
+ pass.setVertexBuffer(0, mesh.vertexBuffer);
910
+ pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
911
+ pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
711
912
  }
712
- pass.setVertexBuffer(0, mesh.vertexBuffer);
713
- pass.setIndexBuffer(mesh.indexBuffer, 'uint32');
714
- pass.drawIndexed(mesh.indexCount, 1, 0, 0, 0);
715
913
  }
716
914
  }
717
915
  // Render instanced meshes (much more efficient for repeated geometry)
@@ -735,16 +933,20 @@ export class Renderer {
735
933
  }
736
934
  }
737
935
  }
738
- pass.end();
739
- // Render section plane visual if enabled
740
- if (sectionPlaneData?.enabled && this.sectionPlaneRenderer && this.modelBounds) {
741
- this.sectionPlaneRenderer.render(encoder, textureView, this.pipeline.getDepthTextureView(), {
936
+ // Draw section plane visual BEFORE pass.end() (within same MSAA render pass)
937
+ // Always show plane when sectionPlane options are provided (as preview or active)
938
+ if (options.sectionPlane && this.sectionPlaneRenderer && this.modelBounds) {
939
+ this.sectionPlaneRenderer.draw(pass, {
742
940
  axis: options.sectionPlane.axis,
743
941
  position: options.sectionPlane.position,
744
942
  bounds: this.modelBounds,
745
943
  viewProj,
944
+ isPreview: !options.sectionPlane.enabled, // Preview mode when not enabled
945
+ min: options.sectionPlane.min,
946
+ max: options.sectionPlane.max,
746
947
  });
747
948
  }
949
+ pass.end();
748
950
  device.queue.submit([encoder.finish()]);
749
951
  }
750
952
  catch (error) {
@@ -759,6 +961,7 @@ export class Renderer {
759
961
  }
760
962
  /**
761
963
  * Pick object at screen coordinates
964
+ * Respects visibility filtering so users can only select visible elements
762
965
  */
763
966
  async pick(x, y, options) {
764
967
  if (!this.picker) {
@@ -770,38 +973,297 @@ export class Renderer {
770
973
  return null;
771
974
  }
772
975
  let meshes = this.scene.getMeshes();
773
- // If we have batched meshes but no regular meshes, create picking meshes from stored MeshData
774
- // This implements lazy loading for picking - meshes are created on-demand from MeshData
775
- if (meshes.length === 0) {
776
- const batchedMeshes = this.scene.getBatchedMeshes();
777
- if (batchedMeshes.length > 0) {
778
- // Collect all expressIds from batched meshes
779
- const expressIds = new Set();
780
- for (const batch of batchedMeshes) {
781
- for (const expressId of batch.expressIds) {
782
- expressIds.add(expressId);
783
- }
976
+ const batchedMeshes = this.scene.getBatchedMeshes();
977
+ // If we have batched meshes, check if we need CPU raycasting
978
+ // This handles the case where we have SOME individual meshes (e.g., from highlighting)
979
+ // but not enough for full GPU picking coverage
980
+ if (batchedMeshes.length > 0) {
981
+ // Collect all expressIds from batched meshes
982
+ const expressIds = new Set();
983
+ for (const batch of batchedMeshes) {
984
+ for (const expressId of batch.expressIds) {
985
+ expressIds.add(expressId);
784
986
  }
785
- // Track existing expressIds to avoid duplicates (using Set for O(1) lookup)
786
- const existingExpressIds = new Set(meshes.map(m => m.expressId));
787
- // Create picking meshes lazily from stored MeshData
788
- for (const expressId of expressIds) {
789
- if (!existingExpressIds.has(expressId) && this.scene.hasMeshData(expressId)) {
790
- const meshData = this.scene.getMeshData(expressId);
791
- if (meshData) {
792
- this.createMeshFromData(meshData);
793
- existingExpressIds.add(expressId); // Track newly created mesh
794
- }
987
+ }
988
+ // Track existing expressIds to avoid duplicates (using Set for O(1) lookup)
989
+ const existingExpressIds = new Set(meshes.map(m => m.expressId));
990
+ // Count how many meshes we'd need to create for full GPU picking
991
+ let toCreate = 0;
992
+ for (const expressId of expressIds) {
993
+ if (existingExpressIds.has(expressId))
994
+ continue;
995
+ if (options?.hiddenIds?.has(expressId))
996
+ continue;
997
+ if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
998
+ continue;
999
+ if (this.scene.hasMeshData(expressId))
1000
+ toCreate++;
1001
+ }
1002
+ // PERFORMANCE FIX: Use CPU raycasting for large models instead of creating GPU meshes
1003
+ // GPU picking requires individual mesh buffers; for 60K+ elements this is too slow
1004
+ // CPU raycasting uses bounding box filtering + triangle tests - no GPU buffers needed
1005
+ const MAX_PICK_MESH_CREATION = 500;
1006
+ if (toCreate > MAX_PICK_MESH_CREATION) {
1007
+ // Use CPU raycasting fallback - works regardless of how many individual meshes exist
1008
+ const ray = this.camera.unprojectToRay(x, y, this.canvas.width, this.canvas.height);
1009
+ const hit = this.scene.raycast(ray.origin, ray.direction, options?.hiddenIds, options?.isolatedIds);
1010
+ return hit ? hit.expressId : null;
1011
+ }
1012
+ // For smaller models, create GPU meshes for picking
1013
+ // Only create meshes for VISIBLE elements (not hidden, and either no isolation or in isolated set)
1014
+ for (const expressId of expressIds) {
1015
+ // Skip if already exists
1016
+ if (existingExpressIds.has(expressId))
1017
+ continue;
1018
+ // Skip if hidden
1019
+ if (options?.hiddenIds?.has(expressId))
1020
+ continue;
1021
+ // Skip if isolation is active and this entity is not isolated
1022
+ if (options?.isolatedIds !== null && options?.isolatedIds !== undefined && !options.isolatedIds.has(expressId))
1023
+ continue;
1024
+ if (this.scene.hasMeshData(expressId)) {
1025
+ const meshData = this.scene.getMeshData(expressId);
1026
+ if (meshData) {
1027
+ this.createMeshFromData(meshData);
1028
+ existingExpressIds.add(expressId); // Track newly created mesh
795
1029
  }
796
1030
  }
797
- // Get updated meshes list (includes newly created ones)
798
- meshes = this.scene.getMeshes();
799
1031
  }
1032
+ // Get updated meshes list (includes newly created ones)
1033
+ meshes = this.scene.getMeshes();
1034
+ }
1035
+ // Apply visibility filtering to meshes before picking
1036
+ // This ensures users can only select elements that are actually visible
1037
+ if (options?.hiddenIds && options.hiddenIds.size > 0) {
1038
+ meshes = meshes.filter(mesh => !options.hiddenIds.has(mesh.expressId));
1039
+ }
1040
+ if (options?.isolatedIds !== null && options?.isolatedIds !== undefined) {
1041
+ meshes = meshes.filter(mesh => options.isolatedIds.has(mesh.expressId));
800
1042
  }
801
1043
  const viewProj = this.camera.getViewProjMatrix().m;
802
1044
  const result = await this.picker.pick(x, y, this.canvas.width, this.canvas.height, meshes, viewProj);
803
1045
  return result;
804
1046
  }
1047
+ /**
1048
+ * Raycast into the scene to get precise 3D intersection point
1049
+ * This is more accurate than pick() as it returns the exact surface point
1050
+ */
1051
+ raycastScene(x, y, options) {
1052
+ try {
1053
+ // Create ray from screen coordinates
1054
+ const ray = this.camera.unprojectToRay(x, y, this.canvas.width, this.canvas.height);
1055
+ // Get all mesh data from scene
1056
+ const allMeshData = [];
1057
+ const meshes = this.scene.getMeshes();
1058
+ const batchedMeshes = this.scene.getBatchedMeshes();
1059
+ // Collect mesh data from regular meshes
1060
+ for (const mesh of meshes) {
1061
+ const meshData = this.scene.getMeshData(mesh.expressId);
1062
+ if (meshData) {
1063
+ // Apply visibility filtering
1064
+ if (options?.hiddenIds?.has(meshData.expressId))
1065
+ continue;
1066
+ if (options?.isolatedIds !== null &&
1067
+ options?.isolatedIds !== undefined &&
1068
+ !options.isolatedIds.has(meshData.expressId)) {
1069
+ continue;
1070
+ }
1071
+ allMeshData.push(meshData);
1072
+ }
1073
+ }
1074
+ // Collect mesh data from batched meshes
1075
+ for (const batch of batchedMeshes) {
1076
+ for (const expressId of batch.expressIds) {
1077
+ const meshData = this.scene.getMeshData(expressId);
1078
+ if (meshData) {
1079
+ // Apply visibility filtering
1080
+ if (options?.hiddenIds?.has(meshData.expressId))
1081
+ continue;
1082
+ if (options?.isolatedIds !== null &&
1083
+ options?.isolatedIds !== undefined &&
1084
+ !options.isolatedIds.has(meshData.expressId)) {
1085
+ continue;
1086
+ }
1087
+ allMeshData.push(meshData);
1088
+ }
1089
+ }
1090
+ }
1091
+ if (allMeshData.length === 0) {
1092
+ return null;
1093
+ }
1094
+ // Use BVH for performance if we have many meshes
1095
+ let meshesToTest = allMeshData;
1096
+ if (allMeshData.length > this.BVH_THRESHOLD) {
1097
+ // Check if BVH needs rebuilding
1098
+ const needsRebuild = !this.bvhCache ||
1099
+ !this.bvhCache.isBuilt ||
1100
+ this.bvhCache.meshCount !== allMeshData.length;
1101
+ if (needsRebuild) {
1102
+ // Build BVH only when needed
1103
+ this.bvh.build(allMeshData);
1104
+ this.bvhCache = {
1105
+ meshCount: allMeshData.length,
1106
+ meshData: allMeshData,
1107
+ isBuilt: true,
1108
+ };
1109
+ }
1110
+ // Use BVH to filter meshes
1111
+ const meshIndices = this.bvh.getMeshesForRay(ray, allMeshData);
1112
+ meshesToTest = meshIndices.map(i => allMeshData[i]);
1113
+ }
1114
+ // Perform raycasting
1115
+ const intersection = this.raycaster.raycast(ray, meshesToTest);
1116
+ if (!intersection) {
1117
+ return null;
1118
+ }
1119
+ // Detect snap targets if requested
1120
+ // Pass meshes near the ray to detect edges even when partially occluded
1121
+ let snapTarget;
1122
+ if (options?.snapOptions) {
1123
+ const cameraPos = this.camera.getPosition();
1124
+ const cameraFov = this.camera.getFOV();
1125
+ // Pass meshes that are near the ray (from BVH or all meshes if BVH not used)
1126
+ // This allows detecting edges even when they're behind other objects
1127
+ snapTarget = this.snapDetector.detectSnapTarget(ray, meshesToTest, // Pass all meshes near the ray
1128
+ intersection, { position: cameraPos, fov: cameraFov }, this.canvas.height, options.snapOptions) || undefined;
1129
+ }
1130
+ return {
1131
+ intersection,
1132
+ snap: snapTarget,
1133
+ };
1134
+ }
1135
+ catch (error) {
1136
+ console.error('Raycast error:', error);
1137
+ return null;
1138
+ }
1139
+ }
1140
+ /**
1141
+ * Raycast with magnetic edge snapping behavior
1142
+ * This provides the "stick and slide along edges" experience
1143
+ */
1144
+ raycastSceneMagnetic(x, y, currentEdgeLock, options) {
1145
+ try {
1146
+ // Create ray from screen coordinates
1147
+ const ray = this.camera.unprojectToRay(x, y, this.canvas.width, this.canvas.height);
1148
+ // Get all mesh data from scene
1149
+ const allMeshData = [];
1150
+ const meshes = this.scene.getMeshes();
1151
+ const batchedMeshes = this.scene.getBatchedMeshes();
1152
+ // Collect mesh data from regular meshes
1153
+ for (const mesh of meshes) {
1154
+ const meshData = this.scene.getMeshData(mesh.expressId);
1155
+ if (meshData) {
1156
+ if (options?.hiddenIds?.has(meshData.expressId))
1157
+ continue;
1158
+ if (options?.isolatedIds !== null &&
1159
+ options?.isolatedIds !== undefined &&
1160
+ !options.isolatedIds.has(meshData.expressId)) {
1161
+ continue;
1162
+ }
1163
+ allMeshData.push(meshData);
1164
+ }
1165
+ }
1166
+ // Collect mesh data from batched meshes
1167
+ for (const batch of batchedMeshes) {
1168
+ for (const expressId of batch.expressIds) {
1169
+ const meshData = this.scene.getMeshData(expressId);
1170
+ if (meshData) {
1171
+ if (options?.hiddenIds?.has(meshData.expressId))
1172
+ continue;
1173
+ if (options?.isolatedIds !== null &&
1174
+ options?.isolatedIds !== undefined &&
1175
+ !options.isolatedIds.has(meshData.expressId)) {
1176
+ continue;
1177
+ }
1178
+ allMeshData.push(meshData);
1179
+ }
1180
+ }
1181
+ }
1182
+ if (allMeshData.length === 0) {
1183
+ return {
1184
+ intersection: null,
1185
+ snapTarget: null,
1186
+ edgeLock: {
1187
+ edge: null,
1188
+ meshExpressId: null,
1189
+ edgeT: 0,
1190
+ shouldLock: false,
1191
+ shouldRelease: true,
1192
+ isCorner: false,
1193
+ cornerValence: 0,
1194
+ },
1195
+ };
1196
+ }
1197
+ // Use BVH for performance if we have many meshes
1198
+ let meshesToTest = allMeshData;
1199
+ if (allMeshData.length > this.BVH_THRESHOLD) {
1200
+ const needsRebuild = !this.bvhCache ||
1201
+ !this.bvhCache.isBuilt ||
1202
+ this.bvhCache.meshCount !== allMeshData.length;
1203
+ if (needsRebuild) {
1204
+ this.bvh.build(allMeshData);
1205
+ this.bvhCache = {
1206
+ meshCount: allMeshData.length,
1207
+ meshData: allMeshData,
1208
+ isBuilt: true,
1209
+ };
1210
+ }
1211
+ const meshIndices = this.bvh.getMeshesForRay(ray, allMeshData);
1212
+ meshesToTest = meshIndices.map(i => allMeshData[i]);
1213
+ }
1214
+ // Perform raycasting
1215
+ const intersection = this.raycaster.raycast(ray, meshesToTest);
1216
+ // Use magnetic snap detection
1217
+ const cameraPos = this.camera.getPosition();
1218
+ const cameraFov = this.camera.getFOV();
1219
+ const magneticResult = this.snapDetector.detectMagneticSnap(ray, meshesToTest, intersection, { position: cameraPos, fov: cameraFov }, this.canvas.height, currentEdgeLock, options?.snapOptions || {});
1220
+ return {
1221
+ intersection,
1222
+ ...magneticResult,
1223
+ };
1224
+ }
1225
+ catch (error) {
1226
+ console.error('Magnetic raycast error:', error);
1227
+ return {
1228
+ intersection: null,
1229
+ snapTarget: null,
1230
+ edgeLock: {
1231
+ edge: null,
1232
+ meshExpressId: null,
1233
+ edgeT: 0,
1234
+ shouldLock: false,
1235
+ shouldRelease: true,
1236
+ isCorner: false,
1237
+ cornerValence: 0,
1238
+ },
1239
+ };
1240
+ }
1241
+ }
1242
+ /**
1243
+ * Invalidate BVH cache (call when geometry changes)
1244
+ */
1245
+ invalidateBVHCache() {
1246
+ this.bvhCache = null;
1247
+ }
1248
+ /**
1249
+ * Get the raycaster instance (for advanced usage)
1250
+ */
1251
+ getRaycaster() {
1252
+ return this.raycaster;
1253
+ }
1254
+ /**
1255
+ * Get the snap detector instance (for advanced usage)
1256
+ */
1257
+ getSnapDetector() {
1258
+ return this.snapDetector;
1259
+ }
1260
+ /**
1261
+ * Clear all caches (call when geometry changes)
1262
+ */
1263
+ clearCaches() {
1264
+ this.invalidateBVHCache();
1265
+ this.snapDetector.clearCache();
1266
+ }
805
1267
  /**
806
1268
  * Resize canvas
807
1269
  */