From 860ef87e9ae7ea511f79f78063bde380080c9a95 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 20 Jan 2026 11:19:12 -0500 Subject: [PATCH 01/94] adapting camera to new ultra --- package.json | 2 +- src/vim-web/core-viewers/ultra/inputAdapter.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index eb42dd7a5..438d16984 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vim-web", - "version": "0.5.0-dev.24", + "version": "0.5.1", "description": "A demonstration app built on top of the vim-webgl-viewer", "type": "module", "files": [ diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index 48920b67c..5b5a7ef3a 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -40,7 +40,7 @@ export function ultraInputAdapter(viewer: Viewer) { function createAdapter(viewer: Viewer): IInputAdapter { return { init: () => { - viewer.rpc.RPCSetCameraSpeed(10); + // No initialization needed }, orbitCamera: (value: THREE.Vector2) => { // handled server side From 4301fa757fb99bc156ce3b4ce3f8fbd152eead28 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 2 Feb 2026 11:45:45 -0500 Subject: [PATCH 02/94] checkpoint, screenshot works --- src/main.tsx | 82 ++++++++- src/vim-web/core-viewers/shared/raycaster.ts | 8 + .../core-viewers/webgl/viewer/raycaster.ts | 39 ++++ .../webgl/viewer/rendering/depthPicker.ts | 173 ++++++++++++++++++ .../webgl/viewer/rendering/depthRenderer.ts | 111 +++++++++++ .../webgl/viewer/rendering/renderer.ts | 34 ++++ .../viewer/rendering/renderingSection.ts | 7 + .../core-viewers/webgl/viewer/viewer.ts | 7 + 8 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 src/vim-web/core-viewers/webgl/viewer/rendering/depthPicker.ts create mode 100644 src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts diff --git a/src/main.tsx b/src/main.tsx index 11310a6de..b687d9971 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -46,19 +46,95 @@ function App() { } async function createWebgl (viewerRef: MutableRefObject, div: HTMLDivElement) { - const viewer = await VIM.React.Webgl.createViewer(div) + const viewer = await VIM.React.Webgl.createViewer(div, {ui: { + panelBimInfo: false, + panelPerformance: false, + panelAxes: false, + panelBimTree: false, + panelControlBar: false, + panelLogo: true, + }}) + viewerRef.current = viewer globalThis.viewer = viewer // for testing in browser console const url = getPathFromUrl() ?? 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' const request = viewer.loader.request( - { url }, + { url }, ) const result = await request.getResult() if (result.isSuccess()) { viewer.loader.add(result.result) viewer.camera.frameScene.call() } + + // Add keyboard shortcut for depth picking test + addDepthPickerTest(viewer, div) +} + +function addDepthPickerTest(viewer: VIM.React.Webgl.ViewerRef, container: HTMLDivElement) { + // Track mouse position + let mousePos = new VIM.THREE.Vector2(0.5, 0.5) + + container.addEventListener('mousemove', (e) => { + const rect = container.getBoundingClientRect() + mousePos.x = (e.clientX - rect.left) / rect.width + mousePos.y = (e.clientY - rect.top) / rect.height + }) + + // Store created spheres for cleanup + const spheres: VIM.THREE.Mesh[] = [] + + // Show instructions + const instructions = document.createElement('div') + instructions.textContent = 'Press T to test depth pick, C to clear spheres, X to save depth image' + instructions.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1000;padding:8px 16px;background:rgba(0,0,0,0.7);color:white;font-family:monospace;' + container.appendChild(instructions) + + // Keyboard handler for T and C (keydown for responsiveness) + window.addEventListener('keydown', (e) => { + if (e.key === 't' || e.key === 'T') { + // Call the new GPU raycast API + const worldPos = viewer.core.raycaster.raycastWorldPosition?.(mousePos) + + if (worldPos) { + console.log('Depth pick hit:', worldPos) + + // Create a small sphere at the hit position + const geometry = new VIM.THREE.SphereGeometry(0.1, 16, 16) + const material = new VIM.THREE.MeshBasicMaterial({ color: 0xff0000 }) + const sphere = new VIM.THREE.Mesh(geometry, material) + sphere.position.copy(worldPos) + + viewer.core.renderer.add(sphere) + spheres.push(sphere) + + // Request render update + viewer.core.renderer.needsUpdate = true + } else { + console.log('Depth pick miss - no geometry at position') + } + } + + if (e.key === 'c' || e.key === 'C') { + spheres.forEach(s => { + viewer.core.renderer.remove(s) + s.geometry.dispose() + ;(s.material as VIM.THREE.MeshBasicMaterial).dispose() + }) + spheres.length = 0 + viewer.core.renderer.needsUpdate = true + console.log('Spheres cleared') + } + }) + + // X key on keyup to only fire once (not on repeat) + window.addEventListener('keyup', (e) => { + if (e.key === 'x' || e.key === 'X') { + // Test depth render - downloads depth buffer as PNG + viewer.core.renderer.testDepthRender() + } + }) } async function createUltra (viewerRef: MutableRefObject, div: HTMLDivElement) { @@ -74,6 +150,8 @@ async function createUltra (viewerRef: MutableRefObject, div: HTMLDiv const result = await request.getResult() if (result.isSuccess) { viewer.camera.frameScene.call() + var object = result.vim.getElementFromIndex(0); + object.state } } diff --git a/src/vim-web/core-viewers/shared/raycaster.ts b/src/vim-web/core-viewers/shared/raycaster.ts index ffaca3b1f..5ebbe45f8 100644 --- a/src/vim-web/core-viewers/shared/raycaster.ts +++ b/src/vim-web/core-viewers/shared/raycaster.ts @@ -28,6 +28,14 @@ export interface IRaycaster { * @returns A promise that resolves to the raycast result. */ raycastFromWorld(position: THREE.Vector3): Promise>; + + /** + * GPU-based raycast that returns only the world position of the first hit. + * Optimized for camera operations where object identification is not needed. + * @param position - Screen position in 0-1 range (0,0 is top-left) + * @returns World position of the first hit, or undefined if no geometry at position + */ + raycastWorldPosition?(position: THREE.Vector2): THREE.Vector3 | undefined; } diff --git a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts index eb921e80a..aeb14ff1e 100644 --- a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts +++ b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts @@ -10,6 +10,7 @@ import { Camera } from './camera/camera' import { Renderer } from './rendering/renderer' import { Marker } from './gizmos/markers/gizmoMarker' import { GizmoMarkers } from './gizmos/markers/gizmoMarkers' +import { DepthPicker } from './rendering/depthPicker' import type { IRaycaster as IRaycasterBase, IRaycastResult as IRaycastResultBase, @@ -58,6 +59,7 @@ export class Raycaster implements IRaycaster { private _camera: Camera private _scene: RenderScene private _renderer: Renderer + private _depthPicker: DepthPicker private _raycaster = new THREE.Raycaster() @@ -65,6 +67,43 @@ export class Raycaster implements IRaycaster { this._camera = camera this._scene = scene this._renderer = renderer + + // Initialize depth picker for GPU-based world position queries + const size = renderer.renderer.getSize(new THREE.Vector2()) + this._depthPicker = new DepthPicker( + renderer.renderer, + camera, + scene, + renderer.section, + size.x || 1, + size.y || 1 + ) + } + + /** + * Updates the depth picker render target size. + * Called when the viewport is resized. + */ + setSize(width: number, height: number): void { + this._depthPicker.setSize(width, height) + } + + /** + * GPU-based raycast that returns only the world position of the first hit. + * Optimized for camera operations where object identification is not needed. + * @param position Screen position in 0-1 range (0,0 is top-left) + * @returns World position of the first hit, or undefined if no geometry at position + */ + raycastWorldPosition(position: THREE.Vector2): THREE.Vector3 | undefined { + if (!Validation.isRelativeVector2(position)) return undefined + return this._depthPicker.pick(position) + } + + /** + * Disposes of resources used by the raycaster. + */ + dispose(): void { + this._depthPicker.dispose() } /** diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/depthPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/depthPicker.ts new file mode 100644 index 000000000..dee44d644 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/depthPicker.ts @@ -0,0 +1,173 @@ +/** + * @module viw-webgl-viewer/rendering + */ + +import * as THREE from 'three' +import { Camera } from '../camera/camera' +import { RenderScene } from './renderScene' +import { RenderingSection } from './renderingSection' + +/** + * Custom shader material that outputs world position as RGB color. + */ +class WorldPositionMaterial extends THREE.ShaderMaterial { + constructor() { + super({ + vertexShader: ` + varying vec3 vWorldPosition; + void main() { + vec4 worldPos = modelMatrix * vec4(position, 1.0); + vWorldPosition = worldPos.xyz; + gl_Position = projectionMatrix * viewMatrix * worldPos; + } + `, + fragmentShader: ` + varying vec3 vWorldPosition; + void main() { + // Encode world position - we'll use a scale/offset to fit in 0-1 range + // Using a large range (-1000 to 1000) mapped to (0-1) + vec3 encoded = (vWorldPosition + 1000.0) / 2000.0; + gl_FragColor = vec4(encoded, 1.0); + } + `, + side: THREE.DoubleSide + }) + } +} + +/** + * GPU-based depth picker for world position queries. + * Renders world position to a texture and samples it at given screen coordinates + * to return the world position of the first hit. + * + * This is optimized for camera operations (orbit target, etc.) + * where only world position is needed, not object identification. + */ +export class DepthPicker { + private _renderer: THREE.WebGLRenderer + private _camera: Camera + private _scene: RenderScene + private _section: RenderingSection + + private _renderTarget: THREE.WebGLRenderTarget + private _worldPosMaterial: WorldPositionMaterial + private _readBuffer: Float32Array + + // Encoding range for world position + private static readonly RANGE = 2000.0 + private static readonly OFFSET = 1000.0 + + constructor( + renderer: THREE.WebGLRenderer, + camera: Camera, + scene: RenderScene, + section: RenderingSection, + width: number, + height: number + ) { + this._renderer = renderer + this._camera = camera + this._scene = scene + this._section = section + + // Create render target with float type for better precision + this._renderTarget = new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.NearestFilter, + magFilter: THREE.NearestFilter, + format: THREE.RGBAFormat, + type: THREE.FloatType, + depthBuffer: true + }) + + // Material that outputs world position + this._worldPosMaterial = new WorldPositionMaterial() + + // Buffer for reading single pixel (RGBA float = 4 floats) + this._readBuffer = new Float32Array(4) + } + + /** + * Updates the render target size to match viewport. + */ + setSize(width: number, height: number): void { + this._renderTarget.setSize(width, height) + } + + /** + * Picks the world position at the given screen coordinates. + * @param screenPos Screen position in 0-1 range (0,0 is top-left) + * @returns World position of the first hit, or undefined if no hit + */ + pick(screenPos: THREE.Vector2): THREE.Vector3 | undefined { + const camera = this._camera.three + + // Store current state + const currentRenderTarget = this._renderer.getRenderTarget() + const currentOverrideMaterial = this._scene.threeScene.overrideMaterial + const currentBackground = this._scene.threeScene.background + + // Apply section box clipping if active + if (this._section.active) { + this._worldPosMaterial.clippingPlanes = this._section.clippingPlanes + } else { + this._worldPosMaterial.clippingPlanes = [] + } + + // Set background to black (will read as 0,0,0 for miss detection) + this._scene.threeScene.background = null + + // Override scene materials with world position material + this._scene.threeScene.overrideMaterial = this._worldPosMaterial + + // Render to target + this._renderer.setRenderTarget(this._renderTarget) + this._renderer.setClearColor(0x000000, 0) + this._renderer.clear() + this._renderer.render(this._scene.threeScene, camera) + + // Restore state + this._renderer.setRenderTarget(currentRenderTarget) + this._scene.threeScene.overrideMaterial = currentOverrideMaterial + this._scene.threeScene.background = currentBackground + + // Calculate pixel position (flip Y for WebGL coordinate system) + const pixelX = Math.floor(screenPos.x * this._renderTarget.width) + const pixelY = Math.floor((1 - screenPos.y) * this._renderTarget.height) + + // Read single pixel + this._renderer.readRenderTargetPixels( + this._renderTarget, + pixelX, + pixelY, + 1, + 1, + this._readBuffer + ) + + console.log('Depth pick - RGBA:', this._readBuffer[0], this._readBuffer[1], this._readBuffer[2], this._readBuffer[3]) + + // Check if hit (alpha = 0 means background/no hit) + if (this._readBuffer[3] === 0) { + console.log('Depth pick - miss (alpha=0)') + return undefined + } + + // Decode world position from encoded RGB + const worldPos = new THREE.Vector3( + this._readBuffer[0] * DepthPicker.RANGE - DepthPicker.OFFSET, + this._readBuffer[1] * DepthPicker.RANGE - DepthPicker.OFFSET, + this._readBuffer[2] * DepthPicker.RANGE - DepthPicker.OFFSET + ) + + console.log('Depth pick - worldPos:', worldPos.x, worldPos.y, worldPos.z) + return worldPos + } + + /** + * Disposes of all resources. + */ + dispose(): void { + this._renderTarget.dispose() + this._worldPosMaterial.dispose() + } +} diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts new file mode 100644 index 000000000..0f15151a8 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts @@ -0,0 +1,111 @@ +/** + * @module viw-webgl-viewer/rendering + */ + +import * as THREE from 'three' +import { Camera } from '../camera/camera' +import { RenderScene } from './renderScene' + +/** + * Renders the scene to a texture and exports it as a PNG image. + */ +export class DepthRenderer { + private _renderer: THREE.WebGLRenderer + private _camera: Camera + private _scene: RenderScene + private _renderTarget: THREE.WebGLRenderTarget + + constructor( + renderer: THREE.WebGLRenderer, + camera: Camera, + scene: RenderScene, + width: number, + height: number + ) { + this._renderer = renderer + this._camera = camera + this._scene = scene + this._renderTarget = new THREE.WebGLRenderTarget(width, height, { + format: THREE.RGBAFormat, + type: THREE.UnsignedByteType + }) + } + + /** + * Updates the render target size to match viewport. + */ + setSize(width: number, height: number): void { + this._renderTarget.setSize(width, height) + } + + /** + * Renders the scene and saves it as a PNG file. + */ + renderAndSave(): void { + const currentTarget = this._renderer.getRenderTarget() + + this._renderer.setRenderTarget(this._renderTarget) + this._renderer.render(this._scene.threeScene, this._camera.three) + this._renderer.setRenderTarget(currentTarget) + + this.saveToFile() + } + + /** + * Reads the render target pixels and saves them as a PNG image. + */ + private saveToFile(): void { + const width = this._renderTarget.width + const height = this._renderTarget.height + const buffer = new Uint8Array(width * height * 4) + + this._renderer.readRenderTargetPixels( + this._renderTarget, + 0, + 0, + width, + height, + buffer + ) + + // Create canvas and flip vertically + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) { + console.error('Failed to get 2D context for export') + return + } + + const imageData = ctx.createImageData(width, height) + + // Copy pixels with vertical flip (WebGL renders upside down) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const src = (y * width + x) * 4 + const dst = ((height - 1 - y) * width + x) * 4 + imageData.data[dst] = buffer[src] + imageData.data[dst + 1] = buffer[src + 1] + imageData.data[dst + 2] = buffer[src + 2] + imageData.data[dst + 3] = 255 + } + } + + ctx.putImageData(imageData, 0, 0) + + const link = document.createElement('a') + link.download = `screenshot-${Date.now()}.png` + link.href = canvas.toDataURL('image/png') + link.click() + + console.log('Screenshot saved:', link.download) + } + + /** + * Disposes of all resources. + */ + dispose(): void { + this._renderTarget.dispose() + } +} diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts index edb2d77ce..39fa1b54f 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts @@ -14,6 +14,7 @@ import { RenderingSection } from './renderingSection' import { RenderingComposer } from './renderingComposer' import { ViewerSettings } from '../settings/viewerSettings' import { SignalDispatcher } from 'ste-signals' +import { DepthRenderer } from './depthRenderer' /** * Manages how vim objects are added and removed from the THREE.Scene to be rendered @@ -55,6 +56,7 @@ export class Renderer implements IRenderer { // 3GB private maxMemory = 3 * Math.pow(10, 9) private _outlineCount = 0 + private _depthRenderer: DepthRenderer | undefined /** @@ -132,6 +134,7 @@ export class Renderer implements IRenderer { this.renderer.forceContextLoss() this.renderer.dispose() this._composer.dispose() + this._depthRenderer?.dispose() } /** @@ -260,6 +263,37 @@ export class Renderer implements IRenderer { this._scene.clearUpdateFlags() } + /** + * Renders the scene depth to a PNG image and triggers download. + * This is a one-off test/debug feature. + */ + testDepthRender(): void { + // Get size from the viewport (more reliable than renderer.getSize during init) + const size = this._viewport.getParentSize() + + if (size.x === 0 || size.y === 0) { + console.error('Cannot render depth: viewport has zero size') + return + } + + // Lazily create depth renderer + if (!this._depthRenderer) { + this._depthRenderer = new DepthRenderer( + this.renderer, + this._camera, + this._scene, + size.x, + size.y + ) + } + + // Ensure size is current + this._depthRenderer.setSize(size.x, size.y) + + // Render and save + this._depthRenderer.renderAndSave() + } + /** * Adds an object to be rendered. * @param target The object or scene to add for rendering. diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderingSection.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderingSection.ts index b9727797d..484a32790 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderingSection.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderingSection.ts @@ -71,4 +71,11 @@ export class RenderingSection { get active () { return this._active } + + /** + * Returns the clipping planes used for section box culling. + */ + get clippingPlanes (): THREE.Plane[] { + return this.planes + } } diff --git a/src/vim-web/core-viewers/webgl/viewer/viewer.ts b/src/vim-web/core-viewers/webgl/viewer/viewer.ts index cf20a4c42..ea6775cb7 100644 --- a/src/vim-web/core-viewers/webgl/viewer/viewer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/viewer.ts @@ -130,6 +130,12 @@ export class Viewer { this.renderer ) + // Update raycaster size on viewport resize + this.viewport.onResize.sub(() => { + const size = this.viewport.getParentSize() + ;(this.raycaster as Raycaster).setSize(size.x, size.y) + }) + this.inputs.init() // Start Loop @@ -216,6 +222,7 @@ export class Viewer { this.selection.clear() this.viewport.dispose() this.renderer.dispose() + ;(this.raycaster as Raycaster).dispose() this.inputs.unregisterAll() this._vims.forEach((v) => v?.dispose()) this.materials.dispose() From 3bc38da17439107e926ff9b62fa8ae51ddfb14df Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 2 Feb 2026 11:55:21 -0500 Subject: [PATCH 03/94] red geometry --- .../webgl/viewer/rendering/depthRenderer.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts index 0f15151a8..fb8d509e7 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts @@ -14,6 +14,7 @@ export class DepthRenderer { private _camera: Camera private _scene: RenderScene private _renderTarget: THREE.WebGLRenderTarget + private _redMaterial: THREE.MeshBasicMaterial constructor( renderer: THREE.WebGLRenderer, @@ -29,6 +30,7 @@ export class DepthRenderer { format: THREE.RGBAFormat, type: THREE.UnsignedByteType }) + this._redMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }) } /** @@ -42,12 +44,29 @@ export class DepthRenderer { * Renders the scene and saves it as a PNG file. */ renderAndSave(): void { + const camera = this._camera.three const currentTarget = this._renderer.getRenderTarget() + const currentOverrideMaterial = this._scene.threeScene.overrideMaterial + const currentBackground = this._scene.threeScene.background + + // Apply red material to entire scene + this._scene.threeScene.overrideMaterial = this._redMaterial + this._scene.threeScene.background = null + + // Disable layer 1 (NoRaycast) to hide skybox and gizmos + camera.layers.disable(1) this._renderer.setRenderTarget(this._renderTarget) - this._renderer.render(this._scene.threeScene, this._camera.three) + this._renderer.setClearColor(0x000000, 1) // Black background + this._renderer.clear() + this._renderer.render(this._scene.threeScene, camera) this._renderer.setRenderTarget(currentTarget) + // Restore original state + camera.layers.enable(1) + this._scene.threeScene.overrideMaterial = currentOverrideMaterial + this._scene.threeScene.background = currentBackground + this.saveToFile() } @@ -107,5 +126,6 @@ export class DepthRenderer { */ dispose(): void { this._renderTarget.dispose() + this._redMaterial.dispose() } } From 6e8403d975994bb0da8d8482d06edd87c545d28f Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 2 Feb 2026 12:59:41 -0500 Subject: [PATCH 04/94] position shader --- .../webgl/viewer/rendering/depthRenderer.ts | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts index fb8d509e7..ee683a19c 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts @@ -6,6 +6,47 @@ import * as THREE from 'three' import { Camera } from '../camera/camera' import { RenderScene } from './renderScene' +/** + * Shader material that outputs world position as RGB. + * Position is normalized to 0-1 based on scene bounding box. + */ +class WorldPositionMaterial extends THREE.ShaderMaterial { + constructor() { + super({ + uniforms: { + uBoundsMin: { value: new THREE.Vector3() }, + uBoundsMax: { value: new THREE.Vector3() } + }, + vertexShader: ` + varying vec3 vWorldPos; + + void main() { + vec4 worldPos = modelMatrix * vec4(position, 1.0); + vWorldPos = worldPos.xyz; + gl_Position = projectionMatrix * viewMatrix * worldPos; + } + `, + fragmentShader: ` + varying vec3 vWorldPos; + uniform vec3 uBoundsMin; + uniform vec3 uBoundsMax; + + void main() { + // Normalize world position to 0-1 based on bounding box + vec3 normalized = (vWorldPos - uBoundsMin) / (uBoundsMax - uBoundsMin); + gl_FragColor = vec4(normalized, 1.0); + } + `, + side: THREE.DoubleSide + }) + } + + updateBounds(min: THREE.Vector3, max: THREE.Vector3): void { + this.uniforms.uBoundsMin.value.copy(min) + this.uniforms.uBoundsMax.value.copy(max) + } +} + /** * Renders the scene to a texture and exports it as a PNG image. */ @@ -14,7 +55,7 @@ export class DepthRenderer { private _camera: Camera private _scene: RenderScene private _renderTarget: THREE.WebGLRenderTarget - private _redMaterial: THREE.MeshBasicMaterial + private _worldPosMaterial: WorldPositionMaterial constructor( renderer: THREE.WebGLRenderer, @@ -30,7 +71,7 @@ export class DepthRenderer { format: THREE.RGBAFormat, type: THREE.UnsignedByteType }) - this._redMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }) + this._worldPosMaterial = new WorldPositionMaterial() } /** @@ -49,8 +90,14 @@ export class DepthRenderer { const currentOverrideMaterial = this._scene.threeScene.overrideMaterial const currentBackground = this._scene.threeScene.background - // Apply red material to entire scene - this._scene.threeScene.overrideMaterial = this._redMaterial + // Update bounds from scene bounding box + const box = this._scene.getBoundingBox() + if (box) { + this._worldPosMaterial.updateBounds(box.min, box.max) + } + + // Apply world position material to entire scene + this._scene.threeScene.overrideMaterial = this._worldPosMaterial this._scene.threeScene.background = null // Disable layer 1 (NoRaycast) to hide skybox and gizmos @@ -126,6 +173,6 @@ export class DepthRenderer { */ dispose(): void { this._renderTarget.dispose() - this._redMaterial.dispose() + this._worldPosMaterial.dispose() } } From a98cb0953b4517f5a118c4d9308e073a0510708d Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 2 Feb 2026 13:16:14 -0500 Subject: [PATCH 05/94] working readback --- .../webgl/viewer/rendering/depthRenderer.ts | 210 +++++++++++++++--- .../webgl/viewer/rendering/renderer.ts | 3 + 2 files changed, 186 insertions(+), 27 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts index ee683a19c..f86b2d9a7 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts @@ -7,43 +7,59 @@ import { Camera } from '../camera/camera' import { RenderScene } from './renderScene' /** - * Shader material that outputs world position as RGB. - * Position is normalized to 0-1 based on scene bounding box. + * Shader material that outputs linear depth encoded into RGB channels. + * Depth is the distance along camera direction, normalized to 0-1 based on near/far. + * Encoded with 24-bit precision into RGB for accurate readback. + * + * To decode in JS: + * const depth01 = r/255 + g/65025 + b/16581375 + * const depth = near + depth01 * (far - near) */ -class WorldPositionMaterial extends THREE.ShaderMaterial { +class DepthMaterial extends THREE.ShaderMaterial { constructor() { super({ uniforms: { - uBoundsMin: { value: new THREE.Vector3() }, - uBoundsMax: { value: new THREE.Vector3() } + uCameraPos: { value: new THREE.Vector3() }, + uCameraDir: { value: new THREE.Vector3() }, + uNear: { value: 0 }, + uFar: { value: 1000 } }, vertexShader: ` - varying vec3 vWorldPos; + varying float vDepth; + uniform vec3 uCameraPos; + uniform vec3 uCameraDir; void main() { vec4 worldPos = modelMatrix * vec4(position, 1.0); - vWorldPos = worldPos.xyz; + + // Depth = distance along camera direction + vec3 toVertex = worldPos.xyz - uCameraPos; + vDepth = dot(toVertex, uCameraDir); + gl_Position = projectionMatrix * viewMatrix * worldPos; } `, fragmentShader: ` - varying vec3 vWorldPos; - uniform vec3 uBoundsMin; - uniform vec3 uBoundsMax; + varying float vDepth; + uniform float uNear; + uniform float uFar; void main() { - // Normalize world position to 0-1 based on bounding box - vec3 normalized = (vWorldPos - uBoundsMin) / (uBoundsMax - uBoundsMin); - gl_FragColor = vec4(normalized, 1.0); + float depth01 = clamp((vDepth - uNear) / (uFar - uNear), 0.0, 1.0); + gl_FragColor = vec4(vec3(depth01), 1.0); } `, side: THREE.DoubleSide }) } - updateBounds(min: THREE.Vector3, max: THREE.Vector3): void { - this.uniforms.uBoundsMin.value.copy(min) - this.uniforms.uBoundsMax.value.copy(max) + updateCamera(camera: THREE.Camera, near: number, far: number): void { + const dir = new THREE.Vector3() + camera.getWorldDirection(dir) + this.uniforms.uCameraPos.value.copy(camera.position) + this.uniforms.uCameraDir.value.copy(dir) + this.uniforms.uNear.value = near + this.uniforms.uFar.value = far } } @@ -55,7 +71,12 @@ export class DepthRenderer { private _camera: Camera private _scene: RenderScene private _renderTarget: THREE.WebGLRenderTarget - private _worldPosMaterial: WorldPositionMaterial + private _depthMaterial: DepthMaterial + private _debugSphere: THREE.Mesh | undefined + + // Store near/far for decoding + private _near: number = 0 + private _far: number = 1000 constructor( renderer: THREE.WebGLRenderer, @@ -71,7 +92,7 @@ export class DepthRenderer { format: THREE.RGBAFormat, type: THREE.UnsignedByteType }) - this._worldPosMaterial = new WorldPositionMaterial() + this._depthMaterial = new DepthMaterial() } /** @@ -81,34 +102,86 @@ export class DepthRenderer { this._renderTarget.setSize(width, height) } + /** + * Calculates near/far depth from scene bounding box corners. + */ + private calculateDepthRange(): { near: number; far: number } { + const box = this._scene.getBoundingBox() + if (!box) { + return { near: 0.1, far: 1000 } + } + + const camera = this._camera.three + const cameraDir = new THREE.Vector3() + camera.getWorldDirection(cameraDir) + + // Get all 8 corners of the bounding box + const corners = [ + new THREE.Vector3(box.min.x, box.min.y, box.min.z), + new THREE.Vector3(box.min.x, box.min.y, box.max.z), + new THREE.Vector3(box.min.x, box.max.y, box.min.z), + new THREE.Vector3(box.min.x, box.max.y, box.max.z), + new THREE.Vector3(box.max.x, box.min.y, box.min.z), + new THREE.Vector3(box.max.x, box.min.y, box.max.z), + new THREE.Vector3(box.max.x, box.max.y, box.min.z), + new THREE.Vector3(box.max.x, box.max.y, box.max.z) + ] + + // Find min/max depth along camera direction + let near = Infinity + let far = -Infinity + + for (const corner of corners) { + const toCorner = corner.clone().sub(camera.position) + const depth = toCorner.dot(cameraDir) + near = Math.min(near, depth) + far = Math.max(far, depth) + } + + // Add small padding + const range = far - near + near = near - range * 0.01 + far = far + range * 0.01 + + return { near, far } + } + /** * Renders the scene and saves it as a PNG file. */ renderAndSave(): void { const camera = this._camera.three + camera.updateMatrixWorld(true) + const currentTarget = this._renderer.getRenderTarget() const currentOverrideMaterial = this._scene.threeScene.overrideMaterial const currentBackground = this._scene.threeScene.background - // Update bounds from scene bounding box - const box = this._scene.getBoundingBox() - if (box) { - this._worldPosMaterial.updateBounds(box.min, box.max) - } + // Calculate and store depth range + const { near, far } = this.calculateDepthRange() + this._near = near + this._far = far + this._depthMaterial.updateCamera(camera, near, far) - // Apply world position material to entire scene - this._scene.threeScene.overrideMaterial = this._worldPosMaterial + console.log('Depth range:', { near, far }) + console.log('Camera position:', camera.position.toArray()) + + // Apply depth material to entire scene + this._scene.threeScene.overrideMaterial = this._depthMaterial this._scene.threeScene.background = null // Disable layer 1 (NoRaycast) to hide skybox and gizmos camera.layers.disable(1) this._renderer.setRenderTarget(this._renderTarget) - this._renderer.setClearColor(0x000000, 1) // Black background + this._renderer.setClearColor(0xffffff, 1) // White = max depth (far) this._renderer.clear() this._renderer.render(this._scene.threeScene, camera) this._renderer.setRenderTarget(currentTarget) + // Read center pixel and create debug sphere + this.createDebugSphereAtCenter(camera) + // Restore original state camera.layers.enable(1) this._scene.threeScene.overrideMaterial = currentOverrideMaterial @@ -117,6 +190,72 @@ export class DepthRenderer { this.saveToFile() } + /** + * Reads the center pixel depth and creates a debug sphere at that world position. + */ + private createDebugSphereAtCenter(camera: THREE.Camera): void { + const width = this._renderTarget.width + const height = this._renderTarget.height + + // Read center pixel + const centerX = Math.floor(width / 2) + const centerY = Math.floor(height / 2) + const pixelBuffer = new Uint8Array(4) + + this._renderer.readRenderTargetPixels( + this._renderTarget, + centerX, + centerY, + 1, + 1, + pixelBuffer + ) + + const r = pixelBuffer[0] + const g = pixelBuffer[1] + const b = pixelBuffer[2] + + // Decode grayscale depth (all channels should be the same) + const depth01 = r / 255 + const depth = this._near + depth01 * (this._far - this._near) + + console.log('Center pixel RGB:', r, g, b) + console.log('Decoded depth01:', depth01) + console.log('Decoded depth:', depth) + + // Skip if no geometry hit (depth01 is 1.0 = far/background) + if (depth01 >= 0.999) { + console.log('No geometry at center pixel') + return + } + + // For center pixel, ray direction = camera direction + const cameraDir = new THREE.Vector3() + camera.getWorldDirection(cameraDir) + + // Compute world position: cameraPos + cameraDir * depth + const worldPos = camera.position.clone().add(cameraDir.multiplyScalar(depth)) + + console.log('Computed world position:', worldPos.toArray()) + + // Remove old debug sphere if exists + if (this._debugSphere) { + this._scene.threeScene.remove(this._debugSphere) + this._debugSphere.geometry.dispose() + ;(this._debugSphere.material as THREE.Material).dispose() + } + + // Create new debug sphere + const geometry = new THREE.SphereGeometry(0.5, 16, 16) + const material = new THREE.MeshBasicMaterial({ color: 0xff0000 }) + this._debugSphere = new THREE.Mesh(geometry, material) + this._debugSphere.position.copy(worldPos) + this._debugSphere.layers.set(1) // NoRaycast layer + + this._scene.threeScene.add(this._debugSphere) + this._renderer.domElement.dispatchEvent(new Event('needsUpdate')) + } + /** * Reads the render target pixels and saves them as a PNG image. */ @@ -173,6 +312,23 @@ export class DepthRenderer { */ dispose(): void { this._renderTarget.dispose() - this._worldPosMaterial.dispose() + this._depthMaterial.dispose() + if (this._debugSphere) { + this._scene.threeScene.remove(this._debugSphere) + this._debugSphere.geometry.dispose() + ;(this._debugSphere.material as THREE.Material).dispose() + } + } + + /** + * Decodes RGB values back to depth. + * @param r Red channel (0-255) + * @param g Green channel (0-255) + * @param b Blue channel (0-255) + * @returns The actual depth value in world units + */ + static decodeDepth(r: number, g: number, b: number, near: number, far: number): number { + const depth01 = r / 255 + g / 65025 + b / 16581375 + return near + depth01 * (far - near) } } diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts index 39fa1b54f..ae5d4de05 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts @@ -292,6 +292,9 @@ export class Renderer implements IRenderer { // Render and save this._depthRenderer.renderAndSave() + + // Trigger re-render to show debug sphere + this.needsUpdate = true } /** From af7206a28dfd1d643d68cadc2a8557bdc5a5ba6d Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 2 Feb 2026 14:29:13 -0500 Subject: [PATCH 06/94] works --- .../webgl/viewer/rendering/depthRenderer.ts | 124 ++++++++++++------ .../webgl/viewer/rendering/renderer.ts | 6 +- 2 files changed, 87 insertions(+), 43 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts index f86b2d9a7..f78a74b6a 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts @@ -7,13 +7,8 @@ import { Camera } from '../camera/camera' import { RenderScene } from './renderScene' /** - * Shader material that outputs linear depth encoded into RGB channels. - * Depth is the distance along camera direction, normalized to 0-1 based on near/far. - * Encoded with 24-bit precision into RGB for accurate readback. - * - * To decode in JS: - * const depth01 = r/255 + g/65025 + b/16581375 - * const depth = near + depth01 * (far - near) + * Shader material that outputs depth as grayscale. + * Based on the simple material pattern with proper Three.js includes. */ class DepthMaterial extends THREE.ShaderMaterial { constructor() { @@ -24,32 +19,64 @@ class DepthMaterial extends THREE.ShaderMaterial { uNear: { value: 0 }, uFar: { value: 1000 } }, - vertexShader: ` - varying float vDepth; - uniform vec3 uCameraPos; - uniform vec3 uCameraDir; + side: THREE.DoubleSide, + clipping: true, + vertexShader: /* glsl */ ` + #include + #include + #include - void main() { - vec4 worldPos = modelMatrix * vec4(position, 1.0); + // Visibility attribute (used by VIM meshes) + attribute float ignore; - // Depth = distance along camera direction - vec3 toVertex = worldPos.xyz - uCameraPos; - vDepth = dot(toVertex, uCameraDir); + // Pass world position to fragment shader + varying vec3 vWorldPos; - gl_Position = projectionMatrix * viewMatrix * worldPos; + void main() { + #include + #include + #include + #include + + // If ignore is set, hide the object + if (ignore > 0.0) { + gl_Position = vec4(1e20, 1e20, 1e20, 1.0); + return; + } + + // Compute world position for depth calculation + #ifdef USE_INSTANCING + vWorldPos = (modelMatrix * instanceMatrix * vec4(position, 1.0)).xyz; + #else + vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; + #endif } `, - fragmentShader: ` - varying float vDepth; + fragmentShader: /* glsl */ ` + #include + #include + #include + + varying vec3 vWorldPos; + uniform vec3 uCameraPos; + uniform vec3 uCameraDir; uniform float uNear; uniform float uFar; void main() { - float depth01 = clamp((vDepth - uNear) / (uFar - uNear), 0.0, 1.0); + #include + #include + + // Depth = distance along camera direction + vec3 toVertex = vWorldPos - uCameraPos; + float depth = dot(toVertex, uCameraDir); + + // Normalize to 0-1 + float depth01 = clamp((depth - uNear) / (uFar - uNear), 0.0, 1.0); + gl_FragColor = vec4(vec3(depth01), 1.0); } - `, - side: THREE.DoubleSide + ` }) } @@ -148,8 +175,9 @@ export class DepthRenderer { /** * Renders the scene and saves it as a PNG file. + * @param mousePos Optional normalized mouse position (0-1). Defaults to center. */ - renderAndSave(): void { + renderAndSave(mousePos?: THREE.Vector2): void { const camera = this._camera.three camera.updateMatrixWorld(true) @@ -179,8 +207,8 @@ export class DepthRenderer { this._renderer.render(this._scene.threeScene, camera) this._renderer.setRenderTarget(currentTarget) - // Read center pixel and create debug sphere - this.createDebugSphereAtCenter(camera) + // Read pixel at mouse position and create debug sphere + this.createDebugSphereAtPixel(camera, mousePos) // Restore original state camera.layers.enable(1) @@ -191,50 +219,66 @@ export class DepthRenderer { } /** - * Reads the center pixel depth and creates a debug sphere at that world position. + * Reads pixel depth at position and creates a debug sphere at that world position. + * @param mousePos Normalized position (0-1). Defaults to center if not provided. */ - private createDebugSphereAtCenter(camera: THREE.Camera): void { + private createDebugSphereAtPixel(camera: THREE.Camera, mousePos?: THREE.Vector2): void { const width = this._renderTarget.width const height = this._renderTarget.height - // Read center pixel - const centerX = Math.floor(width / 2) - const centerY = Math.floor(height / 2) + // Default to center if no mouse position + const normX = mousePos?.x ?? 0.5 + const normY = mousePos?.y ?? 0.5 + + // Convert to pixel coordinates (note: WebGL Y is flipped) + const pixelX = Math.floor(normX * width) + const pixelY = Math.floor((1 - normY) * height) + const pixelBuffer = new Uint8Array(4) this._renderer.readRenderTargetPixels( this._renderTarget, - centerX, - centerY, + pixelX, + pixelY, 1, 1, pixelBuffer ) const r = pixelBuffer[0] - const g = pixelBuffer[1] - const b = pixelBuffer[2] - // Decode grayscale depth (all channels should be the same) + // Decode grayscale depth const depth01 = r / 255 const depth = this._near + depth01 * (this._far - this._near) - console.log('Center pixel RGB:', r, g, b) + console.log('Pixel position:', pixelX, pixelY) console.log('Decoded depth01:', depth01) console.log('Decoded depth:', depth) // Skip if no geometry hit (depth01 is 1.0 = far/background) if (depth01 >= 0.999) { - console.log('No geometry at center pixel') + console.log('No geometry at pixel') return } - // For center pixel, ray direction = camera direction + // Compute ray direction for this pixel using NDC coordinates + const ndcX = normX * 2 - 1 + const ndcY = (1 - normY) * 2 - 1 // Flip Y for NDC + + // Unproject to get ray direction + const rayEnd = new THREE.Vector3(ndcX, ndcY, 1).unproject(camera) + const rayDir = rayEnd.sub(camera.position).normalize() + + // Get camera forward direction const cameraDir = new THREE.Vector3() camera.getWorldDirection(cameraDir) - // Compute world position: cameraPos + cameraDir * depth - const worldPos = camera.position.clone().add(cameraDir.multiplyScalar(depth)) + // depth = dot(worldPos - cameraPos, cameraDir) + // worldPos = cameraPos + rayDir * t + // depth = dot(rayDir * t, cameraDir) = t * dot(rayDir, cameraDir) + // t = depth / dot(rayDir, cameraDir) + const t = depth / rayDir.dot(cameraDir) + const worldPos = camera.position.clone().add(rayDir.clone().multiplyScalar(t)) console.log('Computed world position:', worldPos.toArray()) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts index ae5d4de05..50aa6918c 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts @@ -265,9 +265,9 @@ export class Renderer implements IRenderer { /** * Renders the scene depth to a PNG image and triggers download. - * This is a one-off test/debug feature. + * @param mousePos Optional normalized mouse position (0-1) for debug sphere placement. */ - testDepthRender(): void { + testDepthRender(mousePos?: THREE.Vector2): void { // Get size from the viewport (more reliable than renderer.getSize during init) const size = this._viewport.getParentSize() @@ -291,7 +291,7 @@ export class Renderer implements IRenderer { this._depthRenderer.setSize(size.x, size.y) // Render and save - this._depthRenderer.renderAndSave() + this._depthRenderer.renderAndSave(mousePos) // Trigger re-render to show debug sphere this.needsUpdate = true From 1144c35f0fadf78e007f98afae8b60358b8a7faa Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 2 Feb 2026 15:24:48 -0500 Subject: [PATCH 07/94] gpu picking --- src/main.tsx | 31 +++- .../loader/progressive/insertableGeometry.ts | 26 +++- .../loader/progressive/insertableMesh.ts | 6 +- .../progressive/instancedMeshFactory.ts | 23 ++- .../loader/progressive/legacyMeshFactory.ts | 9 +- .../webgl/loader/progressive/open.ts | 11 +- .../webgl/viewer/rendering/depthRenderer.ts | 135 ++++++++++++++++++ .../webgl/viewer/rendering/renderer.ts | 29 ++++ 8 files changed, 254 insertions(+), 16 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index b687d9971..5842a642c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -87,11 +87,11 @@ function addDepthPickerTest(viewer: VIM.React.Webgl.ViewerRef, container: HTMLDi // Show instructions const instructions = document.createElement('div') - instructions.textContent = 'Press T to test depth pick, C to clear spheres, X to save depth image' + instructions.textContent = 'T=depth pick, C=clear, X=depth image, E=element pick' instructions.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1000;padding:8px 16px;background:rgba(0,0,0,0.7);color:white;font-family:monospace;' container.appendChild(instructions) - // Keyboard handler for T and C (keydown for responsiveness) + // Keyboard handler for T, C, and E (keydown for responsiveness) window.addEventListener('keydown', (e) => { if (e.key === 't' || e.key === 'T') { // Call the new GPU raycast API @@ -126,13 +126,36 @@ function addDepthPickerTest(viewer: VIM.React.Webgl.ViewerRef, container: HTMLDi viewer.core.renderer.needsUpdate = true console.log('Spheres cleared') } + + if (e.key === 'c' || e.key === 'C') { + // Test element picking using GPU-based picking + const elementIndex = viewer.core.renderer.testElementPick(mousePos) + + if (elementIndex !== undefined && elementIndex >= 0) { + // Get the first loaded vim + const vim = viewer.core.vims.at(0) + if (vim) { + const element = vim.getElementFromIndex(elementIndex) + console.log('Element pick - index:', elementIndex, 'element:', element) + + // Select the element to verify + if (element) { + viewer.core.selection.select(element) + } + } else { + console.log('Element pick - index:', elementIndex, '(no vim loaded)') + } + } else { + console.log('Element pick - no element at position') + } + } }) // X key on keyup to only fire once (not on repeat) window.addEventListener('keyup', (e) => { if (e.key === 'x' || e.key === 'X') { - // Test depth render - downloads depth buffer as PNG - viewer.core.renderer.testDepthRender() + // Test depth render - downloads depth buffer as PNG, places sphere at mouse position + viewer.core.renderer.testDepthRender(mousePos) } }) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 6e8cdbde6..0750ebfbe 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -6,6 +6,7 @@ import * as THREE from 'three' import { G3d, G3dMesh, G3dMaterial } from 'vim-format' import { Scene } from '../scene' import { G3dMeshOffsets } from './g3dOffsets' +import { ElementMapping } from '../elementMapping' // TODO Merge both submeshes class. export class GeometrySubmesh { @@ -33,6 +34,8 @@ export class InsertableGeometry { private _indexAttribute: THREE.Uint32BufferAttribute private _vertexAttribute: THREE.BufferAttribute private _colorAttribute: THREE.BufferAttribute + private _elementIndexAttribute: THREE.Float32BufferAttribute + private _mapping: ElementMapping | undefined private _updateStartMesh = 0 private _updateEndMesh = 0 @@ -41,10 +44,12 @@ export class InsertableGeometry { constructor ( offsets: G3dMeshOffsets, materials: G3dMaterial, - transparent: boolean + transparent: boolean, + mapping?: ElementMapping ) { this.offsets = offsets this.materials = materials + this._mapping = mapping this._indexAttribute = new THREE.Uint32BufferAttribute( offsets.counts.indices, @@ -62,6 +67,12 @@ export class InsertableGeometry { colorSize ) + // Element index attribute for GPU picking (one per vertex) + this._elementIndexAttribute = new THREE.Float32BufferAttribute( + offsets.counts.vertices, + 1 + ) + // this._indexAttribute.count = 0 // this._vertexAttribute.count = 0 // this._colorAttribute.count = 0 @@ -70,6 +81,7 @@ export class InsertableGeometry { this.geometry.setIndex(this._indexAttribute) this.geometry.setAttribute('position', this._vertexAttribute) this.geometry.setAttribute('color', this._colorAttribute) + this.geometry.setAttribute('elementIndex', this._elementIndexAttribute) this.boundingBox = offsets.subset.getBoundingBox() if (this.boundingBox) { @@ -223,6 +235,9 @@ export class InsertableGeometry { const g3dInstance = this.offsets.getMeshInstance(mesh, instance) matrix.fromArray(g3d.getInstanceMatrix(g3dInstance)) + // Get element index for this instance (for GPU picking) + const elementIndex = this._mapping?.getElementFromInstance(g3dInstance) ?? -1 + const submesh = new GeometrySubmesh() submesh.instance = g3d.instanceNodes[g3dInstance] submesh.start = indexOffset + indexOut @@ -249,6 +264,7 @@ export class InsertableGeometry { vector.fromArray(g3d.positions, vertex * G3d.POSITION_SIZE) vector.applyMatrix4(matrix) this.setVertex(vertexOffset + vertexOut, vector) + this.setElementIndex(vertexOffset + vertexOut, elementIndex) submesh.expandBox(vector) vertexOut++ } @@ -278,6 +294,10 @@ export class InsertableGeometry { } } + private setElementIndex (index: number, elementIndex: number) { + this._elementIndexAttribute.setX(index, elementIndex) + } + private expandBox (box: THREE.Box3) { if (!box) return this.boundingBox = this.boundingBox?.union(box) ?? box.clone() @@ -322,6 +342,10 @@ export class InsertableGeometry { // this._colorAttribute.count = vertexEnd this._colorAttribute.needsUpdate = true + // update element indices (itemSize is 1) + this._elementIndexAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) + this._elementIndexAttribute.needsUpdate = true + if (this._computeBoundingBox) { this.geometry.computeBoundingBox() this.geometry.computeBoundingSphere() diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts index d2bdd5c40..a07227ed3 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts @@ -9,6 +9,7 @@ import { InsertableSubmesh } from './insertableSubmesh' import { G3dMeshOffsets } from './g3dOffsets' import { Vim } from '../vim' import { ModelMaterial, Materials } from '../materials/materials' +import { ElementMapping } from '../elementMapping' export class InsertableMesh { offsets: G3dMeshOffsets @@ -49,12 +50,13 @@ export class InsertableMesh { constructor ( offsets: G3dMeshOffsets, materials: G3dMaterial, - transparent: boolean + transparent: boolean, + mapping?: ElementMapping ) { this.offsets = offsets this.transparent = transparent - this.geometry = new InsertableGeometry(offsets, materials, transparent) + this.geometry = new InsertableGeometry(offsets, materials, transparent, mapping) this._material = transparent ? Materials.getInstance().transparent.material diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 5b4f1fa11..94a431fd7 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -7,12 +7,15 @@ import { G3d, G3dMesh, G3dMaterial, MeshSection, G3dScene } from 'vim-format' import { InstancedMesh } from './instancedMesh' import { Materials } from '../materials/materials' import * as Geometry from '../geometry' +import { ElementMapping } from '../elementMapping' export class InstancedMeshFactory { materials: G3dMaterial + private _mapping: ElementMapping | undefined - constructor (materials: G3dMaterial) { + constructor (materials: G3dMaterial, mapping?: ElementMapping) { this.materials = materials + this._mapping = mapping } createTransparent (mesh: G3dMesh, instances: number[]) { @@ -86,6 +89,7 @@ export class InstancedMeshFactory { ) this.setMatricesFromVimx(threeMesh, g3d, instances) + this.setElementIndices(threeMesh, instances ?? g3d.meshInstances[mesh]) const result = new InstancedMesh(g3d, threeMesh, instances) return result } @@ -163,4 +167,21 @@ export class InstancedMeshFactory { three.setMatrixAt(i, matrix) } } + + /** + * Adds per-instance element index attribute for GPU picking. + */ + private setElementIndices ( + three: THREE.InstancedMesh, + instances: number[] + ) { + const elementIndices = new Float32Array(instances.length) + for (let i = 0; i < instances.length; i++) { + elementIndices[i] = this._mapping?.getElementFromInstance(instances[i]) ?? -1 + } + three.geometry.setAttribute( + 'elementIndex', + new THREE.InstancedBufferAttribute(elementIndices, 1) + ) + } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts index d937397f7..563260e68 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts @@ -7,6 +7,7 @@ import { Scene } from '../scene' import { G3dMaterial, G3d, MeshSection } from 'vim-format' import { InstancedMeshFactory } from './instancedMeshFactory' import { G3dSubset } from './g3dSubset' +import { ElementMapping } from '../elementMapping' /** * Mesh factory to load a standard vim using the progressive pipeline. @@ -16,12 +17,14 @@ export class VimMeshFactory { private _materials: G3dMaterial private _instancedFactory: InstancedMeshFactory private _scene: Scene + private _mapping: ElementMapping - constructor (g3d: G3d, materials: G3dMaterial, scene: Scene) { + constructor (g3d: G3d, materials: G3dMaterial, scene: Scene, mapping: ElementMapping) { this.g3d = g3d this._materials = materials this._scene = scene - this._instancedFactory = new InstancedMeshFactory(materials) + this._mapping = mapping + this._instancedFactory = new InstancedMeshFactory(materials, mapping) } /** @@ -52,7 +55,7 @@ export class VimMeshFactory { transparent: boolean ) { const offsets = subset.getOffsets(section) - const opaque = new InsertableMesh(offsets, this._materials, transparent) + const opaque = new InsertableMesh(offsets, this._materials, transparent, this._mapping) const count = subset.getMeshCount() for (let m = 0; m < count; m++) { diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/open.ts b/src/vim-web/core-viewers/webgl/loader/progressive/open.ts index 7596ad2e3..c044186e1 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/open.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/open.ts @@ -140,13 +140,14 @@ async function loadFromVim ( const g3d = await G3d.createFromBfast(geometry) const materials = new G3dMaterial(g3d.materialColors) - // Create scene - const scene = new Scene(settings.matrix) - const factory = new VimMeshFactory(g3d, materials, scene) - - // Create legacy mapping + // Create mapping FIRST (needed by factory for element index attributes) const doc = await VimDocument.createFromBfast(bfast) const mapping = await ElementMapping.fromG3d(g3d, doc) + + // Create scene and factory WITH mapping + const scene = new Scene(settings.matrix) + const factory = new VimMeshFactory(g3d, materials, scene, mapping) + const header = await requestHeader(bfast) // Return legacy vim diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts index f78a74b6a..caf010e85 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts @@ -90,6 +90,65 @@ class DepthMaterial extends THREE.ShaderMaterial { } } +/** + * Shader material that outputs element index encoded into RGB. + * Uses the elementIndex attribute added to meshes during construction. + */ +class ElementIndexMaterial extends THREE.ShaderMaterial { + constructor() { + super({ + side: THREE.DoubleSide, + clipping: true, + vertexShader: /* glsl */ ` + #include + #include + #include + + // Visibility attribute (used by VIM meshes) + attribute float ignore; + // Element index attribute for GPU picking + attribute float elementIndex; + + varying float vElementIndex; + + void main() { + #include + #include + #include + #include + + // If ignore is set, hide the object + if (ignore > 0.0) { + gl_Position = vec4(1e20, 1e20, 1e20, 1.0); + return; + } + + vElementIndex = elementIndex; + } + `, + fragmentShader: /* glsl */ ` + #include + #include + #include + + varying float vElementIndex; + + void main() { + #include + #include + + // Encode element index into RGB (24-bit, up to 16 million elements) + float idx = vElementIndex; + float r = mod(idx, 256.0) / 255.0; + float g = mod(floor(idx / 256.0), 256.0) / 255.0; + float b = mod(floor(idx / 65536.0), 256.0) / 255.0; + gl_FragColor = vec4(r, g, b, 1.0); + } + ` + }) + } +} + /** * Renders the scene to a texture and exports it as a PNG image. */ @@ -99,6 +158,7 @@ export class DepthRenderer { private _scene: RenderScene private _renderTarget: THREE.WebGLRenderTarget private _depthMaterial: DepthMaterial + private _elementIndexMaterial: ElementIndexMaterial private _debugSphere: THREE.Mesh | undefined // Store near/far for decoding @@ -120,6 +180,7 @@ export class DepthRenderer { type: THREE.UnsignedByteType }) this._depthMaterial = new DepthMaterial() + this._elementIndexMaterial = new ElementIndexMaterial() } /** @@ -351,12 +412,86 @@ export class DepthRenderer { console.log('Screenshot saved:', link.download) } + /** + * Tests element picking at the given mouse position. + * Renders the scene with element index material and reads back the pixel. + * @param mousePos Optional normalized mouse position (0-1). Defaults to center. + * @returns The element index at the mouse position, or undefined if no geometry hit. + */ + testElementPick(mousePos?: THREE.Vector2): number | undefined { + const camera = this._camera.three + camera.updateMatrixWorld(true) + + // Store/restore state + const currentTarget = this._renderer.getRenderTarget() + const currentOverrideMaterial = this._scene.threeScene.overrideMaterial + const currentBackground = this._scene.threeScene.background + + // Apply element index material + this._scene.threeScene.overrideMaterial = this._elementIndexMaterial + this._scene.threeScene.background = null + + // Disable layer 1 (NoRaycast) to hide skybox and gizmos + camera.layers.disable(1) + + // Render + this._renderer.setRenderTarget(this._renderTarget) + this._renderer.setClearColor(0xffffff, 1) // White = no element (16777215) + this._renderer.clear() + this._renderer.render(this._scene.threeScene, camera) + this._renderer.setRenderTarget(currentTarget) + + // Read pixel at mouse position + const width = this._renderTarget.width + const height = this._renderTarget.height + const normX = mousePos?.x ?? 0.5 + const normY = mousePos?.y ?? 0.5 + const pixelX = Math.floor(normX * width) + const pixelY = Math.floor((1 - normY) * height) // WebGL Y is flipped + + const pixelBuffer = new Uint8Array(4) + this._renderer.readRenderTargetPixels( + this._renderTarget, + pixelX, + pixelY, + 1, + 1, + pixelBuffer + ) + + // Restore state + camera.layers.enable(1) + this._scene.threeScene.overrideMaterial = currentOverrideMaterial + this._scene.threeScene.background = currentBackground + + // Decode element index from RGB + const r = pixelBuffer[0] + const g = pixelBuffer[1] + const b = pixelBuffer[2] + const elementIndex = r + g * 256 + b * 65536 + + // Check for background (white = 0xFFFFFF = 16777215) + if (elementIndex >= 16777215) { + return undefined + } + + // Check for unmapped elements (-1 encoded as very large number due to mod) + // -1 in float becomes a large number, but encoded it wraps around + // We use -1 for unmapped, so check if elementIndex looks invalid + if (elementIndex < 0) { + return undefined + } + + return elementIndex + } + /** * Disposes of all resources. */ dispose(): void { this._renderTarget.dispose() this._depthMaterial.dispose() + this._elementIndexMaterial.dispose() if (this._debugSphere) { this._scene.threeScene.remove(this._debugSphere) this._debugSphere.geometry.dispose() diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts index 50aa6918c..a91fde838 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts @@ -297,6 +297,35 @@ export class Renderer implements IRenderer { this.needsUpdate = true } + /** + * Tests element picking at the given mouse position using GPU-based picking. + * @param mousePos Optional normalized mouse position (0-1). Defaults to center. + * @returns The element index at the mouse position, or undefined if no geometry hit. + */ + testElementPick(mousePos?: THREE.Vector2): number | undefined { + const size = this._viewport.getParentSize() + + if (size.x === 0 || size.y === 0) { + return undefined + } + + // Lazily create depth renderer + if (!this._depthRenderer) { + this._depthRenderer = new DepthRenderer( + this.renderer, + this._camera, + this._scene, + size.x, + size.y + ) + } + + // Ensure size is current + this._depthRenderer.setSize(size.x, size.y) + + return this._depthRenderer.testElementPick(mousePos) + } + /** * Adds an object to be rendered. * @param target The object or scene to add for rendering. From 167c84ed115df6ed4498a2ad9e7fe5c2db1baac8 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 11:48:34 -0500 Subject: [PATCH 08/94] gpu picker --- .../webgl/loader/materials/index.ts | 1 + .../webgl/loader/materials/pickingMaterial.ts | 125 +++++ .../core-viewers/webgl/viewer/raycaster.ts | 16 +- .../webgl/viewer/rendering/depthPicker.ts | 173 ------ .../webgl/viewer/rendering/depthRenderer.ts | 513 ------------------ .../webgl/viewer/rendering/gpuPicker.ts | 281 ++++++++++ .../webgl/viewer/rendering/renderer.ts | 71 ++- 7 files changed, 450 insertions(+), 730 deletions(-) create mode 100644 src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts delete mode 100644 src/vim-web/core-viewers/webgl/viewer/rendering/depthPicker.ts delete mode 100644 src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts create mode 100644 src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts diff --git a/src/vim-web/core-viewers/webgl/loader/materials/index.ts b/src/vim-web/core-viewers/webgl/loader/materials/index.ts index e9c7a4d84..eeb671937 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/index.ts @@ -3,6 +3,7 @@ export * from './maskMaterial'; export * from './materials'; export * from './mergeMaterial'; export * from './outlineMaterial'; +export * from './pickingMaterial'; export * from './simpleMaterial'; export * from './skyboxMaterial'; export * from './standardMaterial'; diff --git a/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts new file mode 100644 index 000000000..63f98f42f --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts @@ -0,0 +1,125 @@ +/** + * @module vim-loader/materials + * Material for GPU picking that outputs both element index and depth in a single pass. + */ + +import * as THREE from 'three' + +/** + * Creates a material for GPU picking that outputs element index and depth. + * + * Output format (Float32 RGBA): + * - R = element index (float, supports up to 16M elements) + * - G = depth (distance along camera direction) + * - B, A = unused (set to 0.0 and 1.0) + * + * @returns A custom shader material for GPU picking. + */ +export function createPickingMaterial() { + return new THREE.ShaderMaterial({ + uniforms: { + uCameraPos: { value: new THREE.Vector3() }, + uCameraDir: { value: new THREE.Vector3() } + }, + side: THREE.DoubleSide, + clipping: true, + vertexShader: /* glsl */ ` + #include + #include + #include + + // Visibility attribute (used by VIM meshes) + attribute float ignore; + // Element index attribute for GPU picking + attribute float elementIndex; + + varying float vElementIndex; + varying vec3 vWorldPos; + + void main() { + #include + #include + #include + #include + + // If ignore is set, hide the object by moving it far out of view + if (ignore > 0.0) { + gl_Position = vec4(1e20, 1e20, 1e20, 1.0); + return; + } + + vElementIndex = elementIndex; + + // Compute world position for depth calculation + #ifdef USE_INSTANCING + vWorldPos = (modelMatrix * instanceMatrix * vec4(position, 1.0)).xyz; + #else + vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; + #endif + } + `, + fragmentShader: /* glsl */ ` + #include + #include + #include + + uniform vec3 uCameraPos; + uniform vec3 uCameraDir; + + varying float vElementIndex; + varying vec3 vWorldPos; + + void main() { + #include + #include + + // Depth = distance along camera direction + vec3 toVertex = vWorldPos - uCameraPos; + float depth = dot(toVertex, uCameraDir); + + // Output: R = element index, G = depth, B = 0, A = 1 + gl_FragColor = vec4(vElementIndex, depth, 0.0, 1.0); + } + ` + }) +} + +/** + * PickingMaterial class that wraps the shader material with camera update functionality. + */ +export class PickingMaterial { + readonly material: THREE.ShaderMaterial + + constructor() { + this.material = createPickingMaterial() + } + + /** + * Updates the camera uniforms for depth calculation. + * Must be called before rendering. + */ + updateCamera(camera: THREE.Camera): void { + const dir = new THREE.Vector3() + camera.getWorldDirection(dir) + this.material.uniforms.uCameraPos.value.copy(camera.position) + this.material.uniforms.uCameraDir.value.copy(dir) + } + + /** + * Gets or sets the clipping planes for section box support. + */ + get clippingPlanes(): THREE.Plane[] { + return this.material.clippingPlanes ?? [] + } + + set clippingPlanes(planes: THREE.Plane[]) { + this.material.clippingPlanes = planes + } + + /** + * Disposes of the material resources. + */ + dispose(): void { + this.material.dispose() + } +} diff --git a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts index aeb14ff1e..e742c3bca 100644 --- a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts +++ b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts @@ -10,7 +10,7 @@ import { Camera } from './camera/camera' import { Renderer } from './rendering/renderer' import { Marker } from './gizmos/markers/gizmoMarker' import { GizmoMarkers } from './gizmos/markers/gizmoMarkers' -import { DepthPicker } from './rendering/depthPicker' +import { GpuPicker } from './rendering/gpuPicker' import type { IRaycaster as IRaycasterBase, IRaycastResult as IRaycastResultBase, @@ -59,7 +59,7 @@ export class Raycaster implements IRaycaster { private _camera: Camera private _scene: RenderScene private _renderer: Renderer - private _depthPicker: DepthPicker + private _gpuPicker: GpuPicker private _raycaster = new THREE.Raycaster() @@ -68,9 +68,9 @@ export class Raycaster implements IRaycaster { this._scene = scene this._renderer = renderer - // Initialize depth picker for GPU-based world position queries + // Initialize GPU picker for world position queries const size = renderer.renderer.getSize(new THREE.Vector2()) - this._depthPicker = new DepthPicker( + this._gpuPicker = new GpuPicker( renderer.renderer, camera, scene, @@ -81,11 +81,11 @@ export class Raycaster implements IRaycaster { } /** - * Updates the depth picker render target size. + * Updates the GPU picker render target size. * Called when the viewport is resized. */ setSize(width: number, height: number): void { - this._depthPicker.setSize(width, height) + this._gpuPicker.setSize(width, height) } /** @@ -96,14 +96,14 @@ export class Raycaster implements IRaycaster { */ raycastWorldPosition(position: THREE.Vector2): THREE.Vector3 | undefined { if (!Validation.isRelativeVector2(position)) return undefined - return this._depthPicker.pick(position) + return this._gpuPicker.pick(position)?.worldPosition } /** * Disposes of resources used by the raycaster. */ dispose(): void { - this._depthPicker.dispose() + this._gpuPicker.dispose() } /** diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/depthPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/depthPicker.ts deleted file mode 100644 index dee44d644..000000000 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/depthPicker.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * @module viw-webgl-viewer/rendering - */ - -import * as THREE from 'three' -import { Camera } from '../camera/camera' -import { RenderScene } from './renderScene' -import { RenderingSection } from './renderingSection' - -/** - * Custom shader material that outputs world position as RGB color. - */ -class WorldPositionMaterial extends THREE.ShaderMaterial { - constructor() { - super({ - vertexShader: ` - varying vec3 vWorldPosition; - void main() { - vec4 worldPos = modelMatrix * vec4(position, 1.0); - vWorldPosition = worldPos.xyz; - gl_Position = projectionMatrix * viewMatrix * worldPos; - } - `, - fragmentShader: ` - varying vec3 vWorldPosition; - void main() { - // Encode world position - we'll use a scale/offset to fit in 0-1 range - // Using a large range (-1000 to 1000) mapped to (0-1) - vec3 encoded = (vWorldPosition + 1000.0) / 2000.0; - gl_FragColor = vec4(encoded, 1.0); - } - `, - side: THREE.DoubleSide - }) - } -} - -/** - * GPU-based depth picker for world position queries. - * Renders world position to a texture and samples it at given screen coordinates - * to return the world position of the first hit. - * - * This is optimized for camera operations (orbit target, etc.) - * where only world position is needed, not object identification. - */ -export class DepthPicker { - private _renderer: THREE.WebGLRenderer - private _camera: Camera - private _scene: RenderScene - private _section: RenderingSection - - private _renderTarget: THREE.WebGLRenderTarget - private _worldPosMaterial: WorldPositionMaterial - private _readBuffer: Float32Array - - // Encoding range for world position - private static readonly RANGE = 2000.0 - private static readonly OFFSET = 1000.0 - - constructor( - renderer: THREE.WebGLRenderer, - camera: Camera, - scene: RenderScene, - section: RenderingSection, - width: number, - height: number - ) { - this._renderer = renderer - this._camera = camera - this._scene = scene - this._section = section - - // Create render target with float type for better precision - this._renderTarget = new THREE.WebGLRenderTarget(width, height, { - minFilter: THREE.NearestFilter, - magFilter: THREE.NearestFilter, - format: THREE.RGBAFormat, - type: THREE.FloatType, - depthBuffer: true - }) - - // Material that outputs world position - this._worldPosMaterial = new WorldPositionMaterial() - - // Buffer for reading single pixel (RGBA float = 4 floats) - this._readBuffer = new Float32Array(4) - } - - /** - * Updates the render target size to match viewport. - */ - setSize(width: number, height: number): void { - this._renderTarget.setSize(width, height) - } - - /** - * Picks the world position at the given screen coordinates. - * @param screenPos Screen position in 0-1 range (0,0 is top-left) - * @returns World position of the first hit, or undefined if no hit - */ - pick(screenPos: THREE.Vector2): THREE.Vector3 | undefined { - const camera = this._camera.three - - // Store current state - const currentRenderTarget = this._renderer.getRenderTarget() - const currentOverrideMaterial = this._scene.threeScene.overrideMaterial - const currentBackground = this._scene.threeScene.background - - // Apply section box clipping if active - if (this._section.active) { - this._worldPosMaterial.clippingPlanes = this._section.clippingPlanes - } else { - this._worldPosMaterial.clippingPlanes = [] - } - - // Set background to black (will read as 0,0,0 for miss detection) - this._scene.threeScene.background = null - - // Override scene materials with world position material - this._scene.threeScene.overrideMaterial = this._worldPosMaterial - - // Render to target - this._renderer.setRenderTarget(this._renderTarget) - this._renderer.setClearColor(0x000000, 0) - this._renderer.clear() - this._renderer.render(this._scene.threeScene, camera) - - // Restore state - this._renderer.setRenderTarget(currentRenderTarget) - this._scene.threeScene.overrideMaterial = currentOverrideMaterial - this._scene.threeScene.background = currentBackground - - // Calculate pixel position (flip Y for WebGL coordinate system) - const pixelX = Math.floor(screenPos.x * this._renderTarget.width) - const pixelY = Math.floor((1 - screenPos.y) * this._renderTarget.height) - - // Read single pixel - this._renderer.readRenderTargetPixels( - this._renderTarget, - pixelX, - pixelY, - 1, - 1, - this._readBuffer - ) - - console.log('Depth pick - RGBA:', this._readBuffer[0], this._readBuffer[1], this._readBuffer[2], this._readBuffer[3]) - - // Check if hit (alpha = 0 means background/no hit) - if (this._readBuffer[3] === 0) { - console.log('Depth pick - miss (alpha=0)') - return undefined - } - - // Decode world position from encoded RGB - const worldPos = new THREE.Vector3( - this._readBuffer[0] * DepthPicker.RANGE - DepthPicker.OFFSET, - this._readBuffer[1] * DepthPicker.RANGE - DepthPicker.OFFSET, - this._readBuffer[2] * DepthPicker.RANGE - DepthPicker.OFFSET - ) - - console.log('Depth pick - worldPos:', worldPos.x, worldPos.y, worldPos.z) - return worldPos - } - - /** - * Disposes of all resources. - */ - dispose(): void { - this._renderTarget.dispose() - this._worldPosMaterial.dispose() - } -} diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts deleted file mode 100644 index caf010e85..000000000 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/depthRenderer.ts +++ /dev/null @@ -1,513 +0,0 @@ -/** - * @module viw-webgl-viewer/rendering - */ - -import * as THREE from 'three' -import { Camera } from '../camera/camera' -import { RenderScene } from './renderScene' - -/** - * Shader material that outputs depth as grayscale. - * Based on the simple material pattern with proper Three.js includes. - */ -class DepthMaterial extends THREE.ShaderMaterial { - constructor() { - super({ - uniforms: { - uCameraPos: { value: new THREE.Vector3() }, - uCameraDir: { value: new THREE.Vector3() }, - uNear: { value: 0 }, - uFar: { value: 1000 } - }, - side: THREE.DoubleSide, - clipping: true, - vertexShader: /* glsl */ ` - #include - #include - #include - - // Visibility attribute (used by VIM meshes) - attribute float ignore; - - // Pass world position to fragment shader - varying vec3 vWorldPos; - - void main() { - #include - #include - #include - #include - - // If ignore is set, hide the object - if (ignore > 0.0) { - gl_Position = vec4(1e20, 1e20, 1e20, 1.0); - return; - } - - // Compute world position for depth calculation - #ifdef USE_INSTANCING - vWorldPos = (modelMatrix * instanceMatrix * vec4(position, 1.0)).xyz; - #else - vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; - #endif - } - `, - fragmentShader: /* glsl */ ` - #include - #include - #include - - varying vec3 vWorldPos; - uniform vec3 uCameraPos; - uniform vec3 uCameraDir; - uniform float uNear; - uniform float uFar; - - void main() { - #include - #include - - // Depth = distance along camera direction - vec3 toVertex = vWorldPos - uCameraPos; - float depth = dot(toVertex, uCameraDir); - - // Normalize to 0-1 - float depth01 = clamp((depth - uNear) / (uFar - uNear), 0.0, 1.0); - - gl_FragColor = vec4(vec3(depth01), 1.0); - } - ` - }) - } - - updateCamera(camera: THREE.Camera, near: number, far: number): void { - const dir = new THREE.Vector3() - camera.getWorldDirection(dir) - this.uniforms.uCameraPos.value.copy(camera.position) - this.uniforms.uCameraDir.value.copy(dir) - this.uniforms.uNear.value = near - this.uniforms.uFar.value = far - } -} - -/** - * Shader material that outputs element index encoded into RGB. - * Uses the elementIndex attribute added to meshes during construction. - */ -class ElementIndexMaterial extends THREE.ShaderMaterial { - constructor() { - super({ - side: THREE.DoubleSide, - clipping: true, - vertexShader: /* glsl */ ` - #include - #include - #include - - // Visibility attribute (used by VIM meshes) - attribute float ignore; - // Element index attribute for GPU picking - attribute float elementIndex; - - varying float vElementIndex; - - void main() { - #include - #include - #include - #include - - // If ignore is set, hide the object - if (ignore > 0.0) { - gl_Position = vec4(1e20, 1e20, 1e20, 1.0); - return; - } - - vElementIndex = elementIndex; - } - `, - fragmentShader: /* glsl */ ` - #include - #include - #include - - varying float vElementIndex; - - void main() { - #include - #include - - // Encode element index into RGB (24-bit, up to 16 million elements) - float idx = vElementIndex; - float r = mod(idx, 256.0) / 255.0; - float g = mod(floor(idx / 256.0), 256.0) / 255.0; - float b = mod(floor(idx / 65536.0), 256.0) / 255.0; - gl_FragColor = vec4(r, g, b, 1.0); - } - ` - }) - } -} - -/** - * Renders the scene to a texture and exports it as a PNG image. - */ -export class DepthRenderer { - private _renderer: THREE.WebGLRenderer - private _camera: Camera - private _scene: RenderScene - private _renderTarget: THREE.WebGLRenderTarget - private _depthMaterial: DepthMaterial - private _elementIndexMaterial: ElementIndexMaterial - private _debugSphere: THREE.Mesh | undefined - - // Store near/far for decoding - private _near: number = 0 - private _far: number = 1000 - - constructor( - renderer: THREE.WebGLRenderer, - camera: Camera, - scene: RenderScene, - width: number, - height: number - ) { - this._renderer = renderer - this._camera = camera - this._scene = scene - this._renderTarget = new THREE.WebGLRenderTarget(width, height, { - format: THREE.RGBAFormat, - type: THREE.UnsignedByteType - }) - this._depthMaterial = new DepthMaterial() - this._elementIndexMaterial = new ElementIndexMaterial() - } - - /** - * Updates the render target size to match viewport. - */ - setSize(width: number, height: number): void { - this._renderTarget.setSize(width, height) - } - - /** - * Calculates near/far depth from scene bounding box corners. - */ - private calculateDepthRange(): { near: number; far: number } { - const box = this._scene.getBoundingBox() - if (!box) { - return { near: 0.1, far: 1000 } - } - - const camera = this._camera.three - const cameraDir = new THREE.Vector3() - camera.getWorldDirection(cameraDir) - - // Get all 8 corners of the bounding box - const corners = [ - new THREE.Vector3(box.min.x, box.min.y, box.min.z), - new THREE.Vector3(box.min.x, box.min.y, box.max.z), - new THREE.Vector3(box.min.x, box.max.y, box.min.z), - new THREE.Vector3(box.min.x, box.max.y, box.max.z), - new THREE.Vector3(box.max.x, box.min.y, box.min.z), - new THREE.Vector3(box.max.x, box.min.y, box.max.z), - new THREE.Vector3(box.max.x, box.max.y, box.min.z), - new THREE.Vector3(box.max.x, box.max.y, box.max.z) - ] - - // Find min/max depth along camera direction - let near = Infinity - let far = -Infinity - - for (const corner of corners) { - const toCorner = corner.clone().sub(camera.position) - const depth = toCorner.dot(cameraDir) - near = Math.min(near, depth) - far = Math.max(far, depth) - } - - // Add small padding - const range = far - near - near = near - range * 0.01 - far = far + range * 0.01 - - return { near, far } - } - - /** - * Renders the scene and saves it as a PNG file. - * @param mousePos Optional normalized mouse position (0-1). Defaults to center. - */ - renderAndSave(mousePos?: THREE.Vector2): void { - const camera = this._camera.three - camera.updateMatrixWorld(true) - - const currentTarget = this._renderer.getRenderTarget() - const currentOverrideMaterial = this._scene.threeScene.overrideMaterial - const currentBackground = this._scene.threeScene.background - - // Calculate and store depth range - const { near, far } = this.calculateDepthRange() - this._near = near - this._far = far - this._depthMaterial.updateCamera(camera, near, far) - - console.log('Depth range:', { near, far }) - console.log('Camera position:', camera.position.toArray()) - - // Apply depth material to entire scene - this._scene.threeScene.overrideMaterial = this._depthMaterial - this._scene.threeScene.background = null - - // Disable layer 1 (NoRaycast) to hide skybox and gizmos - camera.layers.disable(1) - - this._renderer.setRenderTarget(this._renderTarget) - this._renderer.setClearColor(0xffffff, 1) // White = max depth (far) - this._renderer.clear() - this._renderer.render(this._scene.threeScene, camera) - this._renderer.setRenderTarget(currentTarget) - - // Read pixel at mouse position and create debug sphere - this.createDebugSphereAtPixel(camera, mousePos) - - // Restore original state - camera.layers.enable(1) - this._scene.threeScene.overrideMaterial = currentOverrideMaterial - this._scene.threeScene.background = currentBackground - - this.saveToFile() - } - - /** - * Reads pixel depth at position and creates a debug sphere at that world position. - * @param mousePos Normalized position (0-1). Defaults to center if not provided. - */ - private createDebugSphereAtPixel(camera: THREE.Camera, mousePos?: THREE.Vector2): void { - const width = this._renderTarget.width - const height = this._renderTarget.height - - // Default to center if no mouse position - const normX = mousePos?.x ?? 0.5 - const normY = mousePos?.y ?? 0.5 - - // Convert to pixel coordinates (note: WebGL Y is flipped) - const pixelX = Math.floor(normX * width) - const pixelY = Math.floor((1 - normY) * height) - - const pixelBuffer = new Uint8Array(4) - - this._renderer.readRenderTargetPixels( - this._renderTarget, - pixelX, - pixelY, - 1, - 1, - pixelBuffer - ) - - const r = pixelBuffer[0] - - // Decode grayscale depth - const depth01 = r / 255 - const depth = this._near + depth01 * (this._far - this._near) - - console.log('Pixel position:', pixelX, pixelY) - console.log('Decoded depth01:', depth01) - console.log('Decoded depth:', depth) - - // Skip if no geometry hit (depth01 is 1.0 = far/background) - if (depth01 >= 0.999) { - console.log('No geometry at pixel') - return - } - - // Compute ray direction for this pixel using NDC coordinates - const ndcX = normX * 2 - 1 - const ndcY = (1 - normY) * 2 - 1 // Flip Y for NDC - - // Unproject to get ray direction - const rayEnd = new THREE.Vector3(ndcX, ndcY, 1).unproject(camera) - const rayDir = rayEnd.sub(camera.position).normalize() - - // Get camera forward direction - const cameraDir = new THREE.Vector3() - camera.getWorldDirection(cameraDir) - - // depth = dot(worldPos - cameraPos, cameraDir) - // worldPos = cameraPos + rayDir * t - // depth = dot(rayDir * t, cameraDir) = t * dot(rayDir, cameraDir) - // t = depth / dot(rayDir, cameraDir) - const t = depth / rayDir.dot(cameraDir) - const worldPos = camera.position.clone().add(rayDir.clone().multiplyScalar(t)) - - console.log('Computed world position:', worldPos.toArray()) - - // Remove old debug sphere if exists - if (this._debugSphere) { - this._scene.threeScene.remove(this._debugSphere) - this._debugSphere.geometry.dispose() - ;(this._debugSphere.material as THREE.Material).dispose() - } - - // Create new debug sphere - const geometry = new THREE.SphereGeometry(0.5, 16, 16) - const material = new THREE.MeshBasicMaterial({ color: 0xff0000 }) - this._debugSphere = new THREE.Mesh(geometry, material) - this._debugSphere.position.copy(worldPos) - this._debugSphere.layers.set(1) // NoRaycast layer - - this._scene.threeScene.add(this._debugSphere) - this._renderer.domElement.dispatchEvent(new Event('needsUpdate')) - } - - /** - * Reads the render target pixels and saves them as a PNG image. - */ - private saveToFile(): void { - const width = this._renderTarget.width - const height = this._renderTarget.height - const buffer = new Uint8Array(width * height * 4) - - this._renderer.readRenderTargetPixels( - this._renderTarget, - 0, - 0, - width, - height, - buffer - ) - - // Create canvas and flip vertically - const canvas = document.createElement('canvas') - canvas.width = width - canvas.height = height - const ctx = canvas.getContext('2d') - if (!ctx) { - console.error('Failed to get 2D context for export') - return - } - - const imageData = ctx.createImageData(width, height) - - // Copy pixels with vertical flip (WebGL renders upside down) - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const src = (y * width + x) * 4 - const dst = ((height - 1 - y) * width + x) * 4 - imageData.data[dst] = buffer[src] - imageData.data[dst + 1] = buffer[src + 1] - imageData.data[dst + 2] = buffer[src + 2] - imageData.data[dst + 3] = 255 - } - } - - ctx.putImageData(imageData, 0, 0) - - const link = document.createElement('a') - link.download = `screenshot-${Date.now()}.png` - link.href = canvas.toDataURL('image/png') - link.click() - - console.log('Screenshot saved:', link.download) - } - - /** - * Tests element picking at the given mouse position. - * Renders the scene with element index material and reads back the pixel. - * @param mousePos Optional normalized mouse position (0-1). Defaults to center. - * @returns The element index at the mouse position, or undefined if no geometry hit. - */ - testElementPick(mousePos?: THREE.Vector2): number | undefined { - const camera = this._camera.three - camera.updateMatrixWorld(true) - - // Store/restore state - const currentTarget = this._renderer.getRenderTarget() - const currentOverrideMaterial = this._scene.threeScene.overrideMaterial - const currentBackground = this._scene.threeScene.background - - // Apply element index material - this._scene.threeScene.overrideMaterial = this._elementIndexMaterial - this._scene.threeScene.background = null - - // Disable layer 1 (NoRaycast) to hide skybox and gizmos - camera.layers.disable(1) - - // Render - this._renderer.setRenderTarget(this._renderTarget) - this._renderer.setClearColor(0xffffff, 1) // White = no element (16777215) - this._renderer.clear() - this._renderer.render(this._scene.threeScene, camera) - this._renderer.setRenderTarget(currentTarget) - - // Read pixel at mouse position - const width = this._renderTarget.width - const height = this._renderTarget.height - const normX = mousePos?.x ?? 0.5 - const normY = mousePos?.y ?? 0.5 - const pixelX = Math.floor(normX * width) - const pixelY = Math.floor((1 - normY) * height) // WebGL Y is flipped - - const pixelBuffer = new Uint8Array(4) - this._renderer.readRenderTargetPixels( - this._renderTarget, - pixelX, - pixelY, - 1, - 1, - pixelBuffer - ) - - // Restore state - camera.layers.enable(1) - this._scene.threeScene.overrideMaterial = currentOverrideMaterial - this._scene.threeScene.background = currentBackground - - // Decode element index from RGB - const r = pixelBuffer[0] - const g = pixelBuffer[1] - const b = pixelBuffer[2] - const elementIndex = r + g * 256 + b * 65536 - - // Check for background (white = 0xFFFFFF = 16777215) - if (elementIndex >= 16777215) { - return undefined - } - - // Check for unmapped elements (-1 encoded as very large number due to mod) - // -1 in float becomes a large number, but encoded it wraps around - // We use -1 for unmapped, so check if elementIndex looks invalid - if (elementIndex < 0) { - return undefined - } - - return elementIndex - } - - /** - * Disposes of all resources. - */ - dispose(): void { - this._renderTarget.dispose() - this._depthMaterial.dispose() - this._elementIndexMaterial.dispose() - if (this._debugSphere) { - this._scene.threeScene.remove(this._debugSphere) - this._debugSphere.geometry.dispose() - ;(this._debugSphere.material as THREE.Material).dispose() - } - } - - /** - * Decodes RGB values back to depth. - * @param r Red channel (0-255) - * @param g Green channel (0-255) - * @param b Blue channel (0-255) - * @returns The actual depth value in world units - */ - static decodeDepth(r: number, g: number, b: number, near: number, far: number): number { - const depth01 = r / 255 + g / 65025 + b / 16581375 - return near + depth01 * (far - near) - } -} diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts new file mode 100644 index 000000000..87ef257fe --- /dev/null +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -0,0 +1,281 @@ +/** + * @module viw-webgl-viewer/rendering + */ + +import * as THREE from 'three' +import { Camera } from '../camera/camera' +import { RenderScene } from './renderScene' +import { RenderingSection } from './renderingSection' +import { PickingMaterial } from '../../loader/materials/pickingMaterial' +import { Element3D } from '../../loader/element3d' +import { Vim } from '../../loader/vim' + +/** + * Result of a GPU pick operation containing element index and world position. + * Similar to RaycastResult but for GPU-based picking. + */ +export class GpuPickResult { + /** The element index in the vim */ + readonly elementIndex: number + /** The world position of the hit */ + readonly worldPosition: THREE.Vector3 + /** Reference to vims for element lookup */ + private _vims: Vim[] + + constructor(elementIndex: number, worldPosition: THREE.Vector3, vims: Vim[]) { + this.elementIndex = elementIndex + this.worldPosition = worldPosition + this._vims = vims + } + + /** + * Gets the Element3D object for the picked element. + * Searches through all loaded vims to find the element. + * @returns The Element3D object, or undefined if not found + */ + getElement(): Element3D | undefined { + for (const vim of this._vims) { + const element = vim.getElementFromIndex(this.elementIndex) + if (element) return element + } + return undefined + } +} + +/** + * Unified GPU picker that outputs both element index and depth in a single render pass. + * Replaces the separate DepthRenderer and DepthPicker classes. + * + * Uses a Float32 render target with: + * - R = element index (supports up to 16M elements) + * - G = depth (distance along camera direction) + */ +export class GpuPicker { + private _renderer: THREE.WebGLRenderer + private _camera: Camera + private _scene: RenderScene + private _section: RenderingSection + + private _renderTarget: THREE.WebGLRenderTarget + private _pickingMaterial: PickingMaterial + private _readBuffer: Float32Array + + // Debug visualization + private _debugSphere: THREE.Mesh | undefined + + constructor( + renderer: THREE.WebGLRenderer, + camera: Camera, + scene: RenderScene, + section: RenderingSection, + width: number, + height: number + ) { + this._renderer = renderer + this._camera = camera + this._scene = scene + this._section = section + + // Create render target with Float32 for precise element index and depth + this._renderTarget = new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.NearestFilter, + magFilter: THREE.NearestFilter, + format: THREE.RGBAFormat, + type: THREE.FloatType, + depthBuffer: true + }) + + this._pickingMaterial = new PickingMaterial() + + // Buffer for reading single pixel (RGBA float = 4 floats) + this._readBuffer = new Float32Array(4) + } + + /** + * Updates the render target size to match viewport. + */ + setSize(width: number, height: number): void { + this._renderTarget.setSize(width, height) + } + + /** + * Performs GPU picking at the given screen coordinates. + * Returns a result object with element index, world position, and getElement() method. + * + * @param screenPos Screen position in 0-1 range (0,0 is top-left) + * @returns Pick result with element index, world position, and getElement(), or undefined if no hit + */ + pick(screenPos: THREE.Vector2): GpuPickResult | undefined { + const camera = this._camera.three + camera.updateMatrixWorld(true) + + // Store current state + const currentRenderTarget = this._renderer.getRenderTarget() + const currentOverrideMaterial = this._scene.threeScene.overrideMaterial + const currentBackground = this._scene.threeScene.background + + // Update picking material with camera info + this._pickingMaterial.updateCamera(camera) + + // Apply section box clipping if active + if (this._section.active) { + this._pickingMaterial.clippingPlanes = this._section.clippingPlanes + } else { + this._pickingMaterial.clippingPlanes = [] + } + + // Set background to null for miss detection (alpha = 0) + this._scene.threeScene.background = null + + // Override scene materials with picking material + this._scene.threeScene.overrideMaterial = this._pickingMaterial.material + + // Disable layer 1 (NoRaycast) to hide skybox and gizmos + camera.layers.disable(1) + + // Render to target + this._renderer.setRenderTarget(this._renderTarget) + this._renderer.setClearColor(0x000000, 0) + this._renderer.clear() + this._renderer.render(this._scene.threeScene, camera) + + // Restore state + this._renderer.setRenderTarget(currentRenderTarget) + camera.layers.enable(1) + this._scene.threeScene.overrideMaterial = currentOverrideMaterial + this._scene.threeScene.background = currentBackground + + // Calculate pixel position (flip Y for WebGL coordinate system) + const pixelX = Math.floor(screenPos.x * this._renderTarget.width) + const pixelY = Math.floor((1 - screenPos.y) * this._renderTarget.height) + + // Read single pixel + this._renderer.readRenderTargetPixels( + this._renderTarget, + pixelX, + pixelY, + 1, + 1, + this._readBuffer + ) + + // R = element index, G = depth, A = alpha (0 = miss) + const elementIndexFloat = this._readBuffer[0] + const depth = this._readBuffer[1] + const alpha = this._readBuffer[3] + + // Check if hit (alpha = 0 means background/no hit) + if (alpha === 0) { + return undefined + } + + // Round element index to integer + const elementIndex = Math.round(elementIndexFloat) + + // Check for invalid element index (-1 or very large values) + if (elementIndex < 0 || elementIndex >= 16777215) { + return undefined + } + + // Reconstruct world position from depth + const worldPosition = this.reconstructWorldPosition(screenPos, depth, camera) + + return new GpuPickResult(elementIndex, worldPosition, this._scene.vims) + } + + /** + * Reconstructs world position from screen coordinates and depth value. + */ + private reconstructWorldPosition( + screenPos: THREE.Vector2, + depth: number, + camera: THREE.Camera + ): THREE.Vector3 { + // Convert to NDC coordinates + const ndcX = screenPos.x * 2 - 1 + const ndcY = (1 - screenPos.y) * 2 - 1 + + // Unproject to get ray direction + const rayEnd = new THREE.Vector3(ndcX, ndcY, 1).unproject(camera) + const rayDir = rayEnd.sub(camera.position).normalize() + + // Get camera forward direction + const cameraDir = new THREE.Vector3() + camera.getWorldDirection(cameraDir) + + // depth = dot(worldPos - cameraPos, cameraDir) + // worldPos = cameraPos + rayDir * t + // depth = dot(rayDir * t, cameraDir) = t * dot(rayDir, cameraDir) + // t = depth / dot(rayDir, cameraDir) + const t = depth / rayDir.dot(cameraDir) + const worldPos = camera.position.clone().add(rayDir.clone().multiplyScalar(t)) + + return worldPos + } + + /** + * Tests GPU picking at the given screen position and places a red debug sphere + * at the hit world position for visual verification. + * + * @param screenPos Screen position in 0-1 range (0,0 is top-left). Defaults to center. + * @returns The pick result, or undefined if no hit + */ + testPick(screenPos?: THREE.Vector2): GpuPickResult | undefined { + const pos = screenPos ?? new THREE.Vector2(0.5, 0.5) + const result = this.pick(pos) + + console.log('GPU Pick test at:', pos.x.toFixed(3), pos.y.toFixed(3)) + + if (!result) { + console.log('GPU Pick - miss (no geometry)') + return undefined + } + + const element = result.getElement() + console.log('GPU Pick - elementIndex:', result.elementIndex) + console.log('GPU Pick - element:', element) + console.log('GPU Pick - worldPosition:', result.worldPosition.toArray().map(v => v.toFixed(2))) + + // Remove old debug sphere if exists + if (this._debugSphere) { + this._scene.threeScene.remove(this._debugSphere) + this._debugSphere.geometry.dispose() + ;(this._debugSphere.material as THREE.Material).dispose() + } + + // Create new debug sphere at hit position + const geometry = new THREE.SphereGeometry(0.5, 16, 16) + const material = new THREE.MeshBasicMaterial({ color: 0xff0000 }) + this._debugSphere = new THREE.Mesh(geometry, material) + this._debugSphere.position.copy(result.worldPosition) + this._debugSphere.layers.set(1) // NoRaycast layer + + this._scene.threeScene.add(this._debugSphere) + + // Request re-render + this._renderer.domElement.dispatchEvent(new Event('needsUpdate')) + + return result + } + + /** + * Removes the debug sphere from the scene. + */ + clearDebugSphere(): void { + if (this._debugSphere) { + this._scene.threeScene.remove(this._debugSphere) + this._debugSphere.geometry.dispose() + ;(this._debugSphere.material as THREE.Material).dispose() + this._debugSphere = undefined + } + } + + /** + * Disposes of all resources. + */ + dispose(): void { + this.clearDebugSphere() + this._renderTarget.dispose() + this._pickingMaterial.dispose() + } +} diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts index a91fde838..aee0f8960 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts @@ -14,7 +14,7 @@ import { RenderingSection } from './renderingSection' import { RenderingComposer } from './renderingComposer' import { ViewerSettings } from '../settings/viewerSettings' import { SignalDispatcher } from 'ste-signals' -import { DepthRenderer } from './depthRenderer' +import { GpuPicker, GpuPickResult } from './gpuPicker' /** * Manages how vim objects are added and removed from the THREE.Scene to be rendered @@ -56,7 +56,7 @@ export class Renderer implements IRenderer { // 3GB private maxMemory = 3 * Math.pow(10, 9) private _outlineCount = 0 - private _depthRenderer: DepthRenderer | undefined + private _gpuPicker: GpuPicker | undefined /** @@ -134,7 +134,7 @@ export class Renderer implements IRenderer { this.renderer.forceContextLoss() this.renderer.dispose() this._composer.dispose() - this._depthRenderer?.dispose() + this._gpuPicker?.dispose() } /** @@ -264,66 +264,65 @@ export class Renderer implements IRenderer { } /** - * Renders the scene depth to a PNG image and triggers download. - * @param mousePos Optional normalized mouse position (0-1) for debug sphere placement. + * Gets the GPU picker instance, lazily creating it if needed. */ - testDepthRender(mousePos?: THREE.Vector2): void { - // Get size from the viewport (more reliable than renderer.getSize during init) + private getGpuPicker(): GpuPicker | undefined { const size = this._viewport.getParentSize() if (size.x === 0 || size.y === 0) { - console.error('Cannot render depth: viewport has zero size') - return + return undefined } - // Lazily create depth renderer - if (!this._depthRenderer) { - this._depthRenderer = new DepthRenderer( + if (!this._gpuPicker) { + this._gpuPicker = new GpuPicker( this.renderer, this._camera, this._scene, + this.section, size.x, size.y ) } // Ensure size is current - this._depthRenderer.setSize(size.x, size.y) + this._gpuPicker.setSize(size.x, size.y) + return this._gpuPicker + } - // Render and save - this._depthRenderer.renderAndSave(mousePos) + /** + * Performs GPU-based picking at the given mouse position. + * Returns a result object with elementIndex, worldPosition, and getElement() method. + * + * @param mousePos Normalized mouse position (0-1). Defaults to center. + * @returns Pick result with element index, world position, and getElement(), or undefined if no hit + */ + gpuPick(mousePos?: THREE.Vector2): GpuPickResult | undefined { + const picker = this.getGpuPicker() + if (!picker) return undefined - // Trigger re-render to show debug sphere - this.needsUpdate = true + const pos = mousePos ?? new THREE.Vector2(0.5, 0.5) + return picker.pick(pos) } /** - * Tests element picking at the given mouse position using GPU-based picking. + * Tests GPU picking at the given mouse position and places a red debug sphere + * at the hit world position for visual verification. * @param mousePos Optional normalized mouse position (0-1). Defaults to center. - * @returns The element index at the mouse position, or undefined if no geometry hit. + * @returns The pick result, or undefined if no hit */ - testElementPick(mousePos?: THREE.Vector2): number | undefined { - const size = this._viewport.getParentSize() - - if (size.x === 0 || size.y === 0) { + testGpuPick(mousePos?: THREE.Vector2): GpuPickResult | undefined { + const picker = this.getGpuPicker() + if (!picker) { + console.error('Cannot test GPU pick: viewport has zero size') return undefined } - // Lazily create depth renderer - if (!this._depthRenderer) { - this._depthRenderer = new DepthRenderer( - this.renderer, - this._camera, - this._scene, - size.x, - size.y - ) - } + const result = picker.testPick(mousePos) - // Ensure size is current - this._depthRenderer.setSize(size.x, size.y) + // Trigger re-render to show debug sphere + this.needsUpdate = true - return this._depthRenderer.testElementPick(mousePos) + return result } /** From 36e1c23651f611aabd97ff09db7f7c88713de431 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 12:32:45 -0500 Subject: [PATCH 09/94] gpu picking integration --- src/vim-web/core-viewers/shared/raycaster.ts | 14 ++-- .../core-viewers/webgl/viewer/raycaster.ts | 43 +--------- .../webgl/viewer/rendering/gpuPicker.ts | 83 ++++++++++++++++++- .../webgl/viewer/rendering/renderScene.ts | 4 + .../core-viewers/webgl/viewer/viewer.ts | 17 ++-- 5 files changed, 105 insertions(+), 56 deletions(-) diff --git a/src/vim-web/core-viewers/shared/raycaster.ts b/src/vim-web/core-viewers/shared/raycaster.ts index 5ebbe45f8..5db6f5d37 100644 --- a/src/vim-web/core-viewers/shared/raycaster.ts +++ b/src/vim-web/core-viewers/shared/raycaster.ts @@ -5,9 +5,9 @@ export interface IRaycastResult{ object: T | undefined; /** The 3D world position of the hit point */ - worldPosition: THREE.Vector3; - /** The surface normal at the hit point */ - worldNormal: THREE.Vector3; + worldPosition: THREE.Vector3 | undefined; + /** The surface normal at the hit point (may be undefined for GPU picking) */ + worldNormal: THREE.Vector3 | undefined; } /** @@ -18,16 +18,16 @@ export interface IRaycaster { /** * Raycasts from camera to the screen position to find the first object hit. * @param position - The screen position to raycast from. - * @returns A promise that resolves to the raycast result. + * @returns A promise that resolves to the raycast result, or undefined if no hit. */ - raycastFromScreen(position: THREE.Vector2): Promise>; + raycastFromScreen(position: THREE.Vector2): Promise | undefined>; /** * Raycasts from camera to world position to find the first object hit. * @param position - The world position to raycast from. - * @returns A promise that resolves to the raycast result. + * @returns A promise that resolves to the raycast result, or undefined if no hit. */ - raycastFromWorld(position: THREE.Vector3): Promise>; + raycastFromWorld(position: THREE.Vector3): Promise | undefined>; /** * GPU-based raycast that returns only the world position of the first hit. diff --git a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts index e742c3bca..ce1d03376 100644 --- a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts +++ b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts @@ -10,7 +10,6 @@ import { Camera } from './camera/camera' import { Renderer } from './rendering/renderer' import { Marker } from './gizmos/markers/gizmoMarker' import { GizmoMarkers } from './gizmos/markers/gizmoMarkers' -import { GpuPicker } from './rendering/gpuPicker' import type { IRaycaster as IRaycasterBase, IRaycastResult as IRaycastResultBase, @@ -53,13 +52,14 @@ export class RaycastResult implements IRaycastResult { } /** - * Performs raycasting operations. + * Performs CPU-based raycasting operations using Three.js. + * This is kept as a reference/fallback implementation. + * The primary raycaster is GpuPicker which implements IRaycaster. */ export class Raycaster implements IRaycaster { private _camera: Camera private _scene: RenderScene private _renderer: Renderer - private _gpuPicker: GpuPicker private _raycaster = new THREE.Raycaster() @@ -67,43 +67,6 @@ export class Raycaster implements IRaycaster { this._camera = camera this._scene = scene this._renderer = renderer - - // Initialize GPU picker for world position queries - const size = renderer.renderer.getSize(new THREE.Vector2()) - this._gpuPicker = new GpuPicker( - renderer.renderer, - camera, - scene, - renderer.section, - size.x || 1, - size.y || 1 - ) - } - - /** - * Updates the GPU picker render target size. - * Called when the viewport is resized. - */ - setSize(width: number, height: number): void { - this._gpuPicker.setSize(width, height) - } - - /** - * GPU-based raycast that returns only the world position of the first hit. - * Optimized for camera operations where object identification is not needed. - * @param position Screen position in 0-1 range (0,0 is top-left) - * @returns World position of the first hit, or undefined if no geometry at position - */ - raycastWorldPosition(position: THREE.Vector2): THREE.Vector3 | undefined { - if (!Validation.isRelativeVector2(position)) return undefined - return this._gpuPicker.pick(position)?.worldPosition - } - - /** - * Disposes of resources used by the raycaster. - */ - dispose(): void { - this._gpuPicker.dispose() } /** diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index 87ef257fe..e0fee46f4 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -9,12 +9,17 @@ import { RenderingSection } from './renderingSection' import { PickingMaterial } from '../../loader/materials/pickingMaterial' import { Element3D } from '../../loader/element3d' import { Vim } from '../../loader/vim' +import type { IRaycaster, IRaycastResult } from '../../../shared' +import { Marker } from '../gizmos/markers/gizmoMarker' + +/** Raycastable objects for the GpuPicker */ +export type GpuRaycastableObject = Element3D | Marker /** * Result of a GPU pick operation containing element index and world position. - * Similar to RaycastResult but for GPU-based picking. + * Implements IRaycastResult for compatibility with the raycaster interface. */ -export class GpuPickResult { +export class GpuPickResult implements IRaycastResult { /** The element index in the vim */ readonly elementIndex: number /** The world position of the hit */ @@ -28,6 +33,22 @@ export class GpuPickResult { this._vims = vims } + /** + * The object property for IRaycastResult interface. + * Returns the Element3D for the picked element. + */ + get object(): Element3D | undefined { + return this.getElement() + } + + /** + * The world normal at the hit point. + * GPU picking doesn't provide normals, so this returns undefined. + */ + get worldNormal(): THREE.Vector3 | undefined { + return undefined + } + /** * Gets the Element3D object for the picked element. * Searches through all loaded vims to find the element. @@ -44,13 +65,13 @@ export class GpuPickResult { /** * Unified GPU picker that outputs both element index and depth in a single render pass. - * Replaces the separate DepthRenderer and DepthPicker classes. + * Implements IRaycaster for compatibility with the viewer's raycaster interface. * * Uses a Float32 render target with: * - R = element index (supports up to 16M elements) * - G = depth (distance along camera direction) */ -export class GpuPicker { +export class GpuPicker implements IRaycaster { private _renderer: THREE.WebGLRenderer private _camera: Camera private _scene: RenderScene @@ -270,6 +291,60 @@ export class GpuPicker { } } + /** + * Raycasts from camera to the screen position to find the first object hit. + * Implements IRaycaster interface. + * @param position - Screen position in 0-1 range (0,0 is top-left) + * @returns A promise that resolves to the raycast result, or undefined if no hit + */ + raycastFromScreen(position: THREE.Vector2): Promise { + return Promise.resolve(this.pick(position)) + } + + /** + * Raycasts from camera towards a world position to find the first object hit. + * Implements IRaycaster interface. + * @param position - The world position to raycast towards + * @returns A promise that resolves to the raycast result, or undefined if no hit + */ + raycastFromWorld(position: THREE.Vector3): Promise { + const screenPos = this.worldToScreen(position) + if (!screenPos) return Promise.resolve(undefined) + return Promise.resolve(this.pick(screenPos)) + } + + /** + * GPU-based raycast that returns only the world position of the first hit. + * Optimized for camera operations where object identification is not needed. + * @param position - Screen position in 0-1 range (0,0 is top-left) + * @returns World position of the first hit, or undefined if no geometry at position + */ + raycastWorldPosition(position: THREE.Vector2): THREE.Vector3 | undefined { + return this.pick(position)?.worldPosition + } + + /** + * Converts a world position to screen coordinates (0-1 range). + * @param worldPos - The world position to convert + * @returns Screen position in 0-1 range, or undefined if behind camera + */ + private worldToScreen(worldPos: THREE.Vector3): THREE.Vector2 | undefined { + const camera = this._camera.three + camera.updateMatrixWorld(true) + + // Project world position to NDC + const ndc = worldPos.clone().project(camera) + + // Check if behind camera + if (ndc.z > 1) return undefined + + // Convert NDC (-1 to 1) to screen coordinates (0 to 1) + const screenX = (ndc.x + 1) / 2 + const screenY = (1 - ndc.y) / 2 + + return new THREE.Vector2(screenX, screenY) + } + /** * Disposes of all resources. */ diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts index 9dd37b595..b48a15c32 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts @@ -31,6 +31,10 @@ export class RenderScene { return this._vimScenes.flatMap((s) => s.meshes) } + get vims() { + return this._vimScenes.map((s) => s.vim).filter((v) => v !== undefined) + } + constructor () { this.threeScene = new THREE.Scene() } diff --git a/src/vim-web/core-viewers/webgl/viewer/viewer.ts b/src/vim-web/core-viewers/webgl/viewer/viewer.ts index ea6775cb7..457f3cadd 100644 --- a/src/vim-web/core-viewers/webgl/viewer/viewer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/viewer.ts @@ -8,7 +8,8 @@ import * as THREE from 'three' import { Camera } from './camera/camera' import { Environment } from './environment/environment' import { Gizmos } from './gizmos/gizmos' -import { IRaycaster, Raycaster } from './raycaster' +import { IRaycaster } from './raycaster' +import { GpuPicker } from './rendering/gpuPicker' import { RenderScene } from './rendering/renderScene' import { createSelection, ISelection } from './selection' import { createViewerSettings, PartialViewerSettings, ViewerSettings } from './settings/viewerSettings' @@ -124,16 +125,22 @@ export class Viewer { // Input and Selection this.selection = createSelection() - this.raycaster = new Raycaster( + + // GPU-based raycaster for element picking and world position queries + const size = this.renderer.renderer.getSize(new THREE.Vector2()) + this.raycaster = new GpuPicker( + this.renderer.renderer, this._camera, scene, - this.renderer + this.renderer.section, + size.x || 1, + size.y || 1 ) // Update raycaster size on viewport resize this.viewport.onResize.sub(() => { const size = this.viewport.getParentSize() - ;(this.raycaster as Raycaster).setSize(size.x, size.y) + ;(this.raycaster as GpuPicker).setSize(size.x, size.y) }) this.inputs.init() @@ -222,7 +229,7 @@ export class Viewer { this.selection.clear() this.viewport.dispose() this.renderer.dispose() - ;(this.raycaster as Raycaster).dispose() + ;(this.raycaster as GpuPicker).dispose() this.inputs.unregisterAll() this._vims.forEach((v) => v?.dispose()) this.materials.dispose() From 8201e816e6ad7115f0ba673b6e5f5faaf57559f2 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 12:33:14 -0500 Subject: [PATCH 10/94] back to normal main --- src/main.tsx | 106 +++++---------------------------------------------- 1 file changed, 9 insertions(+), 97 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 5842a642c..9493bc6e0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -29,13 +29,20 @@ function App() { const viewerRef = useRef() useEffect(() => { if(window.location.pathname.includes('ultra')){ - createUltra(viewerRef, div.current!) + createUltra(viewerRef, div.current!) } else{ createWebgl(viewerRef, div.current!) } + // Handle page destroy (tab close, navigation away) + const handleBeforeUnload = () => { + viewerRef.current?.dispose() + } + window.addEventListener('beforeunload', handleBeforeUnload) + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) viewerRef.current?.dispose() } }, []) @@ -47,18 +54,13 @@ function App() { async function createWebgl (viewerRef: MutableRefObject, div: HTMLDivElement) { const viewer = await VIM.React.Webgl.createViewer(div, {ui: { - panelBimInfo: false, - panelPerformance: false, - panelAxes: false, - panelBimTree: false, - panelControlBar: false, - panelLogo: true, }}) viewerRef.current = viewer globalThis.viewer = viewer // for testing in browser console const url = getPathFromUrl() ?? 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' + //const url = getPathFromUrl() ?? 'https://vimdevelopment01storage.blob.core.windows.net/samples/Navis-Kajima.vim' const request = viewer.loader.request( { url }, ) @@ -68,96 +70,6 @@ async function createWebgl (viewerRef: MutableRefObject, div: HTMLDiv viewer.camera.frameScene.call() } - // Add keyboard shortcut for depth picking test - addDepthPickerTest(viewer, div) -} - -function addDepthPickerTest(viewer: VIM.React.Webgl.ViewerRef, container: HTMLDivElement) { - // Track mouse position - let mousePos = new VIM.THREE.Vector2(0.5, 0.5) - - container.addEventListener('mousemove', (e) => { - const rect = container.getBoundingClientRect() - mousePos.x = (e.clientX - rect.left) / rect.width - mousePos.y = (e.clientY - rect.top) / rect.height - }) - - // Store created spheres for cleanup - const spheres: VIM.THREE.Mesh[] = [] - - // Show instructions - const instructions = document.createElement('div') - instructions.textContent = 'T=depth pick, C=clear, X=depth image, E=element pick' - instructions.style.cssText = 'position:absolute;top:10px;left:10px;z-index:1000;padding:8px 16px;background:rgba(0,0,0,0.7);color:white;font-family:monospace;' - container.appendChild(instructions) - - // Keyboard handler for T, C, and E (keydown for responsiveness) - window.addEventListener('keydown', (e) => { - if (e.key === 't' || e.key === 'T') { - // Call the new GPU raycast API - const worldPos = viewer.core.raycaster.raycastWorldPosition?.(mousePos) - - if (worldPos) { - console.log('Depth pick hit:', worldPos) - - // Create a small sphere at the hit position - const geometry = new VIM.THREE.SphereGeometry(0.1, 16, 16) - const material = new VIM.THREE.MeshBasicMaterial({ color: 0xff0000 }) - const sphere = new VIM.THREE.Mesh(geometry, material) - sphere.position.copy(worldPos) - - viewer.core.renderer.add(sphere) - spheres.push(sphere) - - // Request render update - viewer.core.renderer.needsUpdate = true - } else { - console.log('Depth pick miss - no geometry at position') - } - } - - if (e.key === 'c' || e.key === 'C') { - spheres.forEach(s => { - viewer.core.renderer.remove(s) - s.geometry.dispose() - ;(s.material as VIM.THREE.MeshBasicMaterial).dispose() - }) - spheres.length = 0 - viewer.core.renderer.needsUpdate = true - console.log('Spheres cleared') - } - - if (e.key === 'c' || e.key === 'C') { - // Test element picking using GPU-based picking - const elementIndex = viewer.core.renderer.testElementPick(mousePos) - - if (elementIndex !== undefined && elementIndex >= 0) { - // Get the first loaded vim - const vim = viewer.core.vims.at(0) - if (vim) { - const element = vim.getElementFromIndex(elementIndex) - console.log('Element pick - index:', elementIndex, 'element:', element) - - // Select the element to verify - if (element) { - viewer.core.selection.select(element) - } - } else { - console.log('Element pick - index:', elementIndex, '(no vim loaded)') - } - } else { - console.log('Element pick - no element at position') - } - } - }) - - // X key on keyup to only fire once (not on repeat) - window.addEventListener('keyup', (e) => { - if (e.key === 'x' || e.key === 'X') { - // Test depth render - downloads depth buffer as PNG, places sphere at mouse position - viewer.core.renderer.testDepthRender(mousePos) - } - }) } async function createUltra (viewerRef: MutableRefObject, div: HTMLDivElement) { From b65b6448a5536a46cca6c4533923fbf22ad5b89d Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 13:08:23 -0500 Subject: [PATCH 11/94] multivim support --- .../webgl/loader/materials/pickingMaterial.ts | 12 +++++-- .../loader/progressive/insertableGeometry.ts | 22 +++++++++++- .../loader/progressive/insertableMesh.ts | 5 +-- .../progressive/instancedMeshFactory.ts | 22 +++++++++++- .../loader/progressive/legacyMeshFactory.ts | 8 +++-- .../webgl/loader/progressive/open.ts | 4 +-- .../webgl/loader/progressive/subsetBuilder.ts | 6 ++-- .../webgl/loader/progressive/subsetRequest.ts | 12 ++++--- .../core-viewers/webgl/loader/vimSettings.ts | 12 ++++++- .../webgl/viewer/rendering/gpuPicker.ts | 34 +++++++++++-------- src/vim-web/react-viewers/webgl/loading.ts | 5 ++- 11 files changed, 108 insertions(+), 34 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts index 63f98f42f..7b7372cdd 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts @@ -11,7 +11,8 @@ import * as THREE from 'three' * Output format (Float32 RGBA): * - R = element index (float, supports up to 16M elements) * - G = depth (distance along camera direction) - * - B, A = unused (set to 0.0 and 1.0) + * - B = vim index (identifies which vim the element belongs to) + * - A = hit flag (1.0) * * @returns A custom shader material for GPU picking. */ @@ -32,8 +33,11 @@ export function createPickingMaterial() { attribute float ignore; // Element index attribute for GPU picking attribute float elementIndex; + // Vim index attribute for GPU picking + attribute float vimIndex; varying float vElementIndex; + varying float vVimIndex; varying vec3 vWorldPos; void main() { @@ -49,6 +53,7 @@ export function createPickingMaterial() { } vElementIndex = elementIndex; + vVimIndex = vimIndex; // Compute world position for depth calculation #ifdef USE_INSTANCING @@ -67,6 +72,7 @@ export function createPickingMaterial() { uniform vec3 uCameraDir; varying float vElementIndex; + varying float vVimIndex; varying vec3 vWorldPos; void main() { @@ -77,8 +83,8 @@ export function createPickingMaterial() { vec3 toVertex = vWorldPos - uCameraPos; float depth = dot(toVertex, uCameraDir); - // Output: R = element index, G = depth, B = 0, A = 1 - gl_FragColor = vec4(vElementIndex, depth, 0.0, 1.0); + // Output: R = element index, G = depth, B = vim index, A = 1 + gl_FragColor = vec4(vElementIndex, depth, vVimIndex, 1.0); } ` }) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 0750ebfbe..6b5adf15f 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -35,7 +35,9 @@ export class InsertableGeometry { private _vertexAttribute: THREE.BufferAttribute private _colorAttribute: THREE.BufferAttribute private _elementIndexAttribute: THREE.Float32BufferAttribute + private _vimIndexAttribute: THREE.Float32BufferAttribute private _mapping: ElementMapping | undefined + private _vimIndex: number private _updateStartMesh = 0 private _updateEndMesh = 0 @@ -45,11 +47,13 @@ export class InsertableGeometry { offsets: G3dMeshOffsets, materials: G3dMaterial, transparent: boolean, - mapping?: ElementMapping + mapping?: ElementMapping, + vimIndex: number = 0 ) { this.offsets = offsets this.materials = materials this._mapping = mapping + this._vimIndex = vimIndex this._indexAttribute = new THREE.Uint32BufferAttribute( offsets.counts.indices, @@ -73,6 +77,12 @@ export class InsertableGeometry { 1 ) + // Vim index attribute for GPU picking (one per vertex) + this._vimIndexAttribute = new THREE.Float32BufferAttribute( + offsets.counts.vertices, + 1 + ) + // this._indexAttribute.count = 0 // this._vertexAttribute.count = 0 // this._colorAttribute.count = 0 @@ -82,6 +92,7 @@ export class InsertableGeometry { this.geometry.setAttribute('position', this._vertexAttribute) this.geometry.setAttribute('color', this._colorAttribute) this.geometry.setAttribute('elementIndex', this._elementIndexAttribute) + this.geometry.setAttribute('vimIndex', this._vimIndexAttribute) this.boundingBox = offsets.subset.getBoundingBox() if (this.boundingBox) { @@ -265,6 +276,7 @@ export class InsertableGeometry { vector.applyMatrix4(matrix) this.setVertex(vertexOffset + vertexOut, vector) this.setElementIndex(vertexOffset + vertexOut, elementIndex) + this.setVimIndex(vertexOffset + vertexOut, this._vimIndex) submesh.expandBox(vector) vertexOut++ } @@ -298,6 +310,10 @@ export class InsertableGeometry { this._elementIndexAttribute.setX(index, elementIndex) } + private setVimIndex (index: number, vimIndex: number) { + this._vimIndexAttribute.setX(index, vimIndex) + } + private expandBox (box: THREE.Box3) { if (!box) return this.boundingBox = this.boundingBox?.union(box) ?? box.clone() @@ -346,6 +362,10 @@ export class InsertableGeometry { this._elementIndexAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) this._elementIndexAttribute.needsUpdate = true + // update vim indices (itemSize is 1) + this._vimIndexAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) + this._vimIndexAttribute.needsUpdate = true + if (this._computeBoundingBox) { this.geometry.computeBoundingBox() this.geometry.computeBoundingSphere() diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts index a07227ed3..96904fccb 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts @@ -51,12 +51,13 @@ export class InsertableMesh { offsets: G3dMeshOffsets, materials: G3dMaterial, transparent: boolean, - mapping?: ElementMapping + mapping?: ElementMapping, + vimIndex: number = 0 ) { this.offsets = offsets this.transparent = transparent - this.geometry = new InsertableGeometry(offsets, materials, transparent, mapping) + this.geometry = new InsertableGeometry(offsets, materials, transparent, mapping, vimIndex) this._material = transparent ? Materials.getInstance().transparent.material diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 94a431fd7..68a2dfbb9 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -12,10 +12,12 @@ import { ElementMapping } from '../elementMapping' export class InstancedMeshFactory { materials: G3dMaterial private _mapping: ElementMapping | undefined + private _vimIndex: number - constructor (materials: G3dMaterial, mapping?: ElementMapping) { + constructor (materials: G3dMaterial, mapping?: ElementMapping, vimIndex: number = 0) { this.materials = materials this._mapping = mapping + this._vimIndex = vimIndex } createTransparent (mesh: G3dMesh, instances: number[]) { @@ -90,6 +92,7 @@ export class InstancedMeshFactory { this.setMatricesFromVimx(threeMesh, g3d, instances) this.setElementIndices(threeMesh, instances ?? g3d.meshInstances[mesh]) + this.setVimIndices(threeMesh, instances ?? g3d.meshInstances[mesh]) const result = new InstancedMesh(g3d, threeMesh, instances) return result } @@ -184,4 +187,21 @@ export class InstancedMeshFactory { new THREE.InstancedBufferAttribute(elementIndices, 1) ) } + + /** + * Adds per-instance vim index attribute for GPU picking. + */ + private setVimIndices ( + three: THREE.InstancedMesh, + instances: number[] + ) { + const vimIndices = new Float32Array(instances.length) + for (let i = 0; i < instances.length; i++) { + vimIndices[i] = this._vimIndex + } + three.geometry.setAttribute( + 'vimIndex', + new THREE.InstancedBufferAttribute(vimIndices, 1) + ) + } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts index 563260e68..48207bd6c 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts @@ -18,13 +18,15 @@ export class VimMeshFactory { private _instancedFactory: InstancedMeshFactory private _scene: Scene private _mapping: ElementMapping + private _vimIndex: number - constructor (g3d: G3d, materials: G3dMaterial, scene: Scene, mapping: ElementMapping) { + constructor (g3d: G3d, materials: G3dMaterial, scene: Scene, mapping: ElementMapping, vimIndex: number = 0) { this.g3d = g3d this._materials = materials this._scene = scene this._mapping = mapping - this._instancedFactory = new InstancedMeshFactory(materials, mapping) + this._vimIndex = vimIndex + this._instancedFactory = new InstancedMeshFactory(materials, mapping, vimIndex) } /** @@ -55,7 +57,7 @@ export class VimMeshFactory { transparent: boolean ) { const offsets = subset.getOffsets(section) - const opaque = new InsertableMesh(offsets, this._materials, transparent, this._mapping) + const opaque = new InsertableMesh(offsets, this._materials, transparent, this._mapping, this._vimIndex) const count = subset.getMeshCount() for (let m = 0; m < count; m++) { diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/open.ts b/src/vim-web/core-viewers/webgl/loader/progressive/open.ts index c044186e1..5d575c4ae 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/open.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/open.ts @@ -97,7 +97,7 @@ async function loadFromVimX ( // wait for bim data. // const bim = bimPromise ? await bimPromise : undefined - const builder = new VimxSubsetBuilder(vimx, scene) + const builder = new VimxSubsetBuilder(vimx, scene, settings.vimIndex) const vim = new Vim( vimx.header, @@ -146,7 +146,7 @@ async function loadFromVim ( // Create scene and factory WITH mapping const scene = new Scene(settings.matrix) - const factory = new VimMeshFactory(g3d, materials, scene, mapping) + const factory = new VimMeshFactory(g3d, materials, scene, mapping, fullSettings.vimIndex) const header = await requestHeader(bfast) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts b/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts index eb64382e0..a3989bc10 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts @@ -72,6 +72,7 @@ export class VimSubsetBuilder implements SubsetBuilder { export class VimxSubsetBuilder { private _localVimx: Vimx private _scene: Scene + private _vimIndex: number private _set = new Set() private _onUpdate = new SignalDispatcher() @@ -83,9 +84,10 @@ export class VimxSubsetBuilder { return this._set.size > 0 } - constructor (localVimx: Vimx, scene: Scene) { + constructor (localVimx: Vimx, scene: Scene, vimIndex: number = 0) { this._localVimx = localVimx this._scene = scene + this._vimIndex = vimIndex } getFullSet () { @@ -93,7 +95,7 @@ export class VimxSubsetBuilder { } async loadSubset (subset: G3dSubset, settings?: LoadPartialSettings) { - const request = new SubsetRequest(this._scene, this._localVimx, subset) + const request = new SubsetRequest(this._scene, this._localVimx, subset, this._vimIndex) this._set.add(request) this._onUpdate.dispatch() await request.start(settings) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts index c3f54bc04..879da4b80 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts @@ -51,7 +51,7 @@ export class SubsetRequest { return this._subset.getBoundingBox() } - constructor (scene: Scene, localVimx: Vimx, subset: G3dSubset) { + constructor (scene: Scene, localVimx: Vimx, subset: G3dSubset, vimIndex: number = 0) { this._subset = subset this._scene = scene @@ -62,7 +62,9 @@ export class SubsetRequest { this._opaqueMesh = new InsertableMesh( opaqueOffsets, localVimx.materials, - false + false, + undefined, + vimIndex ) this._opaqueMesh.mesh.name = 'Opaque_Merged_Mesh' @@ -70,14 +72,16 @@ export class SubsetRequest { this._transparentMesh = new InsertableMesh( transparentOffsets, localVimx.materials, - true + true, + undefined, + vimIndex ) this._transparentMesh.mesh.name = 'Transparent_Merged_Mesh' this._scene.addMesh(this._transparentMesh) this._scene.addMesh(this._opaqueMesh) - this._meshFactory = new InstancedMeshFactory(localVimx.materials) + this._meshFactory = new InstancedMeshFactory(localVimx.materials, undefined, vimIndex) this._synchronizer = new LoadingSynchronizer( this._uniques, diff --git a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts index cf7e0a472..d7aab26f3 100644 --- a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts +++ b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts @@ -60,6 +60,13 @@ export type VimSettings = { * The time in milliseconds between each scene refresh during progressive loading. */ progressiveInterval: number + + /** + * The index of this vim in the scene's vim array. Used for GPU picking to directly + * identify which vim an element belongs to. If not specified, defaults to 0. + * When loading multiple vims, set this to the current count of vims in the scene. + */ + vimIndex: number } /** @@ -77,7 +84,10 @@ return { // progressive fileType: undefined, progressive: false, - progressiveInterval: 1000 + progressiveInterval: 1000, + + // GPU picking + vimIndex: 0 } } diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index e0fee46f4..9a3b5153d 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -22,15 +22,18 @@ export type GpuRaycastableObject = Element3D | Marker export class GpuPickResult implements IRaycastResult { /** The element index in the vim */ readonly elementIndex: number + /** The vim index identifying which vim the element belongs to */ + readonly vimIndex: number /** The world position of the hit */ readonly worldPosition: THREE.Vector3 - /** Reference to vims for element lookup */ - private _vims: Vim[] + /** Reference to the vim containing the element */ + private _vim: Vim | undefined - constructor(elementIndex: number, worldPosition: THREE.Vector3, vims: Vim[]) { + constructor(elementIndex: number, vimIndex: number, worldPosition: THREE.Vector3, vim: Vim | undefined) { this.elementIndex = elementIndex + this.vimIndex = vimIndex this.worldPosition = worldPosition - this._vims = vims + this._vim = vim } /** @@ -51,25 +54,22 @@ export class GpuPickResult implements IRaycastResult { /** * Gets the Element3D object for the picked element. - * Searches through all loaded vims to find the element. * @returns The Element3D object, or undefined if not found */ getElement(): Element3D | undefined { - for (const vim of this._vims) { - const element = vim.getElementFromIndex(this.elementIndex) - if (element) return element - } - return undefined + return this._vim?.getElementFromIndex(this.elementIndex) } } /** - * Unified GPU picker that outputs both element index and depth in a single render pass. + * Unified GPU picker that outputs element index, depth, and vim index in a single render pass. * Implements IRaycaster for compatibility with the viewer's raycaster interface. * * Uses a Float32 render target with: * - R = element index (supports up to 16M elements) * - G = depth (distance along camera direction) + * - B = vim index (identifies which vim the element belongs to) + * - A = hit flag (1.0) */ export class GpuPicker implements IRaycaster { private _renderer: THREE.WebGLRenderer @@ -180,9 +180,10 @@ export class GpuPicker implements IRaycaster { this._readBuffer ) - // R = element index, G = depth, A = alpha (0 = miss) + // R = element index, G = depth, B = vim index, A = alpha (0 = miss) const elementIndexFloat = this._readBuffer[0] const depth = this._readBuffer[1] + const vimIndexFloat = this._readBuffer[2] const alpha = this._readBuffer[3] // Check if hit (alpha = 0 means background/no hit) @@ -190,8 +191,9 @@ export class GpuPicker implements IRaycaster { return undefined } - // Round element index to integer + // Round element index and vim index to integers const elementIndex = Math.round(elementIndexFloat) + const vimIndex = Math.round(vimIndexFloat) // Check for invalid element index (-1 or very large values) if (elementIndex < 0 || elementIndex >= 16777215) { @@ -201,7 +203,10 @@ export class GpuPicker implements IRaycaster { // Reconstruct world position from depth const worldPosition = this.reconstructWorldPosition(screenPos, depth, camera) - return new GpuPickResult(elementIndex, worldPosition, this._scene.vims) + // Get the vim directly using the vim index + const vim = this._scene.vims[vimIndex] + + return new GpuPickResult(elementIndex, vimIndex, worldPosition, vim) } /** @@ -254,6 +259,7 @@ export class GpuPicker implements IRaycaster { const element = result.getElement() console.log('GPU Pick - elementIndex:', result.elementIndex) + console.log('GPU Pick - vimIndex:', result.vimIndex) console.log('GPU Pick - element:', element) console.log('GPU Pick - worldPosition:', result.worldPosition.toArray().map(v => v.toFixed(2))) diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index fc71ccbdb..970821349 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -114,11 +114,14 @@ export class ComponentLoader { */ request (source: Core.Webgl.RequestSource, settings?: Core.Webgl.VimPartialSettings) { + // Auto-assign vim index based on current vim count for GPU picking + const vimIndex = settings?.vimIndex ?? this._viewer.vims.length + const fullSettings = { ...settings, vimIndex } return new LoadRequest({ onProgress: (p) => this.onProgress(p), onError: (e) => this.onError(e), onDone: () => this.onDone() - }, source, settings) + }, source, fullSettings) } /* From 59fce717cbc07601f0dc0b6b57db33ddc23ec914 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 13:13:52 -0500 Subject: [PATCH 12/94] claude md update --- CLAUDE.md | 586 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 586 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..b7fe16031 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,586 @@ +# VIM Web + +React-based 3D viewers for VIM files with BIM (Building Information Modeling) support. + +## Quick Reference + +### Common Operations + +| Task | WebGL | Ultra | +|------|-------|-------| +| **Create viewer** | `VIM.React.Webgl.createViewer(div, settings)` | `VIM.React.Ultra.createViewer(div, settings)` | +| **Load model** | `viewer.loader.open({ url }, {})` then `viewer.loader.add(vim)` | `viewer.load({ url })` | +| **Get element** | `vim.getElementFromIndex(index)` | `vim.getElementFromIndex(index)` | +| **Select** | `viewer.core.selection.select(element)` | `viewer.core.selection.select(element)` | +| **Frame camera** | `viewer.core.camera.lerp(1).frame(element)` | `viewer.camera.frame.call(element)` | +| **Set visibility** | `element.visible = false` | `element.state = VisibilityState.HIDDEN` | +| **Set color** | `element.color = new THREE.Color(0xff0000)` | `element.color = new RGBA32(0xff0000ff)` | +| **Section box** | `viewer.sectionBox.enable.set(true)` | `viewer.sectionBox.enable.set(true)` | + +### Key File Locations + +| Purpose | Path | +|---------|------| +| Main exports | `src/vim-web/index.ts` | +| WebGL React viewer | `src/vim-web/react-viewers/webgl/viewer.tsx` | +| Ultra React viewer | `src/vim-web/react-viewers/ultra/viewer.tsx` | +| WebGL core viewer | `src/vim-web/core-viewers/webgl/viewer/viewer.ts` | +| Ultra core viewer | `src/vim-web/core-viewers/ultra/viewer.ts` | +| Element3D (WebGL) | `src/vim-web/core-viewers/webgl/loader/element3d.ts` | +| Element3D (Ultra) | `src/vim-web/core-viewers/ultra/element3d.ts` | +| Selection | `src/vim-web/core-viewers/shared/selection.ts` | +| Camera | `src/vim-web/core-viewers/webgl/viewer/camera/` | +| Gizmos | `src/vim-web/core-viewers/webgl/viewer/gizmos/` | +| RPC Client (Ultra) | `src/vim-web/core-viewers/ultra/rpcClient.ts` | +| StateRef hooks | `src/vim-web/react-viewers/helpers/reactUtils.ts` | +| GPU Picker | `src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts` | +| Picking Material | `src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts` | +| InsertableGeometry | `src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts` | +| InstancedMeshFactory | `src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts` | +| VimMeshFactory | `src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts` | + +### Import Pattern + +```typescript +import * as VIM from 'vim-web' + +// Access namespaces +VIM.Core.Webgl.Viewer // WebGL core +VIM.Core.Ultra.Viewer // Ultra core +VIM.React.Webgl.createViewer // React WebGL factory +VIM.React.Ultra.createViewer // React Ultra factory +VIM.THREE // Three.js re-export +``` + +--- + +## Tech Stack + +- **TypeScript 5.7** (strict mode disabled), **React 18.3**, **Vite 6** +- **Three.js 0.171**, **Tailwind CSS 3.4** (`vc-` prefix) +- **ste-events** for typed events, **vim-format** for BIM data + +## Architecture + +### Dual Viewer System + +| Viewer | Use Case | Rendering | +|--------|----------|-----------| +| **WebGL** | Small-medium models | Local Three.js | +| **Ultra** | Large models | Server-side streaming via WebSocket | + +### Layer Separation + +``` +src/vim-web/ +├── core-viewers/ # Framework-agnostic (no React) +│ ├── webgl/ # Local Three.js rendering +│ ├── ultra/ # RPC client for streaming server +│ └── shared/ # Common interfaces (IVim, Selection, Input) +└── react-viewers/ # React UI layer + ├── webgl/ # Full UI (BIM tree, context menu, gizmos) + ├── ultra/ # Minimal UI + └── helpers/ # StateRef, hooks, utilities +``` + +### ViewerRef (React-to-Core API) + +```typescript +// WebGL ViewerRef +type ViewerRef = { + core: Core.Webgl.Viewer // Direct core access + loader: ComponentLoader // Load VIM files + camera: CameraRef // Camera controls + sectionBox: SectionBoxRef // Section box + isolation: IsolationRef // Isolation mode + controlBar: ControlBarRef // Toolbar customization + contextMenu: ContextMenuRef + bimInfo: BimInfoPanelRef + modal: ModalHandle + settings: SettingsRef + dispose: () => void +} + +// Ultra ViewerRef (similar but with RPC-based core) +``` + +--- + +## Core Concepts + +### Element3D + +The primary object representing a BIM element. + +```typescript +const element = vim.getElementFromIndex(301) + +// Properties +element.vim // Parent Vim container +element.element // BIM element index +element.elementId // Unique ID (bigint) +element.hasMesh // Has geometry? +element.isRoom // Is a room? + +// Visual state (WebGL) +element.outline = true // Selection highlight +element.visible = false // Hide +element.focused = true // Focus highlight +element.color = new THREE.Color(0xff0000) // Override color + +// Visual state (Ultra) +element.state = VisibilityState.HIDDEN +element.color = new RGBA32(0xff0000ff) + +// Geometry +const box = await element.getBoundingBox() +const center = await element.getCenter() + +// BIM data +const bimElement = await element.getBimElement() // { name, id, categoryIndex, ... } +const params = await element.getBimParameters() // [{ name, value, group }, ...] +``` + +### Selection + +```typescript +const selection = viewer.core.selection + +// Modify +selection.select(element) // Replace +selection.select([a, b, c]) // Multi-select +selection.add(element) // Add +selection.remove(element) // Remove +selection.toggle(element) // Toggle +selection.clear() // Clear all + +// Query +selection.has(element) // Is selected? +selection.any() // Has selection? +selection.count() // Count +selection.getAll() // Get all + +// Events +selection.onSelectionChanged.subscribe(() => { ... }) + +// Bounding box +const box = await selection.getBoundingBox() +``` + +### Camera (WebGL) + +Fluent API with `snap()` (instant) and `lerp(duration)` (animated). + +```typescript +const camera = viewer.core.camera + +// Frame targets +camera.lerp(1).frame(element) // Animate to frame element +camera.lerp(1).frame('all') // Frame everything +camera.snap().frame(box) // Instant frame box + +// Movement +camera.lerp(1).orbit(new THREE.Vector2(45, 0)) // Orbit by degrees +camera.lerp(1).orbitTowards(new THREE.Vector3(0, 0, -1)) // Top-down +camera.lerp(1).zoom(1.5) // Zoom out +camera.snap().set(position, target) // Set position/target + +// State +camera.position // Current position +camera.target // Look-at target +camera.orthographic = true // Ortho projection +camera.allowedRotation = new THREE.Vector2(0, 0) // Lock rotation + +// Plan view setup +camera.snap().orbitTowards(new THREE.Vector3(0, 0, -1)) +camera.allowedRotation = new THREE.Vector2(0, 0) +camera.orthographic = true +viewer.core.inputs.pointerActive = VIM.Core.PointerMode.PAN +``` + +### Gizmos (WebGL) + +```typescript +// Section Box +viewer.gizmos.sectionBox.clip = true +viewer.gizmos.sectionBox.visible = true +viewer.gizmos.sectionBox.setBox(box) + +// Measure Tool +await viewer.gizmos.measure.start() // Two-click measurement +console.log(viewer.gizmos.measure.measurement.length()) +viewer.gizmos.measure.clear() + +// Markers +const marker = viewer.gizmos.markers.add(position) +viewer.gizmos.markers.remove(marker) +``` + +--- + +## StateRef Pattern + +Custom state management in React layer. Critical for customization. + +```typescript +// StateRef - Observable state +const state: StateRef +state.get() // Read +state.set(true) // Write +state.onChange.subscribe(v => ...) // Subscribe + +// ActionRef - Callable action with middleware +const action: ActionRef +action.call() // Execute +action.prepend(() => before()) // Add pre-hook +action.append(() => after()) // Add post-hook + +// In React components +state.useOnChange((v) => ...) // Hook subscription +state.useMemo((v) => compute(v)) +``` + +--- + +## Input Bindings + +| Input | Action | +|-------|--------| +| Left Drag | Orbit (or mode-specific) | +| Right Drag | Look | +| Middle Drag | Pan | +| Wheel | Zoom | +| Click | Select | +| Shift+Click | Add to selection | +| Double-Click | Frame | +| WASD | Move camera | +| F | Frame selection | +| Escape | Clear selection | +| P | Toggle orthographic | +| Home | Reset camera | + +```typescript +// Register custom key handler +viewer.core.inputs.keyboard.registerKeyDown('KeyR', 'replace', () => { + // Custom action on R key +}) +``` + +--- + +## Ultra Viewer + +Server-side rendering via WebSocket RPC. + +### Connection + +```typescript +// States: 'connecting' | 'validating' | 'connected' | 'disconnected' | 'error' +viewer.core.socket.onStatusUpdate.subscribe(state => ...) +``` + +### Visibility States + +```typescript +import VisibilityState = VIM.Core.Ultra.VisibilityState + +element.state = VisibilityState.VISIBLE // 0 +element.state = VisibilityState.HIDDEN // 1 +element.state = VisibilityState.GHOSTED // 2 +element.state = VisibilityState.HIGHLIGHTED // 16 +``` + +### RPC Pattern + +```typescript +// Fire-and-forget (input events) +viewer.core.rpc.RPCMouseMoveEvent(pos) + +// Request-response (queries) +const hit = await viewer.core.rpc.RPCPerformHitTest(pos) +const box = await viewer.core.rpc.RPCGetAABBForElements(vimIndex, indices) +``` + +--- + +## Customization + +### Control Bar + +```typescript +viewer.controlBar.customize((bar) => [{ + id: 'custom-section', + buttons: [{ + id: 'my-button', + tip: 'My Button', + icon: VIM.React.Icons.checkmark, + action: () => { /* ... */ } + }] +}]) +``` + +### Context Menu + +```typescript +viewer.contextMenu.customize((menu) => [ + ...menu, // Keep existing + { + id: 'custom', + label: 'Custom Action', + enabled: true, + action: () => { /* ... */ } + } +]) +``` + +### BIM Info Panel + +```typescript +// Modify values +viewer.bimInfo.onRenderHeaderEntryValue = data => <>{data.data.value + " !"} + +// Add custom data +viewer.bimInfo.onData = data => { + data.body.push({ + title: 'Custom Section', key: 'custom', + content: [{ title: 'Group', key: 'g', content: [{ label: 'Field', value: 'Value' }] }] + }) + return Promise.resolve(data) +} +``` + +--- + +## Code Examples + +### Basic Setup + +```typescript +import * as VIM from 'vim-web' + +const viewer = await VIM.React.Webgl.createViewer(containerDiv, { + isolation: { enabled: 'auto', useGhostMaterial: true } +}) + +const vim = await viewer.loader.open({ url: 'model.vim' }, {}) +viewer.loader.add(vim) +viewer.camera.frameScene.call() + +// Cleanup +viewer.dispose() +``` + +### Load Local File + +```typescript +const file = inputElement.files[0] +const buffer = await file.arrayBuffer() +viewer.modal.loading({ progress: -1, message: 'Loading...' }) +try { + const vim = await viewer.loader.open({ buffer }, {}) + viewer.loader.add(vim) +} finally { + viewer.modal.loading(undefined) + viewer.camera.frameScene.call() +} +``` + +### Isolate Element + +```typescript +const target = vim.getElementFromIndex(301) +vim.getAllElements().forEach(e => e.visible = e === target) +vim.scene.material = [viewer.core.materials.simple, viewer.core.materials.ghost] +``` + +### Color by Height + +```typescript +const box = await vim.getBoundingBox() +for (const e of vim.getAllElements()) { + if (!e.hasMesh) continue + const center = await e.getCenter() + const t = (center.z - box.min.z) / (box.max.z - box.min.z) + e.color = new VIM.THREE.Color().lerpColors( + new VIM.THREE.Color(0x0000ff), + new VIM.THREE.Color(0xff0000), + t + ) +} +``` + +### Section Box from Selection + +```typescript +const box = await viewer.core.selection.getBoundingBox() +viewer.sectionBox.enable.set(true) +viewer.sectionBox.sectionBox.call(box) +``` + +### Screenshot + +```typescript +viewer.core.renderer.needsUpdate = true +viewer.core.renderer.render() +const url = viewer.core.renderer.renderer.domElement.toDataURL('image/png') +const link = document.createElement('a') +link.href = url +link.download = 'screenshot.png' +link.click() +``` + +### Access BIM Data + +```typescript +viewer.core.selection.onSelectionChanged.subscribe(async () => { + const elements = viewer.core.selection.getAll().filter(e => e.type === 'Element3D') + if (!elements.length) return + + const bim = await elements[0].getBimElement() + console.log(bim.name, bim.id) + + const params = await elements[0].getBimParameters() + params.forEach(p => console.log(`${p.name}: ${p.value}`)) +}) +``` + +--- + +## Naming Conventions + +| Pattern | Usage | Example | +|---------|-------|---------| +| `I` prefix | Interfaces | `IVim`, `ICamera` | +| `Ref` suffix | Reference types | `StateRef`, `ViewerRef` | +| `use` prefix | React hooks | `useStateRef` | +| `vc-` prefix | Tailwind classes | `vc-flex` | +| `--c-` prefix | CSS variables | `--c-primary` | + +## Code Style + +- Prettier: no semicolons, trailing commas, single quotes +- Index files control module exports +- No test framework configured + +## Commands + +```bash +npm run dev # Dev server (localhost:5173) +npm run build # Production build +npm run eslint # Lint +npm run documentation # TypeDoc +``` + +--- + +## Architecture Details + +### Rendering Pipeline (WebGL) + +``` +Main Scene (MSAA) → Selection Mask → Outline Pass → FXAA → Merge → Screen +``` + +- On-demand rendering: `renderer.needsUpdate = true` +- Key files: `rendering/renderer.ts`, `renderingComposer.ts` + +### Mesh Building (WebGL) + +- **≤5 instances**: Merged into `InsertableMesh` (chunks at 4M vertices) +- **>5 instances**: GPU instanced via `InstancedMesh` +- Key file: `loader/progressive/legacyMeshFactory.ts` + +### GPU Picking (WebGL) + +GPU-based object picking using a custom shader that renders element metadata to a Float32 render target. + +**Render Target Format (RGBA Float32):** +- R = element index (supports up to 16M elements) +- G = depth (distance along camera direction) +- B = vim index (identifies which vim the element belongs to) +- A = hit flag (1.0 = hit, 0.0 = miss) + +**Key Files:** +| File | Purpose | +|------|---------| +| `gpuPicker.ts` | Main picker class, reads render target | +| `pickingMaterial.ts` | Custom shader material | +| `insertableGeometry.ts` | Merged mesh geometry with per-vertex attributes | +| `instancedMeshFactory.ts` | Instanced mesh with per-instance attributes | + +**Adding New Data to GPU Picker:** + +To add a new attribute to the GPU picker output: + +1. **Update `pickingMaterial.ts`** - Add attribute and varying to vertex shader, output in fragment shader: + ```glsl + // Vertex shader + attribute float myAttribute; + varying float vMyAttribute; + void main() { + vMyAttribute = myAttribute; + } + + // Fragment shader + varying float vMyAttribute; + void main() { + gl_FragColor = vec4(vElementIndex, depth, vMyAttribute, 1.0); + } + ``` + +2. **Update `insertableGeometry.ts`** - For merged meshes (≤5 instances): + - Add `_myAttribute: THREE.Float32BufferAttribute` field + - Initialize in constructor: `new THREE.Float32BufferAttribute(offsets.counts.vertices, 1)` + - Register: `geometry.setAttribute('myAttribute', this._myAttribute)` + - Set values in `insertFromG3d()` loop: `this._myAttribute.setX(index, value)` + - Mark for update in `update()`: `this._myAttribute.needsUpdate = true` + +3. **Update `instancedMeshFactory.ts`** - For instanced meshes (>5 instances): + - Add setter method that creates `THREE.InstancedBufferAttribute` + - Call from `createFromVim()` after mesh creation + +4. **Propagate through factory chain:** + - `VimSettings` → `open.ts` → `VimMeshFactory` → `InsertableMesh`/`InstancedMeshFactory` + - For vimx: `VimSettings` → `open.ts` → `VimxSubsetBuilder` → `SubsetRequest` + +5. **Update `gpuPicker.ts`** - Read new channel: + ```typescript + const myValue = Math.round(this._readBuffer[2]) + ``` + +**Vim Index Flow:** +``` +VimSettings.vimIndex (set by loader based on viewer.vims.length) + → open.ts (loadFromVim / loadFromVimX) + → VimMeshFactory / VimxSubsetBuilder + → InsertableMesh / InstancedMeshFactory / SubsetRequest + → InsertableGeometry (per-vertex) / InstancedBufferAttribute (per-instance) + → pickingMaterial shader → gpuPicker.pick() +``` + +### Ultra RPC Stack + +``` +RpcSafeClient (validation, batching) → RpcClient (marshaling) → SocketClient (WebSocket) +``` + +- Binary protocol, little-endian +- Fire-and-forget for input, request-response for queries +- Key files: `rpcSafeClient.ts`, `rpcClient.ts`, `socketClient.ts` + +### VIM Data Model + +| Concept | Description | +|---------|-------------| +| Element | BIM entity with properties | +| Instance | Geometry placement in 3D | +| G3D | Geometry container | +| VimDocument | BIM tables (accessed via `vim.bim`) | + +```typescript +vim.getElement(instance) // Instance → Element3D +vim.getElementFromIndex(element) // Element index → Element3D +vim.getElementsFromId(id) // Element ID → Element3D[] +vim.getAllElements() // All Element3D +vim.bim // VimDocument for BIM queries +``` From 92558792f0743b4cb9f23975af774510ebfd4acc Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 14:08:40 -0500 Subject: [PATCH 13/94] working with normals --- .../webgl/loader/materials/pickingMaterial.ts | 44 ++++- .../webgl/viewer/rendering/gpuPicker.ts | 159 +++++++++--------- .../webgl/viewer/rendering/renderer.ts | 36 ---- 3 files changed, 118 insertions(+), 121 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts index 7b7372cdd..3e2be271d 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts @@ -1,18 +1,20 @@ /** * @module vim-loader/materials - * Material for GPU picking that outputs both element index and depth in a single pass. + * Material for GPU picking that outputs element index, depth, and surface normal in a single pass. */ import * as THREE from 'three' /** - * Creates a material for GPU picking that outputs element index and depth. + * Creates a material for GPU picking that outputs packed IDs, depth, and surface normal. * * Output format (Float32 RGBA): - * - R = element index (float, supports up to 16M elements) - * - G = depth (distance along camera direction) - * - B = vim index (identifies which vim the element belongs to) - * - A = hit flag (1.0) + * - R = packed(vimIndex * 16777216 + elementIndex) - supports 256 vims × 16M elements + * - G = depth (distance along camera direction, 0 = miss) + * - B = normal.x (surface normal X component) + * - A = normal.y (surface normal Y component) + * + * Normal.z is reconstructed as: sqrt(1 - x² - y²), always positive since normal faces camera. * * @returns A custom shader material for GPU picking. */ @@ -38,6 +40,7 @@ export function createPickingMaterial() { varying float vElementIndex; varying float vVimIndex; + varying float vIgnore; varying vec3 vWorldPos; void main() { @@ -46,6 +49,8 @@ export function createPickingMaterial() { #include #include + vIgnore = ignore; + // If ignore is set, hide the object by moving it far out of view if (ignore > 0.0) { gl_Position = vec4(1e20, 1e20, 1e20, 1.0); @@ -55,7 +60,7 @@ export function createPickingMaterial() { vElementIndex = elementIndex; vVimIndex = vimIndex; - // Compute world position for depth calculation + // Compute world position for depth calculation and normal computation #ifdef USE_INSTANCING vWorldPos = (modelMatrix * instanceMatrix * vec4(position, 1.0)).xyz; #else @@ -73,18 +78,39 @@ export function createPickingMaterial() { varying float vElementIndex; varying float vVimIndex; + varying float vIgnore; varying vec3 vWorldPos; + // Constant for packing vimIndex + elementIndex + const float VIM_MULTIPLIER = 16777216.0; // 2^24 + void main() { #include #include + if (vIgnore > 0.0) { + discard; + } + + // Compute flat normal from screen-space derivatives (same as simpleMaterial) + vec3 normal = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos))); + + // Ensure normal faces camera (flip if needed) + vec3 viewDir = normalize(uCameraPos - vWorldPos); + if (dot(normal, viewDir) < 0.0) { + normal = -normal; + } + // Depth = distance along camera direction vec3 toVertex = vWorldPos - uCameraPos; float depth = dot(toVertex, uCameraDir); - // Output: R = element index, G = depth, B = vim index, A = 1 - gl_FragColor = vec4(vElementIndex, depth, vVimIndex, 1.0); + // Pack vimIndex + elementIndex into single float + // Supports up to 256 vims (8 bits) and 16M elements per vim (24 bits) + float packedId = vVimIndex * VIM_MULTIPLIER + vElementIndex; + + // Output: R = packed(vim+element), G = depth, B = normal.x, A = normal.y + gl_FragColor = vec4(packedId, depth, normal.x, normal.y); } ` }) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index 9a3b5153d..1b81177b6 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -16,7 +16,7 @@ import { Marker } from '../gizmos/markers/gizmoMarker' export type GpuRaycastableObject = Element3D | Marker /** - * Result of a GPU pick operation containing element index and world position. + * Result of a GPU pick operation containing element index, world position, and surface normal. * Implements IRaycastResult for compatibility with the raycaster interface. */ export class GpuPickResult implements IRaycastResult { @@ -26,13 +26,22 @@ export class GpuPickResult implements IRaycastResult { readonly vimIndex: number /** The world position of the hit */ readonly worldPosition: THREE.Vector3 + /** The world normal at the hit point */ + readonly worldNormal: THREE.Vector3 /** Reference to the vim containing the element */ private _vim: Vim | undefined - constructor(elementIndex: number, vimIndex: number, worldPosition: THREE.Vector3, vim: Vim | undefined) { + constructor( + elementIndex: number, + vimIndex: number, + worldPosition: THREE.Vector3, + worldNormal: THREE.Vector3, + vim: Vim | undefined + ) { this.elementIndex = elementIndex this.vimIndex = vimIndex this.worldPosition = worldPosition + this.worldNormal = worldNormal this._vim = vim } @@ -44,14 +53,6 @@ export class GpuPickResult implements IRaycastResult { return this.getElement() } - /** - * The world normal at the hit point. - * GPU picking doesn't provide normals, so this returns undefined. - */ - get worldNormal(): THREE.Vector3 | undefined { - return undefined - } - /** * Gets the Element3D object for the picked element. * @returns The Element3D object, or undefined if not found @@ -61,15 +62,20 @@ export class GpuPickResult implements IRaycastResult { } } +/** Constant for packing/unpacking vimIndex + elementIndex */ +const VIM_MULTIPLIER = 16777216 // 2^24 + /** - * Unified GPU picker that outputs element index, depth, and vim index in a single render pass. + * Unified GPU picker that outputs element index, depth, vim index, and surface normal in a single render pass. * Implements IRaycaster for compatibility with the viewer's raycaster interface. * * Uses a Float32 render target with: - * - R = element index (supports up to 16M elements) - * - G = depth (distance along camera direction) - * - B = vim index (identifies which vim the element belongs to) - * - A = hit flag (1.0) + * - R = packed(vimIndex * 16777216 + elementIndex) - supports 256 vims × 16M elements + * - G = depth (distance along camera direction, 0 = miss) + * - B = normal.x (surface normal X component) + * - A = normal.y (surface normal Y component) + * + * Normal.z is reconstructed as: sqrt(1 - x² - y²), always positive since normal faces camera. */ export class GpuPicker implements IRaycaster { private _renderer: THREE.WebGLRenderer @@ -82,7 +88,9 @@ export class GpuPicker implements IRaycaster { private _readBuffer: Float32Array // Debug visualization + debug = true private _debugSphere: THREE.Mesh | undefined + private _debugLine: THREE.Line | undefined constructor( renderer: THREE.WebGLRenderer, @@ -180,33 +188,72 @@ export class GpuPicker implements IRaycaster { this._readBuffer ) - // R = element index, G = depth, B = vim index, A = alpha (0 = miss) - const elementIndexFloat = this._readBuffer[0] + // R = packed(vim+element), G = depth, B = normal.x, A = normal.y + const packedId = this._readBuffer[0] const depth = this._readBuffer[1] - const vimIndexFloat = this._readBuffer[2] - const alpha = this._readBuffer[3] + const normalX = this._readBuffer[2] + const normalY = this._readBuffer[3] - // Check if hit (alpha = 0 means background/no hit) - if (alpha === 0) { + // Check if hit (depth <= 0 means background/no hit) + if (depth <= 0) { return undefined } - // Round element index and vim index to integers - const elementIndex = Math.round(elementIndexFloat) - const vimIndex = Math.round(vimIndexFloat) + // Unpack vimIndex and elementIndex from packed float + const vimIndex = Math.floor(packedId / VIM_MULTIPLIER) + const elementIndex = Math.round(packedId - vimIndex * VIM_MULTIPLIER) - // Check for invalid element index (-1 or very large values) - if (elementIndex < 0 || elementIndex >= 16777215) { + // Check for invalid element index + if (elementIndex < 0 || elementIndex >= VIM_MULTIPLIER) { return undefined } + // Reconstruct normal.z from x and y (normal is unit length, facing camera so z > 0) + const normalZ = Math.sqrt(Math.max(0, 1 - normalX * normalX - normalY * normalY)) + const worldNormal = new THREE.Vector3(normalX, normalY, normalZ).normalize() + // Reconstruct world position from depth const worldPosition = this.reconstructWorldPosition(screenPos, depth, camera) // Get the vim directly using the vim index const vim = this._scene.vims[vimIndex] - return new GpuPickResult(elementIndex, vimIndex, worldPosition, vim) + const result = new GpuPickResult(elementIndex, vimIndex, worldPosition, worldNormal, vim) + + if (this.debug) { + this.showDebugVisuals(result) + } + + return result + } + + /** + * Shows debug visuals (sphere at hit point, line showing normal direction). + */ + private showDebugVisuals(result: GpuPickResult): void { + // Remove old debug visuals if they exist + this.clearDebugVisuals() + + // Create new debug sphere at hit position + const sphereGeometry = new THREE.SphereGeometry(0.5, 16, 16) + const sphereMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }) + this._debugSphere = new THREE.Mesh(sphereGeometry, sphereMaterial) + this._debugSphere.position.copy(result.worldPosition) + this._debugSphere.layers.set(1) // NoRaycast layer + this._scene.threeScene.add(this._debugSphere) + + // Create line segment showing normal direction + const lineLength = 2.0 + const lineStart = result.worldPosition.clone() + const lineEnd = result.worldPosition.clone().add(result.worldNormal.clone().multiplyScalar(lineLength)) + const lineGeometry = new THREE.BufferGeometry().setFromPoints([lineStart, lineEnd]) + const lineMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00, linewidth: 2 }) + this._debugLine = new THREE.Line(lineGeometry, lineMaterial) + this._debugLine.layers.set(1) // NoRaycast layer + this._scene.threeScene.add(this._debugLine) + + // Request re-render + this._renderer.domElement.dispatchEvent(new Event('needsUpdate')) } /** @@ -240,61 +287,21 @@ export class GpuPicker implements IRaycaster { } /** - * Tests GPU picking at the given screen position and places a red debug sphere - * at the hit world position for visual verification. - * - * @param screenPos Screen position in 0-1 range (0,0 is top-left). Defaults to center. - * @returns The pick result, or undefined if no hit - */ - testPick(screenPos?: THREE.Vector2): GpuPickResult | undefined { - const pos = screenPos ?? new THREE.Vector2(0.5, 0.5) - const result = this.pick(pos) - - console.log('GPU Pick test at:', pos.x.toFixed(3), pos.y.toFixed(3)) - - if (!result) { - console.log('GPU Pick - miss (no geometry)') - return undefined - } - - const element = result.getElement() - console.log('GPU Pick - elementIndex:', result.elementIndex) - console.log('GPU Pick - vimIndex:', result.vimIndex) - console.log('GPU Pick - element:', element) - console.log('GPU Pick - worldPosition:', result.worldPosition.toArray().map(v => v.toFixed(2))) - - // Remove old debug sphere if exists - if (this._debugSphere) { - this._scene.threeScene.remove(this._debugSphere) - this._debugSphere.geometry.dispose() - ;(this._debugSphere.material as THREE.Material).dispose() - } - - // Create new debug sphere at hit position - const geometry = new THREE.SphereGeometry(0.5, 16, 16) - const material = new THREE.MeshBasicMaterial({ color: 0xff0000 }) - this._debugSphere = new THREE.Mesh(geometry, material) - this._debugSphere.position.copy(result.worldPosition) - this._debugSphere.layers.set(1) // NoRaycast layer - - this._scene.threeScene.add(this._debugSphere) - - // Request re-render - this._renderer.domElement.dispatchEvent(new Event('needsUpdate')) - - return result - } - - /** - * Removes the debug sphere from the scene. + * Removes debug visuals (sphere and normal line) from the scene. */ - clearDebugSphere(): void { + clearDebugVisuals(): void { if (this._debugSphere) { this._scene.threeScene.remove(this._debugSphere) this._debugSphere.geometry.dispose() ;(this._debugSphere.material as THREE.Material).dispose() this._debugSphere = undefined } + if (this._debugLine) { + this._scene.threeScene.remove(this._debugLine) + this._debugLine.geometry.dispose() + ;(this._debugLine.material as THREE.Material).dispose() + this._debugLine = undefined + } } /** @@ -355,7 +362,7 @@ export class GpuPicker implements IRaycaster { * Disposes of all resources. */ dispose(): void { - this.clearDebugSphere() + this.clearDebugVisuals() this._renderTarget.dispose() this._pickingMaterial.dispose() } diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts index aee0f8960..b861ea7a6 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts @@ -289,42 +289,6 @@ export class Renderer implements IRenderer { return this._gpuPicker } - /** - * Performs GPU-based picking at the given mouse position. - * Returns a result object with elementIndex, worldPosition, and getElement() method. - * - * @param mousePos Normalized mouse position (0-1). Defaults to center. - * @returns Pick result with element index, world position, and getElement(), or undefined if no hit - */ - gpuPick(mousePos?: THREE.Vector2): GpuPickResult | undefined { - const picker = this.getGpuPicker() - if (!picker) return undefined - - const pos = mousePos ?? new THREE.Vector2(0.5, 0.5) - return picker.pick(pos) - } - - /** - * Tests GPU picking at the given mouse position and places a red debug sphere - * at the hit world position for visual verification. - * @param mousePos Optional normalized mouse position (0-1). Defaults to center. - * @returns The pick result, or undefined if no hit - */ - testGpuPick(mousePos?: THREE.Vector2): GpuPickResult | undefined { - const picker = this.getGpuPicker() - if (!picker) { - console.error('Cannot test GPU pick: viewport has zero size') - return undefined - } - - const result = picker.testPick(mousePos) - - // Trigger re-render to show debug sphere - this.needsUpdate = true - - return result - } - /** * Adds an object to be rendered. * @param target The object or scene to add for rendering. From f944531d0272926a7ea7d03d9e341f54c8b221a5 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 14:19:10 -0500 Subject: [PATCH 14/94] int packing as float --- CLAUDE.md | 25 +++++++++++-- .../webgl/loader/materials/pickingMaterial.ts | 37 +++++++++---------- .../webgl/viewer/rendering/gpuPicker.ts | 19 ++++------ 3 files changed, 45 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b7fe16031..513d078d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -461,6 +461,7 @@ viewer.core.selection.onSelectionChanged.subscribe(async () => { - Prettier: no semicolons, trailing commas, single quotes - Index files control module exports - No test framework configured +- Do not keep deprecated code or backwards-compatibility shims unless explicitly requested ## Commands @@ -495,10 +496,26 @@ Main Scene (MSAA) → Selection Mask → Outline Pass → FXAA → Merge → Scr GPU-based object picking using a custom shader that renders element metadata to a Float32 render target. **Render Target Format (RGBA Float32):** -- R = element index (supports up to 16M elements) -- G = depth (distance along camera direction) -- B = vim index (identifies which vim the element belongs to) -- A = hit flag (1.0 = hit, 0.0 = miss) +- R = packed uint as float bits via `uintBitsToFloat(vimIndex << 24 | elementIndex)` - supports 256 vims × 16M elements +- G = depth (distance along camera direction, 0 = miss) +- B = normal.x (surface normal X component) +- A = normal.y (surface normal Y component) + +Normal.z is reconstructed as: `sqrt(1 - x² - y²)`, always positive since normal faces camera. + +**ID Packing/Unpacking:** +```glsl +// Shader (GLSL 3.0): pack as uint, reinterpret bits as float +uint packedId = (uint(vimIndex) << 24u) | uint(elementIndex); +float packedIdFloat = uintBitsToFloat(packedId); +``` +```typescript +// JavaScript: reinterpret float bits back to uint +const dataView = new DataView(readBuffer.buffer) +const packedId = dataView.getUint32(0, true) // little-endian +const vimIndex = packedId >>> 24 +const elementIndex = packedId & 0xFFFFFF +``` **Key Files:** | File | Purpose | diff --git a/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts index 3e2be271d..e3c59aa42 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts @@ -9,7 +9,7 @@ import * as THREE from 'three' * Creates a material for GPU picking that outputs packed IDs, depth, and surface normal. * * Output format (Float32 RGBA): - * - R = packed(vimIndex * 16777216 + elementIndex) - supports 256 vims × 16M elements + * - R = packed uint as float bits (vimIndex << 24 | elementIndex) - supports 256 vims × 16M elements * - G = depth (distance along camera direction, 0 = miss) * - B = normal.x (surface normal X component) * - A = normal.y (surface normal Y component) @@ -26,22 +26,22 @@ export function createPickingMaterial() { }, side: THREE.DoubleSide, clipping: true, + glslVersion: THREE.GLSL3, vertexShader: /* glsl */ ` #include #include #include // Visibility attribute (used by VIM meshes) - attribute float ignore; + in float ignore; // Element index attribute for GPU picking - attribute float elementIndex; + in float elementIndex; // Vim index attribute for GPU picking - attribute float vimIndex; + in float vimIndex; - varying float vElementIndex; - varying float vVimIndex; - varying float vIgnore; - varying vec3 vWorldPos; + flat out uint vPackedId; + out float vIgnore; + out vec3 vWorldPos; void main() { #include @@ -57,8 +57,8 @@ export function createPickingMaterial() { return; } - vElementIndex = elementIndex; - vVimIndex = vimIndex; + // Pack vimIndex (8 bits) and elementIndex (24 bits) into uint + vPackedId = (uint(vimIndex) << 24u) | uint(elementIndex); // Compute world position for depth calculation and normal computation #ifdef USE_INSTANCING @@ -76,13 +76,11 @@ export function createPickingMaterial() { uniform vec3 uCameraPos; uniform vec3 uCameraDir; - varying float vElementIndex; - varying float vVimIndex; - varying float vIgnore; - varying vec3 vWorldPos; + flat in uint vPackedId; + in float vIgnore; + in vec3 vWorldPos; - // Constant for packing vimIndex + elementIndex - const float VIM_MULTIPLIER = 16777216.0; // 2^24 + out vec4 fragColor; void main() { #include @@ -105,12 +103,11 @@ export function createPickingMaterial() { vec3 toVertex = vWorldPos - uCameraPos; float depth = dot(toVertex, uCameraDir); - // Pack vimIndex + elementIndex into single float - // Supports up to 256 vims (8 bits) and 16M elements per vim (24 bits) - float packedId = vVimIndex * VIM_MULTIPLIER + vElementIndex; + // Reinterpret packed uint bits as float (exact integer preservation) + float packedIdFloat = uintBitsToFloat(vPackedId); // Output: R = packed(vim+element), G = depth, B = normal.x, A = normal.y - gl_FragColor = vec4(packedId, depth, normal.x, normal.y); + fragColor = vec4(packedIdFloat, depth, normal.x, normal.y); } ` }) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index 1b81177b6..851c8b3bc 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -62,9 +62,6 @@ export class GpuPickResult implements IRaycastResult { } } -/** Constant for packing/unpacking vimIndex + elementIndex */ -const VIM_MULTIPLIER = 16777216 // 2^24 - /** * Unified GPU picker that outputs element index, depth, vim index, and surface normal in a single render pass. * Implements IRaycaster for compatibility with the viewer's raycaster interface. @@ -188,8 +185,7 @@ export class GpuPicker implements IRaycaster { this._readBuffer ) - // R = packed(vim+element), G = depth, B = normal.x, A = normal.y - const packedId = this._readBuffer[0] + // R = packed(vim+element) as uint bits, G = depth, B = normal.x, A = normal.y const depth = this._readBuffer[1] const normalX = this._readBuffer[2] const normalY = this._readBuffer[3] @@ -199,14 +195,13 @@ export class GpuPicker implements IRaycaster { return undefined } - // Unpack vimIndex and elementIndex from packed float - const vimIndex = Math.floor(packedId / VIM_MULTIPLIER) - const elementIndex = Math.round(packedId - vimIndex * VIM_MULTIPLIER) + // Reinterpret float bits as uint32 to unpack vimIndex and elementIndex + const dataView = new DataView(this._readBuffer.buffer) + const packedId = dataView.getUint32(0, true) // little-endian - // Check for invalid element index - if (elementIndex < 0 || elementIndex >= VIM_MULTIPLIER) { - return undefined - } + // Unpack: upper 8 bits = vimIndex, lower 24 bits = elementIndex + const vimIndex = packedId >>> 24 + const elementIndex = packedId & 0xFFFFFF // Reconstruct normal.z from x and y (normal is unit length, facing camera so z > 0) const normalZ = Math.sqrt(Math.max(0, 1 - normalX * normalX - normalY * normalY)) From 61aa9e40df3a40a1ba36c21f424a0ab8474f8fd7 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 14:29:23 -0500 Subject: [PATCH 15/94] clean up --- src/vim-web/core-viewers/shared/raycaster.ts | 16 ++++------------ .../webgl/viewer/rendering/gpuPicker.ts | 10 ---------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/src/vim-web/core-viewers/shared/raycaster.ts b/src/vim-web/core-viewers/shared/raycaster.ts index 5db6f5d37..3e46578da 100644 --- a/src/vim-web/core-viewers/shared/raycaster.ts +++ b/src/vim-web/core-viewers/shared/raycaster.ts @@ -5,9 +5,9 @@ export interface IRaycastResult{ object: T | undefined; /** The 3D world position of the hit point */ - worldPosition: THREE.Vector3 | undefined; - /** The surface normal at the hit point (may be undefined for GPU picking) */ - worldNormal: THREE.Vector3 | undefined; + worldPosition: THREE.Vector3; + /** The surface normal at the hit point */ + worldNormal: THREE.Vector3; } /** @@ -24,18 +24,10 @@ export interface IRaycaster { /** * Raycasts from camera to world position to find the first object hit. - * @param position - The world position to raycast from. + * @param position - The world position to raycast through. * @returns A promise that resolves to the raycast result, or undefined if no hit. */ raycastFromWorld(position: THREE.Vector3): Promise | undefined>; - - /** - * GPU-based raycast that returns only the world position of the first hit. - * Optimized for camera operations where object identification is not needed. - * @param position - Screen position in 0-1 range (0,0 is top-left) - * @returns World position of the first hit, or undefined if no geometry at position - */ - raycastWorldPosition?(position: THREE.Vector2): THREE.Vector3 | undefined; } diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index 851c8b3bc..74134fdce 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -321,16 +321,6 @@ export class GpuPicker implements IRaycaster { return Promise.resolve(this.pick(screenPos)) } - /** - * GPU-based raycast that returns only the world position of the first hit. - * Optimized for camera operations where object identification is not needed. - * @param position - Screen position in 0-1 range (0,0 is top-left) - * @returns World position of the first hit, or undefined if no geometry at position - */ - raycastWorldPosition(position: THREE.Vector2): THREE.Vector3 | undefined { - return this.pick(position)?.worldPosition - } - /** * Converts a world position to screen coordinates (0-1 range). * @param worldPos - The world position to convert From db2ca2e173732a2a5a76d4500ad767402f14e9bc Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 14:46:27 -0500 Subject: [PATCH 16/94] packing at build time --- .../webgl/loader/materials/pickingMaterial.ts | 13 +++--- .../loader/progressive/insertableGeometry.ts | 42 +++++-------------- .../progressive/instancedMeshFactory.ts | 34 ++++----------- .../webgl/viewer/rendering/gpuPicker.ts | 25 ++++++++--- 4 files changed, 46 insertions(+), 68 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts index e3c59aa42..579139701 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts @@ -8,8 +8,10 @@ import * as THREE from 'three' /** * Creates a material for GPU picking that outputs packed IDs, depth, and surface normal. * + * Expects a `packedId` uint attribute pre-packed during mesh building as: (vimIndex << 24) | elementIndex + * * Output format (Float32 RGBA): - * - R = packed uint as float bits (vimIndex << 24 | elementIndex) - supports 256 vims × 16M elements + * - R = packed uint as float bits - supports 256 vims × 16M elements * - G = depth (distance along camera direction, 0 = miss) * - B = normal.x (surface normal X component) * - A = normal.y (surface normal Y component) @@ -34,10 +36,8 @@ export function createPickingMaterial() { // Visibility attribute (used by VIM meshes) in float ignore; - // Element index attribute for GPU picking - in float elementIndex; - // Vim index attribute for GPU picking - in float vimIndex; + // Pre-packed ID: (vimIndex << 24) | elementIndex + in uint packedId; flat out uint vPackedId; out float vIgnore; @@ -57,8 +57,7 @@ export function createPickingMaterial() { return; } - // Pack vimIndex (8 bits) and elementIndex (24 bits) into uint - vPackedId = (uint(vimIndex) << 24u) | uint(elementIndex); + vPackedId = packedId; // Compute world position for depth calculation and normal computation #ifdef USE_INSTANCING diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 6b5adf15f..72e0630b4 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -7,6 +7,7 @@ import { G3d, G3dMesh, G3dMaterial } from 'vim-format' import { Scene } from '../scene' import { G3dMeshOffsets } from './g3dOffsets' import { ElementMapping } from '../elementMapping' +import { packPickingId } from '../../viewer/rendering/gpuPicker' // TODO Merge both submeshes class. export class GeometrySubmesh { @@ -34,8 +35,7 @@ export class InsertableGeometry { private _indexAttribute: THREE.Uint32BufferAttribute private _vertexAttribute: THREE.BufferAttribute private _colorAttribute: THREE.BufferAttribute - private _elementIndexAttribute: THREE.Float32BufferAttribute - private _vimIndexAttribute: THREE.Float32BufferAttribute + private _packedIdAttribute: THREE.Uint32BufferAttribute private _mapping: ElementMapping | undefined private _vimIndex: number @@ -71,28 +71,17 @@ export class InsertableGeometry { colorSize ) - // Element index attribute for GPU picking (one per vertex) - this._elementIndexAttribute = new THREE.Float32BufferAttribute( + // Packed ID attribute for GPU picking: (vimIndex << 24) | elementIndex + this._packedIdAttribute = new THREE.Uint32BufferAttribute( offsets.counts.vertices, 1 ) - // Vim index attribute for GPU picking (one per vertex) - this._vimIndexAttribute = new THREE.Float32BufferAttribute( - offsets.counts.vertices, - 1 - ) - - // this._indexAttribute.count = 0 - // this._vertexAttribute.count = 0 - // this._colorAttribute.count = 0 - this.geometry = new THREE.BufferGeometry() this.geometry.setIndex(this._indexAttribute) this.geometry.setAttribute('position', this._vertexAttribute) this.geometry.setAttribute('color', this._colorAttribute) - this.geometry.setAttribute('elementIndex', this._elementIndexAttribute) - this.geometry.setAttribute('vimIndex', this._vimIndexAttribute) + this.geometry.setAttribute('packedId', this._packedIdAttribute) this.boundingBox = offsets.subset.getBoundingBox() if (this.boundingBox) { @@ -275,8 +264,7 @@ export class InsertableGeometry { vector.fromArray(g3d.positions, vertex * G3d.POSITION_SIZE) vector.applyMatrix4(matrix) this.setVertex(vertexOffset + vertexOut, vector) - this.setElementIndex(vertexOffset + vertexOut, elementIndex) - this.setVimIndex(vertexOffset + vertexOut, this._vimIndex) + this.setPackedId(vertexOffset + vertexOut, elementIndex) submesh.expandBox(vector) vertexOut++ } @@ -306,12 +294,8 @@ export class InsertableGeometry { } } - private setElementIndex (index: number, elementIndex: number) { - this._elementIndexAttribute.setX(index, elementIndex) - } - - private setVimIndex (index: number, vimIndex: number) { - this._vimIndexAttribute.setX(index, vimIndex) + private setPackedId (index: number, elementIndex: number) { + this._packedIdAttribute.setX(index, packPickingId(this._vimIndex, elementIndex)) } private expandBox (box: THREE.Box3) { @@ -358,13 +342,9 @@ export class InsertableGeometry { // this._colorAttribute.count = vertexEnd this._colorAttribute.needsUpdate = true - // update element indices (itemSize is 1) - this._elementIndexAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) - this._elementIndexAttribute.needsUpdate = true - - // update vim indices (itemSize is 1) - this._vimIndexAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) - this._vimIndexAttribute.needsUpdate = true + // update packed IDs (itemSize is 1) + this._packedIdAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) + this._packedIdAttribute.needsUpdate = true if (this._computeBoundingBox) { this.geometry.computeBoundingBox() diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 68a2dfbb9..bef6d6ef8 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -8,6 +8,7 @@ import { InstancedMesh } from './instancedMesh' import { Materials } from '../materials/materials' import * as Geometry from '../geometry' import { ElementMapping } from '../elementMapping' +import { packPickingId } from '../../viewer/rendering/gpuPicker' export class InstancedMeshFactory { materials: G3dMaterial @@ -91,8 +92,7 @@ export class InstancedMeshFactory { ) this.setMatricesFromVimx(threeMesh, g3d, instances) - this.setElementIndices(threeMesh, instances ?? g3d.meshInstances[mesh]) - this.setVimIndices(threeMesh, instances ?? g3d.meshInstances[mesh]) + this.setPackedIds(threeMesh, instances ?? g3d.meshInstances[mesh]) const result = new InstancedMesh(g3d, threeMesh, instances) return result } @@ -172,36 +172,20 @@ export class InstancedMeshFactory { } /** - * Adds per-instance element index attribute for GPU picking. + * Adds per-instance packed ID attribute for GPU picking. */ - private setElementIndices ( + private setPackedIds ( three: THREE.InstancedMesh, instances: number[] ) { - const elementIndices = new Float32Array(instances.length) + const packedIds = new Uint32Array(instances.length) for (let i = 0; i < instances.length; i++) { - elementIndices[i] = this._mapping?.getElementFromInstance(instances[i]) ?? -1 + const elementIndex = this._mapping?.getElementFromInstance(instances[i]) ?? -1 + packedIds[i] = packPickingId(this._vimIndex, elementIndex) } three.geometry.setAttribute( - 'elementIndex', - new THREE.InstancedBufferAttribute(elementIndices, 1) - ) - } - - /** - * Adds per-instance vim index attribute for GPU picking. - */ - private setVimIndices ( - three: THREE.InstancedMesh, - instances: number[] - ) { - const vimIndices = new Float32Array(instances.length) - for (let i = 0; i < instances.length; i++) { - vimIndices[i] = this._vimIndex - } - three.geometry.setAttribute( - 'vimIndex', - new THREE.InstancedBufferAttribute(vimIndices, 1) + 'packedId', + new THREE.InstancedBufferAttribute(packedIds, 1) ) } } diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index 74134fdce..eb299a550 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -15,6 +15,24 @@ import { Marker } from '../gizmos/markers/gizmoMarker' /** Raycastable objects for the GpuPicker */ export type GpuRaycastableObject = Element3D | Marker +/** + * Packs vimIndex (8 bits) and elementIndex (24 bits) into a single uint32. + * Used for GPU picking attribute. + */ +export function packPickingId(vimIndex: number, elementIndex: number): number { + return ((vimIndex & 0xFF) << 24) | (elementIndex & 0xFFFFFF) +} + +/** + * Unpacks vimIndex and elementIndex from a packed uint32. + */ +export function unpackPickingId(packedId: number): { vimIndex: number; elementIndex: number } { + return { + vimIndex: packedId >>> 24, + elementIndex: packedId & 0xFFFFFF + } +} + /** * Result of a GPU pick operation containing element index, world position, and surface normal. * Implements IRaycastResult for compatibility with the raycaster interface. @@ -195,13 +213,10 @@ export class GpuPicker implements IRaycaster { return undefined } - // Reinterpret float bits as uint32 to unpack vimIndex and elementIndex + // Reinterpret float bits as uint32 and unpack vimIndex/elementIndex const dataView = new DataView(this._readBuffer.buffer) const packedId = dataView.getUint32(0, true) // little-endian - - // Unpack: upper 8 bits = vimIndex, lower 24 bits = elementIndex - const vimIndex = packedId >>> 24 - const elementIndex = packedId & 0xFFFFFF + const { vimIndex, elementIndex } = unpackPickingId(packedId) // Reconstruct normal.z from x and y (normal is unit length, facing camera so z > 0) const normalZ = Math.sqrt(Math.max(0, 1 - normalX * normalX - normalY * normalY)) From 2d36c483961ab655cb5cda195a298000ff035ad4 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 14:47:46 -0500 Subject: [PATCH 17/94] claude md --- CLAUDE.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 513d078d5..ee40a62cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -503,19 +503,11 @@ GPU-based object picking using a custom shader that renders element metadata to Normal.z is reconstructed as: `sqrt(1 - x² - y²)`, always positive since normal faces camera. -**ID Packing/Unpacking:** -```glsl -// Shader (GLSL 3.0): pack as uint, reinterpret bits as float -uint packedId = (uint(vimIndex) << 24u) | uint(elementIndex); -float packedIdFloat = uintBitsToFloat(packedId); -``` -```typescript -// JavaScript: reinterpret float bits back to uint -const dataView = new DataView(readBuffer.buffer) -const packedId = dataView.getUint32(0, true) // little-endian -const vimIndex = packedId >>> 24 -const elementIndex = packedId & 0xFFFFFF -``` +**ID Packing:** +- IDs are pre-packed during mesh building using `packPickingId(vimIndex, elementIndex)` +- Format: `(vimIndex << 24) | elementIndex` as uint32 +- Shader reads `packedId` attribute directly, outputs via `uintBitsToFloat(packedId)` +- Utility functions in `gpuPicker.ts`: `packPickingId()` and `unpackPickingId()` **Key Files:** | File | Purpose | From ccbe3dd6b197fb1e9dad31c64453767650ff2b34 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 16:00:07 -0500 Subject: [PATCH 18/94] updated and unified vim collection --- src/vim-web/core-viewers/shared/index.ts | 3 +- .../core-viewers/shared/vimCollection.ts | 32 ++++ src/vim-web/core-viewers/ultra/raycaster.ts | 2 +- .../core-viewers/ultra/vimCollection.ts | 40 +++-- .../core-viewers/webgl/loader/index.ts | 1 + .../webgl/loader/vimCollection.ts | 149 ++++++++++++++++++ .../webgl/viewer/rendering/gpuPicker.ts | 8 +- .../webgl/viewer/rendering/renderScene.ts | 49 +++--- .../webgl/viewer/rendering/renderer.ts | 30 ---- .../core-viewers/webgl/viewer/viewer.ts | 43 +++-- src/vim-web/react-viewers/webgl/loading.ts | 8 +- 11 files changed, 287 insertions(+), 78 deletions(-) create mode 100644 src/vim-web/core-viewers/shared/vimCollection.ts create mode 100644 src/vim-web/core-viewers/webgl/loader/vimCollection.ts diff --git a/src/vim-web/core-viewers/shared/index.ts b/src/vim-web/core-viewers/shared/index.ts index 4366640d2..3ad548770 100644 --- a/src/vim-web/core-viewers/shared/index.ts +++ b/src/vim-web/core-viewers/shared/index.ts @@ -11,4 +11,5 @@ export type * from './mouseHandler' export type * from './raycaster' export type * from './selection' export type * from './touchHandler' -export type * from './vim' \ No newline at end of file +export type * from './vim' +export type * from './vimCollection' \ No newline at end of file diff --git a/src/vim-web/core-viewers/shared/vimCollection.ts b/src/vim-web/core-viewers/shared/vimCollection.ts new file mode 100644 index 000000000..0492f1598 --- /dev/null +++ b/src/vim-web/core-viewers/shared/vimCollection.ts @@ -0,0 +1,32 @@ +import { ISignal } from 'ste-signals' +import { IVim, IVimElement } from './vim' + +/** + * Readonly interface for a collection of vims. + */ +export interface IReadonlyVimCollection> { + /** Number of vims in the collection */ + readonly count: number + + /** Signal dispatched when collection changes */ + readonly onChanged: ISignal + + /** Get vim by its stable ID */ + getFromId(id: number): T | undefined + + /** Get all vims as an array */ + getAll(): ReadonlyArray + + /** Check if vim is in collection */ + has(vim: T): boolean +} + +/** + * Mutable interface for a collection of vims. + */ +export interface IVimCollection> + extends IReadonlyVimCollection { + add(vim: T): void + remove(vim: T): void + clear(): void +} diff --git a/src/vim-web/core-viewers/ultra/raycaster.ts b/src/vim-web/core-viewers/ultra/raycaster.ts index 9d64192c8..d235e34e9 100644 --- a/src/vim-web/core-viewers/ultra/raycaster.ts +++ b/src/vim-web/core-viewers/ultra/raycaster.ts @@ -68,7 +68,7 @@ export class Raycaster implements IUltraRaycaster { const test = await this._rpc.RPCPerformHitTest(position); if (!test) return undefined; - const vim = this._vims.getFromHandle(test.vimIndex); + const vim = this._vims.getFromId(test.vimIndex); if (!vim) return undefined; const object = vim.getElement(test.vimElementIndex); diff --git a/src/vim-web/core-viewers/ultra/vimCollection.ts b/src/vim-web/core-viewers/ultra/vimCollection.ts index de32b6af2..39d1089c7 100644 --- a/src/vim-web/core-viewers/ultra/vimCollection.ts +++ b/src/vim-web/core-viewers/ultra/vimCollection.ts @@ -1,15 +1,16 @@ -import { ISignal, SignalDispatcher } from "ste-signals"; -import { Vim } from "./vim"; +import { ISignal, SignalDispatcher } from 'ste-signals' +import { + IReadonlyVimCollection as ISharedReadonlyVimCollection, + IVimCollection +} from '../shared/vimCollection' +import { Vim } from './vim' -export interface IReadonlyVimCollection { - getFromHandle(handle: number): Vim | undefined; - getAll(): ReadonlyArray; +export interface IReadonlyVimCollection extends ISharedReadonlyVimCollection { + /** Get vim at a specific index */ getAt(index: number): Vim | undefined - count: number; - onChanged: ISignal; } -export class VimCollection implements IReadonlyVimCollection { +export class VimCollection implements IVimCollection, IReadonlyVimCollection { private _vims: Vim[]; private _onChanged = new SignalDispatcher(); get onChanged() { @@ -49,12 +50,21 @@ export class VimCollection implements IReadonlyVimCollection { } /** - * Gets a Vim instance by its handle. - * @param handle - The handle of the Vim instance. + * Gets a Vim instance by its stable ID. + * @param id - The ID of the Vim instance. * @returns The Vim instance or undefined if not found. */ - public getFromHandle(handle: number): Vim | undefined { - return this._vims.find(v => v.handle === handle); + public getFromId(id: number): Vim | undefined { + return this._vims.find(v => v.handle === id) + } + + /** + * Checks if a vim is in the collection. + * @param vim - The Vim instance to check. + * @returns True if the vim is in the collection. + */ + public has(vim: Vim): boolean { + return this._vims.includes(vim) } /** @@ -78,6 +88,10 @@ export class VimCollection implements IReadonlyVimCollection { * Clears all Vim instances from the collection. */ public clear(): void { - this._vims = []; + const hadVims = this._vims.length > 0 + this._vims = [] + if (hadVims) { + this._onChanged.dispatch() + } } } diff --git a/src/vim-web/core-viewers/webgl/loader/index.ts b/src/vim-web/core-viewers/webgl/loader/index.ts index 1f290246d..0f8c88f0c 100644 --- a/src/vim-web/core-viewers/webgl/loader/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/index.ts @@ -1,5 +1,6 @@ // Full export export * from './vimSettings'; +export * from './vimCollection'; export {requestVim as request, type RequestSource, type VimRequest} from './progressive/vimRequest'; export * as Materials from './materials'; diff --git a/src/vim-web/core-viewers/webgl/loader/vimCollection.ts b/src/vim-web/core-viewers/webgl/loader/vimCollection.ts new file mode 100644 index 000000000..e9dcd0e28 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/vimCollection.ts @@ -0,0 +1,149 @@ +/** + * @module vim-loader + */ + +import { ISignal, SignalDispatcher } from 'ste-signals' +import { IVimCollection } from '../../shared/vimCollection' +import { Vim } from './vim' + +/** + * Maximum number of vims that can be loaded simultaneously. + * Limited by the 8-bit vimIndex in GPU picking (256 values: 0-255). + */ +export const MAX_VIMS = 256 + +/** + * Manages a collection of Vim objects with stable IDs for GPU picking. + * + * Each vim is assigned a stable ID (0-255) that persists for its lifetime. + * IDs are allocated sequentially and only reused after all 256 have been used. + * This ensures GPU picker can correctly identify vims even after removals. + */ +export class VimCollection implements IVimCollection { + // Sparse storage indexed by stable ID + private _vimsById: (Vim | undefined)[] = new Array(MAX_VIMS).fill(undefined) + + // Sequential allocation - only reuse after all 256 exhausted + private _nextId = 0 + private _freedIds: number[] = [] + private _count = 0 + + private _onChanged = new SignalDispatcher() + + /** + * Signal dispatched when collection changes (add/remove/clear). + */ + get onChanged(): ISignal { + return this._onChanged.asEvent() + } + + /** + * Allocates a stable ID for a new vim. + * Fresh IDs are allocated sequentially (0, 1, 2, ..., 255). + * Freed IDs are only reused after all 256 have been allocated once. + * @returns The allocated ID, or undefined if all 256 IDs are in use + */ + allocateId(): number | undefined { + // Fresh ID first + if (this._nextId < MAX_VIMS) { + return this._nextId++ + } + // Reuse freed ID + if (this._freedIds.length > 0) { + return this._freedIds.pop() + } + // All 256 in use + return undefined + } + + /** + * Whether the collection has reached maximum capacity (256 vims). + */ + get isFull(): boolean { + return this._nextId >= MAX_VIMS && this._freedIds.length === 0 + } + + /** + * The number of vims currently in the collection. + */ + get count(): number { + return this._count + } + + /** + * Adds a vim to the collection using its settings.vimIndex as the ID. + * The vim's vimIndex should have been allocated via allocateId(). + * @param vim The vim to add + * @throws Error if the vim's vimIndex slot is already occupied + */ + add(vim: Vim): void { + const id = vim.settings.vimIndex + if (id < 0 || id >= MAX_VIMS) { + throw new Error(`Invalid vimIndex ${id}. Must be 0-${MAX_VIMS - 1}.`) + } + if (this._vimsById[id] !== undefined) { + throw new Error(`Vim slot ${id} is already occupied.`) + } + this._vimsById[id] = vim + this._count++ + this._onChanged.dispatch() + } + + /** + * Removes a vim from the collection and frees its ID for reuse. + * @param vim The vim to remove + * @throws Error if the vim is not in the collection + */ + remove(vim: Vim): void { + const id = vim.settings.vimIndex + if (this._vimsById[id] !== vim) { + throw new Error('Vim not found in collection.') + } + this._vimsById[id] = undefined + this._freedIds.push(id) + this._count-- + this._onChanged.dispatch() + } + + /** + * Gets a vim by its stable ID. + * @param id The stable ID (0-255) + * @returns The vim at that ID, or undefined if empty + */ + getFromId(id: number): Vim | undefined { + if (id < 0 || id >= MAX_VIMS) return undefined + return this._vimsById[id] + } + + /** + * Checks if a vim is in the collection. + * @param vim The vim to check + * @returns True if the vim is in the collection + */ + has(vim: Vim): boolean { + const id = vim.settings.vimIndex + return this._vimsById[id] === vim + } + + /** + * Returns all vims as a packed array (for iteration). + * @returns Array of all vims currently in the collection + */ + getAll(): Vim[] { + return this._vimsById.filter((v): v is Vim => v !== undefined) + } + + /** + * Clears all vims from the collection and resets ID allocation. + */ + clear(): void { + const hadVims = this._count > 0 + this._vimsById.fill(undefined) + this._nextId = 0 + this._freedIds = [] + this._count = 0 + if (hadVims) { + this._onChanged.dispatch() + } + } +} diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index eb299a550..d96146f23 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -9,6 +9,7 @@ import { RenderingSection } from './renderingSection' import { PickingMaterial } from '../../loader/materials/pickingMaterial' import { Element3D } from '../../loader/element3d' import { Vim } from '../../loader/vim' +import { VimCollection } from '../../loader/vimCollection' import type { IRaycaster, IRaycastResult } from '../../../shared' import { Marker } from '../gizmos/markers/gizmoMarker' @@ -96,6 +97,7 @@ export class GpuPicker implements IRaycaster { private _renderer: THREE.WebGLRenderer private _camera: Camera private _scene: RenderScene + private _vims: VimCollection private _section: RenderingSection private _renderTarget: THREE.WebGLRenderTarget @@ -111,6 +113,7 @@ export class GpuPicker implements IRaycaster { renderer: THREE.WebGLRenderer, camera: Camera, scene: RenderScene, + vims: VimCollection, section: RenderingSection, width: number, height: number @@ -118,6 +121,7 @@ export class GpuPicker implements IRaycaster { this._renderer = renderer this._camera = camera this._scene = scene + this._vims = vims this._section = section // Create render target with Float32 for precise element index and depth @@ -225,8 +229,8 @@ export class GpuPicker implements IRaycaster { // Reconstruct world position from depth const worldPosition = this.reconstructWorldPosition(screenPos, depth, camera) - // Get the vim directly using the vim index - const vim = this._scene.vims[vimIndex] + // Get the vim by its stable ID + const vim = this._vims.getFromId(vimIndex) const result = new GpuPickResult(elementIndex, vimIndex, worldPosition, worldNormal, vim) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts index b48a15c32..23035d0fe 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts @@ -7,6 +7,7 @@ import { Scene } from '../../loader/scene' import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer' import { ModelMaterial } from '../../loader/materials/materials' import { InstancedMesh } from '../../loader/progressive/instancedMesh' +import { MAX_VIMS } from '../../loader/vimCollection' /** * Wrapper around the THREE scene that tracks bounding box and other information. @@ -20,7 +21,8 @@ export class RenderScene { // public value smallGhostThreshold: number | undefined = 10 - private _vimScenes: Scene[] = [] + // Sparse storage indexed by stable vim ID for GPU picking + private _vimScenesById: (Scene | undefined)[] = new Array(MAX_VIMS).fill(undefined) private _boundingBox: THREE.Box3 | undefined private _memory = 0 private _2dCount = 0 @@ -28,11 +30,9 @@ export class RenderScene { private _modelMaterial: ModelMaterial get meshes() { - return this._vimScenes.flatMap((s) => s.meshes) - } - - get vims() { - return this._vimScenes.map((s) => s.vim).filter((v) => v !== undefined) + return this._vimScenesById + .filter((s): s is Scene => s !== undefined) + .flatMap((s) => s.meshes) } constructor () { @@ -49,7 +49,9 @@ export class RenderScene { /** Clears the scene updated flags */ clearUpdateFlags () { - this._vimScenes.forEach((s) => s.clearUpdateFlag()) + for (const scene of this._vimScenesById) { + scene?.clearUpdateFlag() + } } /** @@ -67,13 +69,14 @@ export class RenderScene { * Less precise but is more stable against outliers. */ getAverageBoundingBox () { - if (this._vimScenes.length === 0) { + const scenes = this._vimScenesById.filter((s): s is Scene => s !== undefined) + if (scenes.length === 0) { return new THREE.Box3() } const result = new THREE.Box3() - result.copy(this._vimScenes[0].getAverageBoundingBox()) - for (let i = 1; i < this._vimScenes.length; i++) { - result.union(this._vimScenes[i].getAverageBoundingBox()) + result.copy(scenes[0].getAverageBoundingBox()) + for (let i = 1; i < scenes.length; i++) { + result.union(scenes[i].getAverageBoundingBox()) } return result } @@ -128,6 +131,7 @@ export class RenderScene { */ clear () { this.threeScene.clear() + this._vimScenesById.fill(undefined) this._boundingBox = undefined this._memory = 0 } @@ -137,9 +141,9 @@ export class RenderScene { } set modelMaterial(material: ModelMaterial) { this._modelMaterial = material - this._vimScenes.forEach((s) => { - s.material = material - }) + for (const scene of this._vimScenesById) { + if (scene) scene.material = material + } // Hide small instances when using ghost material this.updateInstanceMeshVisibility() @@ -164,7 +168,10 @@ export class RenderScene { } private addScene (scene: Scene) { - this._vimScenes.push(scene) + // Store scene at its vim's stable ID for GPU picking + const vimIndex = scene.vim?.settings.vimIndex ?? 0 + this._vimScenesById[vimIndex] = scene + scene.meshes.forEach((m) => { this.threeScene.add(m.mesh) }) @@ -182,18 +189,20 @@ export class RenderScene { } private removeScene (scene: Scene) { - // Remove from array - this._vimScenes = this._vimScenes.filter((f) => f !== scene) + // Clear the slot at this scene's vim ID + const vimIndex = scene.vim?.settings.vimIndex ?? 0 + this._vimScenesById[vimIndex] = undefined // Remove all meshes from three scene for (let i = 0; i < scene.meshes.length; i++) { this.threeScene.remove(scene.meshes[i].mesh) } - // Recompute bounding box + // Recompute bounding box from remaining scenes + const remainingScenes = this._vimScenesById.filter((s): s is Scene => s !== undefined) this._boundingBox = - this._vimScenes.length > 0 - ? this._vimScenes + remainingScenes.length > 0 + ? remainingScenes .map((s) => s.getBoundingBox()) .reduce((b1, b2) => b1.union(b2)) : undefined diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts index b861ea7a6..e526b1281 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts @@ -14,7 +14,6 @@ import { RenderingSection } from './renderingSection' import { RenderingComposer } from './renderingComposer' import { ViewerSettings } from '../settings/viewerSettings' import { SignalDispatcher } from 'ste-signals' -import { GpuPicker, GpuPickResult } from './gpuPicker' /** * Manages how vim objects are added and removed from the THREE.Scene to be rendered @@ -56,8 +55,6 @@ export class Renderer implements IRenderer { // 3GB private maxMemory = 3 * Math.pow(10, 9) private _outlineCount = 0 - private _gpuPicker: GpuPicker | undefined - /** * Indicates whether the scene should be re-rendered on change only. @@ -134,7 +131,6 @@ export class Renderer implements IRenderer { this.renderer.forceContextLoss() this.renderer.dispose() this._composer.dispose() - this._gpuPicker?.dispose() } /** @@ -263,32 +259,6 @@ export class Renderer implements IRenderer { this._scene.clearUpdateFlags() } - /** - * Gets the GPU picker instance, lazily creating it if needed. - */ - private getGpuPicker(): GpuPicker | undefined { - const size = this._viewport.getParentSize() - - if (size.x === 0 || size.y === 0) { - return undefined - } - - if (!this._gpuPicker) { - this._gpuPicker = new GpuPicker( - this.renderer, - this._camera, - this._scene, - this.section, - size.x, - size.y - ) - } - - // Ensure size is current - this._gpuPicker.setSize(size.x, size.y) - return this._gpuPicker - } - /** * Adds an object to be rendered. * @param target The object or scene to add for rendering. diff --git a/src/vim-web/core-viewers/webgl/viewer/viewer.ts b/src/vim-web/core-viewers/webgl/viewer/viewer.ts index 457f3cadd..f0b4c7cf2 100644 --- a/src/vim-web/core-viewers/webgl/viewer/viewer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/viewer.ts @@ -20,6 +20,7 @@ import { ISignal, SignalDispatcher } from 'ste-signals' import type {InputHandler} from '../../shared' import { Materials } from '../loader/materials/materials' import { Vim } from '../loader/vim' +import { VimCollection } from '../loader/vimCollection' import { createInputHandler } from './inputAdapter' import { Renderer } from './rendering/renderer' @@ -96,7 +97,7 @@ export class Viewer { private _clock = new THREE.Clock() // State - private _vims = new Set() + private _vimCollection = new VimCollection() private _onVimLoaded = new SignalDispatcher() private _updateId: number @@ -132,6 +133,7 @@ export class Viewer { this.renderer.renderer, this._camera, scene, + this._vimCollection, this.renderer.section, size.x || 1, size.y || 1 @@ -169,14 +171,30 @@ export class Viewer { * @returns {Vim[]} An array of all Vim objects currently loaded in the viewer. */ get vims () { - return [...this._vims] + return this._vimCollection.getAll() } /** * The number of Vim objects currently loaded in the viewer. */ get vimCount () { - return this._vims.size + return this._vimCollection.count + } + + /** + * Allocates a stable ID for a new vim to be loaded. + * The ID persists for the vim's lifetime and is used for GPU picking. + * @returns The allocated ID (0-255), or undefined if all 256 slots are in use + */ + allocateVimId (): number | undefined { + return this._vimCollection.allocateId() + } + + /** + * Whether the viewer has reached maximum capacity (256 vims). + */ + get isVimsFull (): boolean { + return this._vimCollection.isFull } /** @@ -185,7 +203,7 @@ export class Viewer { * @throws {Error} If the Vim object is already added or if loading the Vim would exceed maximum geometry memory. */ add (vim: Vim) { - if (this._vims.has(vim)) { + if (this._vimCollection.has(vim)) { throw new Error('Vim cannot be added again, unless removed first.') } @@ -194,7 +212,7 @@ export class Viewer { throw new Error('Could not load vim. Max geometry memory reached.') } - this._vims.add(vim) + this._vimCollection.add(vim) this._onVimLoaded.dispatch() } @@ -204,10 +222,10 @@ export class Viewer { * @throws {Error} If attempting to remove a Vim object that is not present in the viewer. */ remove (vim: Vim) { - if (!this._vims.has(vim)) { + if (!this._vimCollection.has(vim)) { throw new Error('Cannot remove missing vim from viewer.') } - this._vims.delete(vim) + this._vimCollection.remove(vim) this.renderer.remove(vim.scene) this.selection.removeFromVim(vim) this._onVimLoaded.dispatch() @@ -217,7 +235,11 @@ export class Viewer { * Removes all Vim objects from the viewer, clearing the scene. */ clear () { - this.vims.forEach((v) => this.remove(v)) + // Get a copy of all vims before clearing + const vims = this._vimCollection.getAll() + for (const vim of vims) { + this.remove(vim) + } } /** @@ -231,7 +253,10 @@ export class Viewer { this.renderer.dispose() ;(this.raycaster as GpuPicker).dispose() this.inputs.unregisterAll() - this._vims.forEach((v) => v?.dispose()) + for (const vim of this._vimCollection.getAll()) { + vim?.dispose() + } + this._vimCollection.clear() this.materials.dispose() this.gizmos.dispose() } diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index 970821349..85853bf31 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -111,11 +111,15 @@ export class ComponentLoader { * @param source The url to the vim file or a buffer of the file. * @param settings Settings to apply to vim file. * @returns A new load request instance to track progress and get result. + * @throws Error if the viewer has reached maximum capacity (256 vims) */ request (source: Core.Webgl.RequestSource, settings?: Core.Webgl.VimPartialSettings) { - // Auto-assign vim index based on current vim count for GPU picking - const vimIndex = settings?.vimIndex ?? this._viewer.vims.length + // Allocate a stable vim ID via the viewer + const vimIndex = settings?.vimIndex ?? this._viewer.allocateVimId() + if (vimIndex === undefined) { + throw new Error('Cannot load vim: maximum of 256 vims already loaded') + } const fullSettings = { ...settings, vimIndex } return new LoadRequest({ onProgress: (p) => this.onProgress(p), From 3462d889348b235cf90619ac63862998b24d4c39 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 17:00:58 -0500 Subject: [PATCH 19/94] vim index provided by loading pipeline --- .../core-viewers/webgl/loader/index.ts | 3 +- .../webgl/loader/progressive/open.ts | 24 ++++--- .../webgl/loader/progressive/subsetRequest.ts | 3 +- .../webgl/loader/progressive/vimRequest.ts | 11 +-- src/vim-web/core-viewers/webgl/loader/vim.ts | 12 +++- .../webgl/loader/vimCollection.ts | 8 +-- .../core-viewers/webgl/loader/vimSettings.ts | 37 +++++----- .../webgl/viewer/rendering/renderScene.ts | 4 +- .../react-viewers/helpers/loadRequest.ts | 29 ++++++-- src/vim-web/react-viewers/webgl/loading.ts | 72 +++++-------------- 10 files changed, 99 insertions(+), 104 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/index.ts b/src/vim-web/core-viewers/webgl/loader/index.ts index 0f8c88f0c..c02ae7f85 100644 --- a/src/vim-web/core-viewers/webgl/loader/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/index.ts @@ -1,5 +1,5 @@ // Full export -export * from './vimSettings'; +export type { VimSettings, VimPartialSettings } from './vimSettings'; export * from './vimCollection'; export {requestVim as request, type RequestSource, type VimRequest} from './progressive/vimRequest'; export * as Materials from './materials'; @@ -13,7 +13,6 @@ export type * from './elementMapping'; export type * from './mesh'; export type * from './scene'; export type * from './vim'; -export type * from './progressive/vimx'; export type * from './progressive/g3dOffsets'; export type * from './progressive/g3dSubset'; diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/open.ts b/src/vim-web/core-viewers/webgl/loader/progressive/open.ts index 5d575c4ae..0a3214c88 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/open.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/open.ts @@ -2,7 +2,7 @@ import { createVimSettings, VimPartialSettings, - VimSettings + VimSettingsFull } from '../vimSettings' import { Vim } from '../vim' @@ -29,12 +29,14 @@ import { DefaultLog } from 'vim-format/dist/logging' * Asynchronously opens a vim object from a given source with the provided settings. * @param {string | BFast} source - The source of the vim object, either a string or a BFast. * @param {VimPartialSettings} settings - The settings to configure the behavior of the vim object. + * @param {number} vimIndex - The stable ID (0-255) for GPU picking, allocated by the viewer. * @param {(p: IProgressLogs) => void} [onProgress] - Optional callback function to track progress logs. - * @returns {Promise} A Promise that resolves when the vim object is successfully opened. + * @returns {Promise} A Promise that resolves when the vim object is successfully opened. */ export async function open ( source: VimSource | BFast, settings: VimPartialSettings, + vimIndex: number, onProgress?: (p: IProgressLogs) => void ) { const bfast = source instanceof BFast ? source : new BFast(source) @@ -42,11 +44,11 @@ export async function open ( const type = await determineFileType(bfast, fullSettings) if (type === 'vim') { - return loadFromVim(bfast, fullSettings, onProgress) + return loadFromVim(bfast, fullSettings, vimIndex, onProgress) } if (type === 'vimx') { - return loadFromVimX(bfast, fullSettings, onProgress) + return loadFromVimX(bfast, fullSettings, vimIndex, onProgress) } throw new Error('Cannot determine the appropriate loading strategy.') @@ -54,7 +56,7 @@ export async function open ( async function determineFileType ( bfast: BFast, - settings: VimSettings + settings: VimSettingsFull ) { if (settings?.fileType === 'vim') return 'vim' if (settings?.fileType === 'vimx') return 'vimx' @@ -79,7 +81,8 @@ async function requestFileType (bfast: BFast) { */ async function loadFromVimX ( bfast: BFast, - settings: VimSettings, + settings: VimSettingsFull, + vimIndex: number, onProgress: (p: IProgressLogs) => void ) { // Fetch geometry data @@ -97,7 +100,7 @@ async function loadFromVimX ( // wait for bim data. // const bim = bimPromise ? await bimPromise : undefined - const builder = new VimxSubsetBuilder(vimx, scene, settings.vimIndex) + const builder = new VimxSubsetBuilder(vimx, scene, vimIndex) const vim = new Vim( vimx.header, @@ -105,6 +108,7 @@ async function loadFromVimX ( undefined, scene, settings, + vimIndex, mapping, builder, bfast.url, @@ -123,7 +127,8 @@ async function loadFromVimX ( */ async function loadFromVim ( bfast: BFast, - settings: VimSettings, + settings: VimSettingsFull, + vimIndex: number, onProgress?: (p: IProgressLogs) => void ) { const fullSettings = createVimSettings(settings) @@ -146,7 +151,7 @@ async function loadFromVim ( // Create scene and factory WITH mapping const scene = new Scene(settings.matrix) - const factory = new VimMeshFactory(g3d, materials, scene, mapping, fullSettings.vimIndex) + const factory = new VimMeshFactory(g3d, materials, scene, mapping, vimIndex) const header = await requestHeader(bfast) @@ -158,6 +163,7 @@ async function loadFromVim ( g3d, scene, fullSettings, + vimIndex, mapping, builder, bfast.url, diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts index 879da4b80..5f9ed07d2 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts @@ -4,7 +4,8 @@ import { InsertableMesh } from './insertableMesh' import { InstancedMeshFactory } from './instancedMeshFactory' -import { Vimx, Scene } from '../..' +import { Scene } from '../scene' +import { Vimx } from './vimx' import { G3dMesh } from 'vim-format' import { G3dSubset } from './g3dSubset' diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts index 59cce8b1c..b063f5b8a 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts @@ -23,10 +23,11 @@ export type RequestSource = { * Initiates a request to load a VIM object from a given source. * @param options a url where to find the vim file or a buffer of a vim file. * @param settings the settings to configure how the vim will be loaded. + * @param vimIndex the stable ID (0-255) for GPU picking, allocated by the viewer. * @returns a request object that can be used to track progress and get the result. */ -export function requestVim (options: RequestSource, settings? : VimPartialSettings) { - return new VimRequest(options, settings) +export function requestVim (options: RequestSource, settings: VimPartialSettings, vimIndex: number) { + return new VimRequest(options, settings, vimIndex) } /** @@ -35,6 +36,7 @@ export function requestVim (options: RequestSource, settings? : VimPartialSettin export class VimRequest { private _source: VimSource private _settings : VimPartialSettings + private _vimIndex: number private _bfast : BFast // Result states @@ -47,9 +49,10 @@ export class VimRequest { private _progressPromise = new ControllablePromise() private _completionPromise = new ControllablePromise() - constructor (source: VimSource, settings: VimPartialSettings) { + constructor (source: VimSource, settings: VimPartialSettings, vimIndex: number) { this._source = source this._settings = settings + this._vimIndex = vimIndex this.startRequest() } @@ -61,7 +64,7 @@ export class VimRequest { try { this._bfast = new BFast(this._source) - const vim: Vim = await open(this._bfast, this._settings, (progress: IProgressLogs) => { + const vim: Vim = await open(this._bfast, this._settings, this._vimIndex, (progress: IProgressLogs) => { this._progress = progress this._progressPromise.resolve(progress) this._progressPromise = new ControllablePromise() diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index 7b40ab3df..4eb20bf40 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -29,7 +29,13 @@ export class Vim implements IVim { * The type of the viewer, indicating it is a WebGL viewer. * Useful for distinguishing between different viewer types in a multi-viewer application. */ - readonly type = 'webgl'; + readonly type = 'webgl'; + + /** + * The stable ID of this vim in the scene's vim collection (0-255). + * Used for GPU picking to identify which vim an element belongs to. + */ + readonly vimIndex: number /** * Indicates whether the vim was opened from a vim or vimx file. @@ -108,11 +114,11 @@ export class Vim implements IVim { * @param {G3d | undefined} g3d - The G3d object, if available. * @param {Scene} scene - The scene containing the vim's geometry. * @param {VimSettings} settings - The settings used to open this vim. + * @param {number} vimIndex - The stable ID of this vim (0-255) for GPU picking. * @param {ElementMapping | ElementNoMapping | ElementMapping2} map - The element mapping. * @param {SubsetBuilder} builder - The subset builder for constructing subsets of the Vim object. * @param {string} source - The source of the Vim object. * @param {VimFormat} format - The format of the Vim object. - * @param {boolean} isLegacy - Indicates whether the Vim object uses a legacy loading pipeline. */ constructor ( header: VimHeader | undefined, @@ -120,6 +126,7 @@ export class Vim implements IVim { g3d: G3d | undefined, scene: Scene, settings: VimSettings, + vimIndex: number, map: ElementMapping | ElementNoMapping | ElementMapping2, builder: SubsetBuilder, source: string, @@ -130,6 +137,7 @@ export class Vim implements IVim { scene.vim = this this.scene = scene this.settings = settings + this.vimIndex = vimIndex this.map = map ?? new ElementNoMapping() this._builder = builder diff --git a/src/vim-web/core-viewers/webgl/loader/vimCollection.ts b/src/vim-web/core-viewers/webgl/loader/vimCollection.ts index e9dcd0e28..e917c5fe2 100644 --- a/src/vim-web/core-viewers/webgl/loader/vimCollection.ts +++ b/src/vim-web/core-viewers/webgl/loader/vimCollection.ts @@ -71,13 +71,13 @@ export class VimCollection implements IVimCollection { } /** - * Adds a vim to the collection using its settings.vimIndex as the ID. + * Adds a vim to the collection using its vimIndex as the ID. * The vim's vimIndex should have been allocated via allocateId(). * @param vim The vim to add * @throws Error if the vim's vimIndex slot is already occupied */ add(vim: Vim): void { - const id = vim.settings.vimIndex + const id = vim.vimIndex if (id < 0 || id >= MAX_VIMS) { throw new Error(`Invalid vimIndex ${id}. Must be 0-${MAX_VIMS - 1}.`) } @@ -95,7 +95,7 @@ export class VimCollection implements IVimCollection { * @throws Error if the vim is not in the collection */ remove(vim: Vim): void { - const id = vim.settings.vimIndex + const id = vim.vimIndex if (this._vimsById[id] !== vim) { throw new Error('Vim not found in collection.') } @@ -121,7 +121,7 @@ export class VimCollection implements IVimCollection { * @returns True if the vim is in the collection */ has(vim: Vim): boolean { - const id = vim.settings.vimIndex + const id = vim.vimIndex return this._vimsById[id] === vim } diff --git a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts index d7aab26f3..a1c6869fb 100644 --- a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts +++ b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts @@ -6,7 +6,8 @@ import deepmerge from 'deepmerge' import { Transparency } from './geometry' import * as THREE from 'three' -export type FileType = 'vim' | 'vimx' | undefined +// Internal only - not exported +type FileType = 'vim' | 'vimx' | undefined /** * Represents settings for configuring the behavior and rendering of a vim object. @@ -43,9 +44,13 @@ export type VimSettings = { * Set to true to enable verbose HTTP logging. */ verboseHttp: boolean +} - // VIMX - +/** + * Internal settings type that includes vimx-specific fields. + * Used internally for loading vimx files. + */ +export type VimSettingsFull = VimSettings & { /** * Specifies the file type (vim or vimx) if it cannot or should not be inferred from the file extension. */ @@ -60,20 +65,13 @@ export type VimSettings = { * The time in milliseconds between each scene refresh during progressive loading. */ progressiveInterval: number - - /** - * The index of this vim in the scene's vim array. Used for GPU picking to directly - * identify which vim an element belongs to. If not specified, defaults to 0. - * When loading multiple vims, set this to the current count of vims in the scene. - */ - vimIndex: number } /** * Default configuration settings for a vim object. */ -export function getDefaultVimSettings(): VimSettings { -return { +export function getDefaultVimSettings(): VimSettingsFull { + return { position: new THREE.Vector3(), rotation: new THREE.Vector3(), scale: 1, @@ -81,13 +79,10 @@ return { transparency: 'all', verboseHttp: false, - // progressive + // progressive (internal) fileType: undefined, progressive: false, - progressiveInterval: 1000, - - // GPU picking - vimIndex: 0 + progressiveInterval: 1000 } } @@ -99,12 +94,12 @@ export type VimPartialSettings = Partial /** * Wraps Vim options, converting values to related THREE.js types and providing default values. * @param {VimPartialSettings} [options] - Optional partial settings for the Vim object. - * @returns {VimSettings} The complete settings for the Vim object, including defaults. + * @returns {VimSettingsFull} The complete settings for the Vim object, including defaults. */ -export function createVimSettings (options?: VimPartialSettings) { - const merge = options +export function createVimSettings (options?: VimPartialSettings): VimSettingsFull { + const merge = (options ? deepmerge(getDefaultVimSettings(), options, undefined) - : getDefaultVimSettings() + : getDefaultVimSettings()) as VimSettingsFull merge.transparency = Transparency.isValid(merge.transparency) ? merge.transparency diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts index 23035d0fe..71f053225 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts @@ -169,7 +169,7 @@ export class RenderScene { private addScene (scene: Scene) { // Store scene at its vim's stable ID for GPU picking - const vimIndex = scene.vim?.settings.vimIndex ?? 0 + const vimIndex = scene.vim?.vimIndex ?? 0 this._vimScenesById[vimIndex] = scene scene.meshes.forEach((m) => { @@ -190,7 +190,7 @@ export class RenderScene { private removeScene (scene: Scene) { // Clear the slot at this scene's vim ID - const vimIndex = scene.vim?.settings.vimIndex ?? 0 + const vimIndex = scene.vim?.vimIndex ?? 0 this._vimScenesById[vimIndex] = undefined // Remove all meshes from three scene diff --git a/src/vim-web/react-viewers/helpers/loadRequest.ts b/src/vim-web/react-viewers/helpers/loadRequest.ts index cc5d967e1..97a8f098b 100644 --- a/src/vim-web/react-viewers/helpers/loadRequest.ts +++ b/src/vim-web/react-viewers/helpers/loadRequest.ts @@ -15,6 +15,7 @@ export class LoadRequest { readonly source private _callbacks : RequestCallbacks private _request: Core.Webgl.VimRequest + private _onLoaded?: (vim: Core.Webgl.Vim) => void private _progress: Core.Webgl.IProgressLogs = { loaded: 0, total: 0, all: new Map() } private _progressPromise = new ControllablePromise() @@ -22,14 +23,21 @@ export class LoadRequest { private _isDone: boolean = false private _completionPromise = new ControllablePromise() - constructor (callbacks: RequestCallbacks, source: Core.Webgl.RequestSource, settings?: Core.Webgl.VimPartialSettings) { + constructor ( + callbacks: RequestCallbacks, + source: Core.Webgl.RequestSource, + settings: Core.Webgl.VimPartialSettings, + vimIndex: number, + onLoaded?: (vim: Core.Webgl.Vim) => void + ) { this.source = source this._callbacks = callbacks - this.startRequest(source, settings) + this._onLoaded = onLoaded + this.startRequest(source, settings, vimIndex) } - private async startRequest (source: Core.Webgl.RequestSource, settings?: Core.Webgl.VimPartialSettings) { - this._request = await Core.Webgl.request(source, settings) + private async startRequest (source: Core.Webgl.RequestSource, settings: Core.Webgl.VimPartialSettings, vimIndex: number) { + this._request = Core.Webgl.request(source, settings, vimIndex) for await (const progress of this._request.getProgress()) { this.onProgress(progress) } @@ -37,6 +45,7 @@ export class LoadRequest { if (result.isError()) { this.onError(result.error) } else { + this._onLoaded?.(result.result) this.onSuccess() } } @@ -79,6 +88,18 @@ export class LoadRequest { return this._request.getResult() } + /** + * Convenience method to get the vim directly. + * Throws if loading failed. + */ + async getVim (): Promise { + const result = await this.getResult() + if (result.isError()) { + throw new Error(result.error) + } + return result.result + } + abort () { this._request.abort() this.onError('Request aborted') diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index 85853bf31..19e079e06 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -59,7 +59,7 @@ export class ComponentLoader { } /** - * Event emitter for completion notifications. + * Event emitter for completion notifications. */ onDone () { this._modal.current?.loading(undefined) @@ -73,68 +73,30 @@ export class ComponentLoader { } /** - * Asynchronously opens a vim at source, applying the provided settings. - * @param source The source to open, either as a string or ArrayBuffer. - * @param settings Partial settings to apply to the opened source. - * @param onProgress Optional callback function to track progress during opening. - * Receives progress logs as input. - */ - async open ( - source: Core.Webgl.RequestSource, - settings: OpenSettings, - onProgress?: (p: Core.Webgl.IProgressLogs) => void - ) { - const request = this.request(source, settings) - - for await (const progress of request.getProgress()) { - onProgress?.(progress) - this.onProgress(progress) - } - - const result = await request.getResult() - if (result.isError()) { - console.log('Error loading vim', result.error) - this.onError({ - url: source.url ?? '', - error: result.error - }) - return - } - const vim = result.result - - this.onDone() - return vim - } - - /** - * Creates a new load request for the provided source and settings. + * Loads a vim and adds it to the viewer. + * Returns a request to track progress and get the result. * @param source The url to the vim file or a buffer of the file. * @param settings Settings to apply to vim file. - * @returns A new load request instance to track progress and get result. + * @returns A LoadRequest to track progress and get result. The vim is auto-added on success. * @throws Error if the viewer has reached maximum capacity (256 vims) */ - request (source: Core.Webgl.RequestSource, - settings?: Core.Webgl.VimPartialSettings) { - // Allocate a stable vim ID via the viewer - const vimIndex = settings?.vimIndex ?? this._viewer.allocateVimId() + load (source: Core.Webgl.RequestSource, settings: OpenSettings = {}) { + const vimIndex = this._viewer.allocateVimId() if (vimIndex === undefined) { throw new Error('Cannot load vim: maximum of 256 vims already loaded') } - const fullSettings = { ...settings, vimIndex } - return new LoadRequest({ - onProgress: (p) => this.onProgress(p), - onError: (e) => this.onError(e), - onDone: () => this.onDone() - }, source, fullSettings) - } - /* - * Adds a vim to the viewer and initializes it. - * @param vim Vim to add to the viewer. - * @param settings Optional settings to apply to the vim. - */ - add (vim: Core.Webgl.Vim, settings: AddSettings = {}) { - this.initVim(vim, settings) + return new LoadRequest( + { + onProgress: (p) => this.onProgress(p), + onError: (e) => this.onError(e), + onDone: () => this.onDone() + }, + source, + settings, + vimIndex, + (vim) => this.initVim(vim, settings) + ) } /** From 2155f230d101fc0521a3348b3796682b45ef4c3d Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 3 Feb 2026 17:01:21 -0500 Subject: [PATCH 20/94] open vs load --- src/vim-web/react-viewers/webgl/loading.ts | 33 ++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index 19e079e06..ccfc007a4 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -15,12 +15,6 @@ type AddSettings = { * Default: true */ autoFrame?: boolean - - /** - * Controls whether to initially load the vim content or not. - * Default: false - */ - loadEmpty?: boolean } export type OpenSettings = Core.Webgl.VimPartialSettings & AddSettings @@ -73,14 +67,31 @@ export class ComponentLoader { } /** - * Loads a vim and adds it to the viewer. - * Returns a request to track progress and get the result. + * Opens a vim file without loading geometry. + * Use this for querying BIM data or selective loading. + * Call vim.loadAll() or vim.loadSubset() to load geometry later. + * @param source The url to the vim file or a buffer of the file. + * @param settings Settings to apply to vim file. + * @returns A LoadRequest to track progress and get result. The vim is auto-added on success. + * @throws Error if the viewer has reached maximum capacity (256 vims) + */ + open (source: Core.Webgl.RequestSource, settings: OpenSettings = {}) { + return this.loadInternal(source, settings, false) + } + + /** + * Loads a vim file with all geometry. + * Use this for immediate viewing. * @param source The url to the vim file or a buffer of the file. * @param settings Settings to apply to vim file. * @returns A LoadRequest to track progress and get result. The vim is auto-added on success. * @throws Error if the viewer has reached maximum capacity (256 vims) */ load (source: Core.Webgl.RequestSource, settings: OpenSettings = {}) { + return this.loadInternal(source, settings, true) + } + + private loadInternal (source: Core.Webgl.RequestSource, settings: OpenSettings, loadGeometry: boolean) { const vimIndex = this._viewer.allocateVimId() if (vimIndex === undefined) { throw new Error('Cannot load vim: maximum of 256 vims already loaded') @@ -95,7 +106,7 @@ export class ComponentLoader { source, settings, vimIndex, - (vim) => this.initVim(vim, settings) + (vim) => this.initVim(vim, settings, loadGeometry) ) } @@ -108,7 +119,7 @@ export class ComponentLoader { vim.dispose() } - private initVim (vim : Core.Webgl.Vim, settings: AddSettings) { + private initVim (vim: Core.Webgl.Vim, settings: AddSettings, loadGeometry: boolean) { this._viewer.add(vim) vim.onLoadingUpdate.subscribe(() => { this._viewer.gizmos.loading.visible = vim.isLoading @@ -117,7 +128,7 @@ export class ComponentLoader { this._viewer.camera.save() } }) - if (settings.loadEmpty !== true) { + if (loadGeometry) { void vim.loadAll() } } From 709999074a6110eec70396602622cd95905476b5 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 4 Feb 2026 10:21:48 -0500 Subject: [PATCH 21/94] async queue instead of promise loop --- src/main.tsx | 11 +-- .../webgl/loader/progressive/vimRequest.ts | 76 +++++-------------- .../react-viewers/helpers/loadRequest.ts | 75 +++++------------- src/vim-web/utils/asyncQueue.ts | 42 ++++++++++ src/vim-web/utils/index.ts | 1 + 5 files changed, 87 insertions(+), 118 deletions(-) create mode 100644 src/vim-web/utils/asyncQueue.ts diff --git a/src/main.tsx b/src/main.tsx index 9493bc6e0..e4c51dc6e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -61,15 +61,8 @@ async function createWebgl (viewerRef: MutableRefObject, div: HTMLDiv const url = getPathFromUrl() ?? 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' //const url = getPathFromUrl() ?? 'https://vimdevelopment01storage.blob.core.windows.net/samples/Navis-Kajima.vim' - const request = viewer.loader.request( - { url }, - ) - const result = await request.getResult() - if (result.isSuccess()) { - viewer.loader.add(result.result) - viewer.camera.frameScene.call() - } - + const vim = await viewer.loader.load({ url }).getVim() + viewer.camera.frameScene.call() } async function createUltra (viewerRef: MutableRefObject, div: HTMLDivElement) { diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts index b063f5b8a..cbac7f6ec 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts @@ -1,17 +1,10 @@ -// loader -import { - VimPartialSettings -} from '../vimSettings' - +import { VimPartialSettings } from '../vimSettings' import { Vim } from '../vim' import { Result, ErrorResult, SuccessResult } from '../../../../utils/result' +import { AsyncQueue } from '../../../../utils/asyncQueue' import { open } from './open' - import { VimSource } from '../..' -import { - BFast, IProgressLogs -} from 'vim-format' -import { ControllablePromise } from '../../../../utils/promise' +import { BFast, IProgressLogs } from 'vim-format' export type RequestSource = { url?: string, @@ -35,73 +28,46 @@ export function requestVim (options: RequestSource, settings: VimPartialSettings */ export class VimRequest { private _source: VimSource - private _settings : VimPartialSettings + private _settings: VimPartialSettings private _vimIndex: number - private _bfast : BFast + private _bfast: BFast - // Result states - private _isDone: boolean = false - private _vimResult?: Vim - private _error?: string - - // Promises to await progress updates and completion - private _progress : IProgressLogs = { loaded: 0, total: 0, all: new Map() } - private _progressPromise = new ControllablePromise() - private _completionPromise = new ControllablePromise() + private _progressQueue = new AsyncQueue() + private _result: Promise> constructor (source: VimSource, settings: VimPartialSettings, vimIndex: number) { this._source = source this._settings = settings this._vimIndex = vimIndex - - this.startRequest() + this._result = this.startRequest() } - /** - * Initiates the asynchronous request and handles progress updates. - */ - private async startRequest () { + private async startRequest (): Promise> { try { this._bfast = new BFast(this._source) - - const vim: Vim = await open(this._bfast, this._settings, this._vimIndex, (progress: IProgressLogs) => { - this._progress = progress - this._progressPromise.resolve(progress) - this._progressPromise = new ControllablePromise() + const vim = await open(this._bfast, this._settings, this._vimIndex, (progress) => { + this._progressQueue.push(progress) }) - this._vimResult = vim + this._progressQueue.close() + return new SuccessResult(vim) } catch (err: any) { - this._error = err.message ?? JSON.stringify(err) + this._progressQueue.close() + const message = err.message ?? JSON.stringify(err) console.error('Error loading VIM:', err) - } finally { - this.end() + return new ErrorResult(message) } } - private end () { - this._isDone = true - this._progressPromise.resolve(this._progress) - this._completionPromise.resolve() - } - async getResult (): Promise> { - await this._completionPromise.promise - return this._error ? new ErrorResult(this._error) : new SuccessResult(this._vimResult) + return this._result } - /** - * Async generator that yields progress updates. - * @returns An AsyncGenerator yielding IProgressLogs. - */ - async * getProgress (): AsyncGenerator { - while (!this._isDone) { - yield await this._progressPromise.promise - } + getProgress (): AsyncGenerator { + return this._progressQueue[Symbol.asyncIterator]() } - abort () { + abort (): void { this._bfast.abort() - this._error = 'Request aborted' - this.end() + this._progressQueue.close() } } diff --git a/src/vim-web/react-viewers/helpers/loadRequest.ts b/src/vim-web/react-viewers/helpers/loadRequest.ts index 97a8f098b..7aeda3985 100644 --- a/src/vim-web/react-viewers/helpers/loadRequest.ts +++ b/src/vim-web/react-viewers/helpers/loadRequest.ts @@ -1,6 +1,5 @@ import * as Core from '../../core-viewers' import { LoadingError } from '../webgl/loading' -import { ControllablePromise } from '../../utils' type RequestCallbacks = { onProgress: (p: Core.Webgl.IProgressLogs) => void @@ -12,17 +11,11 @@ type RequestCallbacks = { * Class to handle loading a request. */ export class LoadRequest { - readonly source - private _callbacks : RequestCallbacks + readonly source: Core.Webgl.RequestSource private _request: Core.Webgl.VimRequest + private _callbacks: RequestCallbacks private _onLoaded?: (vim: Core.Webgl.Vim) => void - private _progress: Core.Webgl.IProgressLogs = { loaded: 0, total: 0, all: new Map() } - private _progressPromise = new ControllablePromise() - - private _isDone: boolean = false - private _completionPromise = new ControllablePromise() - constructor ( callbacks: RequestCallbacks, source: Core.Webgl.RequestSource, @@ -33,58 +26,33 @@ export class LoadRequest { this.source = source this._callbacks = callbacks this._onLoaded = onLoaded - this.startRequest(source, settings, vimIndex) - } - - private async startRequest (source: Core.Webgl.RequestSource, settings: Core.Webgl.VimPartialSettings, vimIndex: number) { this._request = Core.Webgl.request(source, settings, vimIndex) - for await (const progress of this._request.getProgress()) { - this.onProgress(progress) - } - const result = await this._request.getResult() - if (result.isError()) { - this.onError(result.error) - } else { - this._onLoaded?.(result.result) - this.onSuccess() - } + this.trackRequest() } - private onProgress (progress: Core.Webgl.IProgressLogs) { - this._callbacks.onProgress(progress) - this._progress = progress - this._progressPromise.resolve() - this._progressPromise = new ControllablePromise() - } - - private onSuccess () { - this._callbacks.onDone() - this.end() - } + private async trackRequest () { + try { + for await (const progress of this._request.getProgress()) { + this._callbacks.onProgress(progress) + } - private onError (error: string) { - this._callbacks.onError({ - url: this.source.url, - error - }) - this.end() - } - - private end () { - this._isDone = true - this._progressPromise.resolve() - this._completionPromise.resolve() + const result = await this._request.getResult() + if (result.isError()) { + this._callbacks.onError({ url: this.source.url, error: result.error }) + } else { + this._onLoaded?.(result.result) + this._callbacks.onDone() + } + } catch (err) { + this._callbacks.onError({ url: this.source.url, error: String(err) }) + } } - async * getProgress () : AsyncGenerator { - while (!this._isDone) { - await this._progressPromise.promise - yield this._progress - } + getProgress () { + return this._request.getProgress() } - async getResult () { - await this._completionPromise + getResult () { return this._request.getResult() } @@ -102,6 +70,5 @@ export class LoadRequest { abort () { this._request.abort() - this.onError('Request aborted') } } diff --git a/src/vim-web/utils/asyncQueue.ts b/src/vim-web/utils/asyncQueue.ts new file mode 100644 index 000000000..03474bc40 --- /dev/null +++ b/src/vim-web/utils/asyncQueue.ts @@ -0,0 +1,42 @@ +/** + * A queue that converts push-based callbacks into an async iterator. + * Use this to bridge callback-based APIs with async/await consumers. + */ +export class AsyncQueue { + private _queue: T[] = [] + private _waiters: ((value: T | undefined) => void)[] = [] + private _closed = false + + /** Push a value to the queue. Wakes up one waiting consumer. */ + push (value: T): void { + if (this._closed) return + if (this._waiters.length > 0) { + this._waiters.shift()!(value) + } else { + this._queue.push(value) + } + } + + /** Close the queue. No more values can be pushed. */ + close (): void { + this._closed = true + // Wake up all waiters with undefined to signal end + this._waiters.forEach(w => w(undefined)) + this._waiters = [] + } + + /** Async iterator that yields queued values until closed. */ + async * [Symbol.asyncIterator] (): AsyncGenerator { + while (true) { + if (this._queue.length > 0) { + yield this._queue.shift()! + } else if (this._closed) { + return + } else { + const value = await new Promise(resolve => this._waiters.push(resolve)) + if (value === undefined) return // Queue was closed + yield value + } + } + } +} diff --git a/src/vim-web/utils/index.ts b/src/vim-web/utils/index.ts index 8a1816f7f..d826ad5e6 100644 --- a/src/vim-web/utils/index.ts +++ b/src/vim-web/utils/index.ts @@ -1,4 +1,5 @@ export * from './array' +export * from './asyncQueue' export * from './debounce' export * from './interfaces' export * from './math3d' From b48f44e6303a690a72f0fa88f9aeed0147b60064 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 4 Feb 2026 11:13:53 -0500 Subject: [PATCH 22/94] simplifying load --- src/main.tsx | 2 +- src/vim-web/react-viewers/webgl/loading.ts | 9 ------- src/vim-web/react-viewers/webgl/viewer.tsx | 7 ++++- src/vim-web/react-viewers/webgl/viewerRef.ts | 27 +++++++++++++++++--- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index e4c51dc6e..ef727e116 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -61,7 +61,7 @@ async function createWebgl (viewerRef: MutableRefObject, div: HTMLDiv const url = getPathFromUrl() ?? 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' //const url = getPathFromUrl() ?? 'https://vimdevelopment01storage.blob.core.windows.net/samples/Navis-Kajima.vim' - const vim = await viewer.loader.load({ url }).getVim() + const vim = await viewer.load({ url }).getVim() viewer.camera.frameScene.call() } diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index ccfc007a4..64444c993 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -110,15 +110,6 @@ export class ComponentLoader { ) } - /** - * Removes the vim from the viewer and disposes it. - * @param vim Vim to remove from the viewer. - */ - remove (vim: Core.Webgl.Vim) { - this._viewer.remove(vim) - vim.dispose() - } - private initVim (vim: Core.Webgl.Vim, settings: AddSettings, loadGeometry: boolean) { this._viewer.add(vim) vim.onLoadingUpdate.subscribe(() => { diff --git a/src/vim-web/react-viewers/webgl/viewer.tsx b/src/vim-web/react-viewers/webgl/viewer.tsx index 0ded9cc0d..f9986782f 100644 --- a/src/vim-web/react-viewers/webgl/viewer.tsx +++ b/src/vim-web/react-viewers/webgl/viewer.tsx @@ -172,7 +172,12 @@ export function Viewer (props: { props.onMount({ container: props.container, core: props.viewer, - loader: loader.current, + load: (source, loadSettings) => loader.current.load(source, loadSettings), + open: (source, loadSettings) => loader.current.open(source, loadSettings), + remove: (vim) => { + props.viewer.remove(vim) + vim.dispose() + }, isolation: isolationRef, camera, settings: { diff --git a/src/vim-web/react-viewers/webgl/viewerRef.ts b/src/vim-web/react-viewers/webgl/viewerRef.ts index 0b5614578..9e33da6fb 100644 --- a/src/vim-web/react-viewers/webgl/viewerRef.ts +++ b/src/vim-web/react-viewers/webgl/viewerRef.ts @@ -9,13 +9,16 @@ import { CameraRef } from '../state/cameraState' import { Container } from '../container' import { BimInfoPanelRef } from '../bim/bimInfoData' import { ControlBarRef } from '../controlbar' -import { ComponentLoader } from './loading' +import { LoadRequest } from '../helpers/loadRequest' +import { OpenSettings } from './loading' import { ModalHandle } from '../panels/modal' import { SectionBoxRef } from '../state/sectionBoxState' import { IsolationRef } from '../state/sharedIsolation' import { GenericPanelHandle } from '../generic' import { SettingsItem } from '../settings/settingsItem' import { WebglSettings } from './settings' + +export type { OpenSettings } from './loading' /** * Settings API managing settings applied to the viewer. */ @@ -78,9 +81,27 @@ export type ViewerRef = { core: Core.Webgl.Viewer /** - * Vim WebGL loader to download VIMs. + * Loads a vim file with all geometry for immediate viewing. + * @param source The url or buffer of the vim file + * @param settings Optional settings + * @returns LoadRequest to track progress and get result + */ + load: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => LoadRequest + + /** + * Opens a vim file without loading geometry. + * Use for BIM queries or selective loading via vim.loadAll()/loadSubset(). + * @param source The url or buffer of the vim file + * @param settings Optional settings + * @returns LoadRequest to track progress and get result + */ + open: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => LoadRequest + + /** + * Removes a vim from the viewer and disposes it. + * @param vim The vim to remove */ - loader: ComponentLoader + remove: (vim: Core.Webgl.Vim) => void /** * Isolation API managing isolation state in the viewer. From 2b2be769b431519e46e682f399b3855139bac785 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 4 Feb 2026 11:26:43 -0500 Subject: [PATCH 23/94] removed open --- CLAUDE.md | 15 +- .../webgl/loader/progressive/open.ts | 178 ------------------ .../webgl/loader/progressive/vimRequest.ts | 73 ++++++- 3 files changed, 71 insertions(+), 195 deletions(-) delete mode 100644 src/vim-web/core-viewers/webgl/loader/progressive/open.ts diff --git a/CLAUDE.md b/CLAUDE.md index ee40a62cb..8045c4ef5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ React-based 3D viewers for VIM files with BIM (Building Information Modeling) su | Task | WebGL | Ultra | |------|-------|-------| | **Create viewer** | `VIM.React.Webgl.createViewer(div, settings)` | `VIM.React.Ultra.createViewer(div, settings)` | -| **Load model** | `viewer.loader.open({ url }, {})` then `viewer.loader.add(vim)` | `viewer.load({ url })` | +| **Load model** | `await viewer.load({ url }).getVim()` | `viewer.load({ url })` | | **Get element** | `vim.getElementFromIndex(index)` | `vim.getElementFromIndex(index)` | | **Select** | `viewer.core.selection.select(element)` | `viewer.core.selection.select(element)` | | **Frame camera** | `viewer.core.camera.lerp(1).frame(element)` | `viewer.camera.frame.call(element)` | @@ -362,8 +362,7 @@ const viewer = await VIM.React.Webgl.createViewer(containerDiv, { isolation: { enabled: 'auto', useGhostMaterial: true } }) -const vim = await viewer.loader.open({ url: 'model.vim' }, {}) -viewer.loader.add(vim) +const vim = await viewer.load({ url: 'model.vim' }).getVim() viewer.camera.frameScene.call() // Cleanup @@ -375,14 +374,8 @@ viewer.dispose() ```typescript const file = inputElement.files[0] const buffer = await file.arrayBuffer() -viewer.modal.loading({ progress: -1, message: 'Loading...' }) -try { - const vim = await viewer.loader.open({ buffer }, {}) - viewer.loader.add(vim) -} finally { - viewer.modal.loading(undefined) - viewer.camera.frameScene.call() -} +const vim = await viewer.load({ buffer }).getVim() +viewer.camera.frameScene.call() ``` ### Isolate Element diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/open.ts b/src/vim-web/core-viewers/webgl/loader/progressive/open.ts deleted file mode 100644 index 0a3214c88..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/open.ts +++ /dev/null @@ -1,178 +0,0 @@ -// loader -import { - createVimSettings, - VimPartialSettings, - VimSettingsFull -} from '../vimSettings' - -import { Vim } from '../vim' -import { Scene } from '../scene' -import { Vimx } from './vimx' - -import { VimSource } from '../..' -import { ElementMapping, ElementMapping2 } from '../elementMapping' -import { - BFast, - RemoteBuffer, - RemoteVimx, - requestHeader, - IProgressLogs, - VimDocument, - G3d, - G3dMaterial -} from 'vim-format' -import { VimSubsetBuilder, VimxSubsetBuilder } from './subsetBuilder' -import { VimMeshFactory } from './legacyMeshFactory' -import { DefaultLog } from 'vim-format/dist/logging' - -/** - * Asynchronously opens a vim object from a given source with the provided settings. - * @param {string | BFast} source - The source of the vim object, either a string or a BFast. - * @param {VimPartialSettings} settings - The settings to configure the behavior of the vim object. - * @param {number} vimIndex - The stable ID (0-255) for GPU picking, allocated by the viewer. - * @param {(p: IProgressLogs) => void} [onProgress] - Optional callback function to track progress logs. - * @returns {Promise} A Promise that resolves when the vim object is successfully opened. - */ -export async function open ( - source: VimSource | BFast, - settings: VimPartialSettings, - vimIndex: number, - onProgress?: (p: IProgressLogs) => void -) { - const bfast = source instanceof BFast ? source : new BFast(source) - const fullSettings = createVimSettings(settings) - const type = await determineFileType(bfast, fullSettings) - - if (type === 'vim') { - return loadFromVim(bfast, fullSettings, vimIndex, onProgress) - } - - if (type === 'vimx') { - return loadFromVimX(bfast, fullSettings, vimIndex, onProgress) - } - - throw new Error('Cannot determine the appropriate loading strategy.') -} - -async function determineFileType ( - bfast: BFast, - settings: VimSettingsFull -) { - if (settings?.fileType === 'vim') return 'vim' - if (settings?.fileType === 'vimx') return 'vimx' - return requestFileType(bfast) -} - -async function requestFileType (bfast: BFast) { - if (bfast.url) { - if (bfast.url.endsWith('vim')) return 'vim' - if (bfast.url.endsWith('vimx')) return 'vimx' - } - - const header = await requestHeader(bfast) - if (header.vim !== undefined) return 'vim' - if (header.vimx !== undefined) return 'vimx' - - throw new Error('Cannot determine file type from header.') -} - -/** - * Loads a Vimx file from source - */ -async function loadFromVimX ( - bfast: BFast, - settings: VimSettingsFull, - vimIndex: number, - onProgress: (p: IProgressLogs) => void -) { - // Fetch geometry data - const remoteVimx = new RemoteVimx(bfast) - if (remoteVimx.bfast.source instanceof RemoteBuffer) { - remoteVimx.bfast.source.onProgress = onProgress - } - - const vimx = await Vimx.fromRemote(remoteVimx, !settings.progressive) - - // Create scene - const scene = new Scene(settings.matrix) - const mapping = new ElementMapping2(vimx.scene) - - // wait for bim data. - // const bim = bimPromise ? await bimPromise : undefined - - const builder = new VimxSubsetBuilder(vimx, scene, vimIndex) - - const vim = new Vim( - vimx.header, - undefined, - undefined, - scene, - settings, - vimIndex, - mapping, - builder, - bfast.url, - 'vimx' - ) - - if (remoteVimx.bfast.source instanceof RemoteBuffer) { - remoteVimx.bfast.source.onProgress = undefined - } - - return vim -} - -/** - * Loads a Vim file from source - */ -async function loadFromVim ( - bfast: BFast, - settings: VimSettingsFull, - vimIndex: number, - onProgress?: (p: IProgressLogs) => void -) { - const fullSettings = createVimSettings(settings) - - if (bfast.source instanceof RemoteBuffer) { - bfast.source.onProgress = onProgress - if (settings.verboseHttp) { - bfast.source.logs = new DefaultLog() - } - } - - // Fetch g3d data - const geometry = await bfast.getBfast('geometry') - const g3d = await G3d.createFromBfast(geometry) - const materials = new G3dMaterial(g3d.materialColors) - - // Create mapping FIRST (needed by factory for element index attributes) - const doc = await VimDocument.createFromBfast(bfast) - const mapping = await ElementMapping.fromG3d(g3d, doc) - - // Create scene and factory WITH mapping - const scene = new Scene(settings.matrix) - const factory = new VimMeshFactory(g3d, materials, scene, mapping, vimIndex) - - const header = await requestHeader(bfast) - - // Return legacy vim - const builder = new VimSubsetBuilder(factory) - const vim = new Vim( - header, - doc, - g3d, - scene, - fullSettings, - vimIndex, - mapping, - builder, - bfast.url, - 'vim' - ) - - if (bfast.source instanceof RemoteBuffer) { - bfast.source.onProgress = undefined - } - - return vim -} diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts index cbac7f6ec..02bdf84e0 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts @@ -1,10 +1,22 @@ -import { VimPartialSettings } from '../vimSettings' +import { createVimSettings, VimPartialSettings } from '../vimSettings' import { Vim } from '../vim' +import { Scene } from '../scene' +import { ElementMapping } from '../elementMapping' +import { VimSubsetBuilder } from './subsetBuilder' +import { VimMeshFactory } from './legacyMeshFactory' import { Result, ErrorResult, SuccessResult } from '../../../../utils/result' import { AsyncQueue } from '../../../../utils/asyncQueue' -import { open } from './open' import { VimSource } from '../..' -import { BFast, IProgressLogs } from 'vim-format' +import { + BFast, + RemoteBuffer, + requestHeader, + IProgressLogs, + VimDocument, + G3d, + G3dMaterial +} from 'vim-format' +import { DefaultLog } from 'vim-format/dist/logging' export type RequestSource = { url?: string, @@ -45,9 +57,7 @@ export class VimRequest { private async startRequest (): Promise> { try { this._bfast = new BFast(this._source) - const vim = await open(this._bfast, this._settings, this._vimIndex, (progress) => { - this._progressQueue.push(progress) - }) + const vim = await this.loadFromVim(this._bfast, this._settings, this._vimIndex) this._progressQueue.close() return new SuccessResult(vim) } catch (err: any) { @@ -58,6 +68,57 @@ export class VimRequest { } } + private async loadFromVim ( + bfast: BFast, + settings: VimPartialSettings, + vimIndex: number + ): Promise { + const fullSettings = createVimSettings(settings) + + if (bfast.source instanceof RemoteBuffer) { + bfast.source.onProgress = (p) => this._progressQueue.push(p) + if (fullSettings.verboseHttp) { + bfast.source.logs = new DefaultLog() + } + } + + // Fetch g3d data + const geometry = await bfast.getBfast('geometry') + const g3d = await G3d.createFromBfast(geometry) + const materials = new G3dMaterial(g3d.materialColors) + + // Create mapping (needed by factory for element index attributes) + const doc = await VimDocument.createFromBfast(bfast) + const mapping = await ElementMapping.fromG3d(g3d, doc) + + // Create scene and factory WITH mapping + const scene = new Scene(fullSettings.matrix) + const factory = new VimMeshFactory(g3d, materials, scene, mapping, vimIndex) + + const header = await requestHeader(bfast) + + // Create vim + const builder = new VimSubsetBuilder(factory) + const vim = new Vim( + header, + doc, + g3d, + scene, + fullSettings, + vimIndex, + mapping, + builder, + bfast.url, + 'vim' + ) + + if (bfast.source instanceof RemoteBuffer) { + bfast.source.onProgress = undefined + } + + return vim + } + async getResult (): Promise> { return this._result } From f240d0903338c6a7468f7a856dac464a9c2f3522 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 4 Feb 2026 12:12:56 -0500 Subject: [PATCH 24/94] cleaning up load result --- src/vim-web/core-viewers/shared/index.ts | 1 + src/vim-web/core-viewers/shared/loadResult.ts | 29 ++++++++++++++++ src/vim-web/core-viewers/ultra/loadRequest.ts | 31 ++++++++--------- .../webgl/loader/progressive/vimRequest.ts | 19 +++++++---- .../react-viewers/helpers/loadRequest.ts | 16 ++------- src/vim-web/react-viewers/ultra/viewer.tsx | 2 +- src/vim-web/utils/index.ts | 1 - src/vim-web/utils/result.ts | 34 ------------------- 8 files changed, 61 insertions(+), 72 deletions(-) create mode 100644 src/vim-web/core-viewers/shared/loadResult.ts delete mode 100644 src/vim-web/utils/result.ts diff --git a/src/vim-web/core-viewers/shared/index.ts b/src/vim-web/core-viewers/shared/index.ts index 3ad548770..1df68c7db 100644 --- a/src/vim-web/core-viewers/shared/index.ts +++ b/src/vim-web/core-viewers/shared/index.ts @@ -1,5 +1,6 @@ // Full export export * from './inputHandler' +export * from './loadResult' // Partial export export {PointerMode} from './inputHandler' diff --git a/src/vim-web/core-viewers/shared/loadResult.ts b/src/vim-web/core-viewers/shared/loadResult.ts new file mode 100644 index 000000000..12420747e --- /dev/null +++ b/src/vim-web/core-viewers/shared/loadResult.ts @@ -0,0 +1,29 @@ +export interface ILoadSuccess { + readonly isSuccess: true + readonly isError: false + readonly vim: T +} + +export interface ILoadError { + readonly isSuccess: false + readonly isError: true + readonly error: string + readonly details?: string +} + +export type LoadResult = ILoadSuccess | ILoadError + +export class LoadSuccess implements ILoadSuccess { + readonly isSuccess = true as const + readonly isError = false as const + constructor(readonly vim: T) {} +} + +export class LoadError implements ILoadError { + readonly isSuccess = false as const + readonly isError = true as const + constructor( + readonly error: string, + readonly details?: string + ) {} +} diff --git a/src/vim-web/core-viewers/ultra/loadRequest.ts b/src/vim-web/core-viewers/ultra/loadRequest.ts index 99c1e8e73..0f9aadb1e 100644 --- a/src/vim-web/core-viewers/ultra/loadRequest.ts +++ b/src/vim-web/core-viewers/ultra/loadRequest.ts @@ -1,37 +1,36 @@ import { Vim } from './vim' import * as Utils from '../../utils' +import { + LoadSuccess as SharedLoadSuccess, + LoadError as SharedLoadError, + LoadResult +} from '../shared/loadResult' -export type LoadRequestResult = LoadSuccess | LoadError +export type VimRequestErrorType = 'loadingError' | 'downloadingError' | 'serverDisconnected' | 'unknown' | 'cancelled' -export class LoadSuccess { - readonly isError = false - readonly isSuccess = true - readonly vim: Vim +export class LoadSuccess extends SharedLoadSuccess { constructor (vim: Vim) { - this.vim = vim + super(vim) } } -export class LoadError { - readonly isError = true - readonly isSuccess = false +export class LoadError extends SharedLoadError { readonly type: VimRequestErrorType - readonly details: string | undefined - constructor (public error: VimRequestErrorType, details?: string) { + constructor (error: VimRequestErrorType, details?: string) { + super(error, details) this.type = error - this.details = details } } +export type LoadRequestResult = LoadSuccess | LoadError + export interface ILoadRequest { get isCompleted(): boolean; getProgress(): AsyncGenerator; - getResult(): Promise; + getResult(): Promise; abort(): void; } -export type VimRequestErrorType = 'loadingError' | 'downloadingError' | 'serverDisconnected' | 'unknown' | 'cancelled' - export class LoadRequest implements ILoadRequest { private _progress : number = 0 private _progressPromise = new Utils.ControllablePromise() @@ -58,7 +57,7 @@ export class LoadRequest implements ILoadRequest { } } - async getResult () : Promise { + async getResult () : Promise { await this._completionPromise.promise return this._result } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts index 02bdf84e0..594582020 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts @@ -4,7 +4,7 @@ import { Scene } from '../scene' import { ElementMapping } from '../elementMapping' import { VimSubsetBuilder } from './subsetBuilder' import { VimMeshFactory } from './legacyMeshFactory' -import { Result, ErrorResult, SuccessResult } from '../../../../utils/result' +import { LoadResult, LoadError, LoadSuccess } from '../../../shared/loadResult' import { AsyncQueue } from '../../../../utils/asyncQueue' import { VimSource } from '../..' import { @@ -45,7 +45,8 @@ export class VimRequest { private _bfast: BFast private _progressQueue = new AsyncQueue() - private _result: Promise> + private _result: Promise> + private _isCompleted = false constructor (source: VimSource, settings: VimPartialSettings, vimIndex: number) { this._source = source @@ -54,17 +55,23 @@ export class VimRequest { this._result = this.startRequest() } - private async startRequest (): Promise> { + get isCompleted () { + return this._isCompleted + } + + private async startRequest (): Promise> { try { this._bfast = new BFast(this._source) const vim = await this.loadFromVim(this._bfast, this._settings, this._vimIndex) this._progressQueue.close() - return new SuccessResult(vim) + this._isCompleted = true + return new LoadSuccess(vim) } catch (err: any) { this._progressQueue.close() + this._isCompleted = true const message = err.message ?? JSON.stringify(err) console.error('Error loading VIM:', err) - return new ErrorResult(message) + return new LoadError(message) } } @@ -119,7 +126,7 @@ export class VimRequest { return vim } - async getResult (): Promise> { + async getResult (): Promise> { return this._result } diff --git a/src/vim-web/react-viewers/helpers/loadRequest.ts b/src/vim-web/react-viewers/helpers/loadRequest.ts index 7aeda3985..4dad33e94 100644 --- a/src/vim-web/react-viewers/helpers/loadRequest.ts +++ b/src/vim-web/react-viewers/helpers/loadRequest.ts @@ -37,10 +37,10 @@ export class LoadRequest { } const result = await this._request.getResult() - if (result.isError()) { + if (result.isSuccess === false) { this._callbacks.onError({ url: this.source.url, error: result.error }) } else { - this._onLoaded?.(result.result) + this._onLoaded?.(result.vim) this._callbacks.onDone() } } catch (err) { @@ -56,18 +56,6 @@ export class LoadRequest { return this._request.getResult() } - /** - * Convenience method to get the vim directly. - * Throws if loading failed. - */ - async getVim (): Promise { - const result = await this.getResult() - if (result.isError()) { - throw new Error(result.error) - } - return result.result - } - abort () { this._request.abort() } diff --git a/src/vim-web/react-viewers/ultra/viewer.tsx b/src/vim-web/react-viewers/ultra/viewer.tsx index 2ccc19c1f..bbd018998 100644 --- a/src/vim-web/react-viewers/ultra/viewer.tsx +++ b/src/vim-web/react-viewers/ultra/viewer.tsx @@ -207,7 +207,7 @@ function patchLoad(viewer: Core.Ultra.Viewer, modal: RefObject) { void request.getResult().then( result => { if (result.isError) { - modal.current?.message(getRequestErrorMessage(viewer.serverUrl, source, result.error)) + modal.current?.message(getRequestErrorMessage(viewer.serverUrl, source, result.type)) return } if (result.isSuccess) { diff --git a/src/vim-web/utils/index.ts b/src/vim-web/utils/index.ts index d826ad5e6..c6e6a233f 100644 --- a/src/vim-web/utils/index.ts +++ b/src/vim-web/utils/index.ts @@ -5,7 +5,6 @@ export * from './interfaces' export * from './math3d' export * from './partial' export * from './promise' -export * from './result' export * from './threeUtils' export * from './url' export * from './validation' \ No newline at end of file diff --git a/src/vim-web/utils/result.ts b/src/vim-web/utils/result.ts deleted file mode 100644 index e0de5404d..000000000 --- a/src/vim-web/utils/result.ts +++ /dev/null @@ -1,34 +0,0 @@ - -export class SuccessResult { - result: T - - constructor (result: T) { - this.result = result - } - - isSuccess (): this is SuccessResult { - return true - } - - isError (): false { - return false - } -} - -export class ErrorResult { - error: string - - constructor (error: string) { - this.error = error - } - - isSuccess (): false { - return false - } - - isError (): this is ErrorResult { - return true - } -} - -export type Result = SuccessResult | ErrorResult From 6c9c64d6865e4c509a7e604fb62255c6eb389a04 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 4 Feb 2026 15:11:36 -0500 Subject: [PATCH 25/94] load cleanup --- src/main.tsx | 31 ++++++--- src/vim-web/core-viewers/shared/loadResult.ts | 51 +++++++++++++- src/vim-web/core-viewers/ultra/loadRequest.ts | 69 +++---------------- src/vim-web/core-viewers/ultra/viewer.ts | 2 +- .../core-viewers/webgl/loader/index.ts | 2 +- .../{vimRequest.ts => loadRequest.ts} | 62 ++++------------- .../react-viewers/helpers/loadRequest.ts | 21 ++++-- src/vim-web/react-viewers/webgl/viewerRef.ts | 6 +- 8 files changed, 113 insertions(+), 131 deletions(-) rename src/vim-web/core-viewers/webgl/loader/progressive/{vimRequest.ts => loadRequest.ts} (53%) diff --git a/src/main.tsx b/src/main.tsx index ef727e116..0d4bd1c25 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -60,8 +60,15 @@ async function createWebgl (viewerRef: MutableRefObject, div: HTMLDiv globalThis.viewer = viewer // for testing in browser console const url = getPathFromUrl() ?? 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' - //const url = getPathFromUrl() ?? 'https://vimdevelopment01storage.blob.core.windows.net/samples/Navis-Kajima.vim' - const vim = await viewer.load({ url }).getVim() + const request = viewer.load({ url }) + + const result = await request.getResult() + if (result.isError) { + console.error('Load failed:', result.error) + return + } + + viewer.camera.frameScene.call() } @@ -72,15 +79,21 @@ async function createUltra (viewerRef: MutableRefObject, div: HTMLDiv globalThis.viewer = viewer // for testing in browser console const url = getPathFromUrl() ?? 'https://storage.cdn.vimaec.com/samples/residence.v1.2.75.vim' - const request = viewer.load( - { url }, - ) + const request = viewer.load({ url }) + + // Track progress + void (async () => { + for await (const progress of request.getProgress()) { + console.log('Loading progress:', progress) + } + })() + const result = await request.getResult() - if (result.isSuccess) { - viewer.camera.frameScene.call() - var object = result.vim.getElementFromIndex(0); - object.state + if (result.isError) { + console.error('Load failed:', result.type, result.error) + return } + viewer.camera.frameScene.call() } diff --git a/src/vim-web/core-viewers/shared/loadResult.ts b/src/vim-web/core-viewers/shared/loadResult.ts index 12420747e..2c5f21ab2 100644 --- a/src/vim-web/core-viewers/shared/loadResult.ts +++ b/src/vim-web/core-viewers/shared/loadResult.ts @@ -11,7 +11,7 @@ export interface ILoadError { readonly details?: string } -export type LoadResult = ILoadSuccess | ILoadError +export type LoadResult = ILoadSuccess | TError export class LoadSuccess implements ILoadSuccess { readonly isSuccess = true as const @@ -27,3 +27,52 @@ export class LoadError implements ILoadError { readonly details?: string ) {} } + +import { AsyncQueue } from '../../utils/asyncQueue' +import { ControllablePromise } from '../../utils/promise' + +/** + * Interface for load requests that can be used as a type constraint. + */ +export interface ILoadRequest { + readonly isCompleted: boolean + getProgress(): AsyncGenerator + getResult(): Promise> + abort(): void +} + +/** + * Base class for loading requests that provides progress tracking via AsyncQueue. + * Both WebGL and Ultra extend this class with their specific loading logic. + */ +export class LoadRequest + implements ILoadRequest { + private _progressQueue = new AsyncQueue() + private _result: LoadResult | undefined + private _resultPromise = new ControllablePromise>() + + get isCompleted () { return this._result !== undefined } + + async * getProgress (): AsyncGenerator { + yield * this._progressQueue + } + + async getResult (): Promise> { + return this._resultPromise.promise + } + + pushProgress (progress: TProgress) { + this._progressQueue.push(progress) + } + + complete (result: LoadResult) { + if (this._result !== undefined) return + this._result = result + this._progressQueue.close() + this._resultPromise.resolve(result) + } + + abort () { + this.complete(new LoadError('cancelled') as TError) + } +} diff --git a/src/vim-web/core-viewers/ultra/loadRequest.ts b/src/vim-web/core-viewers/ultra/loadRequest.ts index 0f9aadb1e..41e141d27 100644 --- a/src/vim-web/core-viewers/ultra/loadRequest.ts +++ b/src/vim-web/core-viewers/ultra/loadRequest.ts @@ -1,19 +1,13 @@ import { Vim } from './vim' -import * as Utils from '../../utils' import { - LoadSuccess as SharedLoadSuccess, - LoadError as SharedLoadError, - LoadResult + LoadRequest as BaseLoadRequest, + ILoadRequest as BaseILoadRequest, + LoadSuccess, + LoadError as SharedLoadError } from '../shared/loadResult' export type VimRequestErrorType = 'loadingError' | 'downloadingError' | 'serverDisconnected' | 'unknown' | 'cancelled' -export class LoadSuccess extends SharedLoadSuccess { - constructor (vim: Vim) { - super(vim) - } -} - export class LoadError extends SharedLoadError { readonly type: VimRequestErrorType constructor (error: VimRequestErrorType, details?: string) { @@ -22,65 +16,20 @@ export class LoadError extends SharedLoadError { } } -export type LoadRequestResult = LoadSuccess | LoadError - -export interface ILoadRequest { - get isCompleted(): boolean; - getProgress(): AsyncGenerator; - getResult(): Promise; - abort(): void; -} - -export class LoadRequest implements ILoadRequest { - private _progress : number = 0 - private _progressPromise = new Utils.ControllablePromise() - - private _completionPromise = new Utils.ControllablePromise() - private _result : LoadError | LoadSuccess | undefined - - get isCompleted () { - return this._result !== undefined - } - - async * getProgress () { - //Always yield 0 initially - yield 0 - - if (this._result !== undefined) { - yield this._progress - return - } - - while (this._result === undefined) { - await this._progressPromise.promise - yield this._progress - } - } - - async getResult () : Promise { - await this._completionPromise.promise - return this._result - } +export type ILoadRequest = BaseILoadRequest +export class LoadRequest extends BaseLoadRequest { onProgress (progress: number) { - this._progress = progress - this._progressPromise.resolve() - this._progressPromise = new Utils.ControllablePromise() + this.pushProgress(progress) } success (vim: Vim) { - this._result = new LoadSuccess(vim) - this._progress = 1 - this._progressPromise.resolve() - this._completionPromise.resolve() + this.complete(new LoadSuccess(vim)) return this } error (error: VimRequestErrorType, details?: string) { - this._result = new LoadError(error, details) - this._progress = 1 - this._progressPromise.resolve() - this._completionPromise.resolve() + this.complete(new LoadError(error, details)) return this } diff --git a/src/vim-web/core-viewers/ultra/viewer.ts b/src/vim-web/core-viewers/ultra/viewer.ts index b21a4f14d..b2034b493 100644 --- a/src/vim-web/core-viewers/ultra/viewer.ts +++ b/src/vim-web/core-viewers/ultra/viewer.ts @@ -5,7 +5,7 @@ import { ColorManager } from './colorManager' import { Decoder, IDecoder } from './decoder' import { DecoderWithWorker } from './decoderWithWorker' import { ultraInputAdapter } from './inputAdapter' -import { ILoadRequest, LoadRequest } from './loadRequest' +import { type ILoadRequest, LoadRequest } from './loadRequest' import { defaultLogger, ILogger } from './logger' import { IUltraRaycaster, Raycaster } from './raycaster' import { IRenderer, Renderer } from './renderer' diff --git a/src/vim-web/core-viewers/webgl/loader/index.ts b/src/vim-web/core-viewers/webgl/loader/index.ts index c02ae7f85..6dd2fba36 100644 --- a/src/vim-web/core-viewers/webgl/loader/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/index.ts @@ -1,7 +1,7 @@ // Full export export type { VimSettings, VimPartialSettings } from './vimSettings'; export * from './vimCollection'; -export {requestVim as request, type RequestSource, type VimRequest} from './progressive/vimRequest'; +export type {RequestSource, LoadRequest} from './progressive/loadRequest'; export * as Materials from './materials'; // Types diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts similarity index 53% rename from src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts rename to src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts index 594582020..f030f00dc 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -4,8 +4,7 @@ import { Scene } from '../scene' import { ElementMapping } from '../elementMapping' import { VimSubsetBuilder } from './subsetBuilder' import { VimMeshFactory } from './legacyMeshFactory' -import { LoadResult, LoadError, LoadSuccess } from '../../../shared/loadResult' -import { AsyncQueue } from '../../../../utils/asyncQueue' +import { LoadRequest as BaseLoadRequest, LoadError, LoadSuccess } from '../../../shared/loadResult' import { VimSource } from '../..' import { BFast, @@ -25,53 +24,26 @@ export type RequestSource = { } /** - * Initiates a request to load a VIM object from a given source. - * @param options a url where to find the vim file or a buffer of a vim file. - * @param settings the settings to configure how the vim will be loaded. - * @param vimIndex the stable ID (0-255) for GPU picking, allocated by the viewer. - * @returns a request object that can be used to track progress and get the result. + * A request to load a VIM file. Extends the base LoadRequest to add BFast abort handling. + * Loading starts immediately upon construction. */ -export function requestVim (options: RequestSource, settings: VimPartialSettings, vimIndex: number) { - return new VimRequest(options, settings, vimIndex) -} - -/** - * A class that represents a request to load a VIM object from a given source. - */ -export class VimRequest { - private _source: VimSource - private _settings: VimPartialSettings - private _vimIndex: number +export class LoadRequest extends BaseLoadRequest { private _bfast: BFast - private _progressQueue = new AsyncQueue() - private _result: Promise> - private _isCompleted = false - constructor (source: VimSource, settings: VimPartialSettings, vimIndex: number) { - this._source = source - this._settings = settings - this._vimIndex = vimIndex - this._result = this.startRequest() + super() + this._bfast = new BFast(source) + this.startRequest(settings, vimIndex) } - get isCompleted () { - return this._isCompleted - } - - private async startRequest (): Promise> { + private async startRequest (settings: VimPartialSettings, vimIndex: number) { try { - this._bfast = new BFast(this._source) - const vim = await this.loadFromVim(this._bfast, this._settings, this._vimIndex) - this._progressQueue.close() - this._isCompleted = true - return new LoadSuccess(vim) + const vim = await this.loadFromVim(this._bfast, settings, vimIndex) + this.complete(new LoadSuccess(vim)) } catch (err: any) { - this._progressQueue.close() - this._isCompleted = true const message = err.message ?? JSON.stringify(err) console.error('Error loading VIM:', err) - return new LoadError(message) + this.complete(new LoadError(message)) } } @@ -83,7 +55,7 @@ export class VimRequest { const fullSettings = createVimSettings(settings) if (bfast.source instanceof RemoteBuffer) { - bfast.source.onProgress = (p) => this._progressQueue.push(p) + bfast.source.onProgress = (p) => this.pushProgress(p) if (fullSettings.verboseHttp) { bfast.source.logs = new DefaultLog() } @@ -126,16 +98,8 @@ export class VimRequest { return vim } - async getResult (): Promise> { - return this._result - } - - getProgress (): AsyncGenerator { - return this._progressQueue[Symbol.asyncIterator]() - } - abort (): void { this._bfast.abort() - this._progressQueue.close() + super.abort() } } diff --git a/src/vim-web/react-viewers/helpers/loadRequest.ts b/src/vim-web/react-viewers/helpers/loadRequest.ts index 4dad33e94..e1faa6aac 100644 --- a/src/vim-web/react-viewers/helpers/loadRequest.ts +++ b/src/vim-web/react-viewers/helpers/loadRequest.ts @@ -1,4 +1,6 @@ import * as Core from '../../core-viewers' +import { LoadRequest as CoreLoadRequest } from '../../core-viewers/webgl/loader/progressive/loadRequest' +import { ILoadRequest } from '../../core-viewers/shared/loadResult' import { LoadingError } from '../webgl/loading' type RequestCallbacks = { @@ -9,10 +11,11 @@ type RequestCallbacks = { /** * Class to handle loading a request. + * Implements ILoadRequest for compatibility with Ultra viewer's load request interface. */ -export class LoadRequest { - readonly source: Core.Webgl.RequestSource - private _request: Core.Webgl.VimRequest +export class LoadRequest implements ILoadRequest { + private _source: Core.Webgl.RequestSource + private _request: Core.Webgl.LoadRequest private _callbacks: RequestCallbacks private _onLoaded?: (vim: Core.Webgl.Vim) => void @@ -23,10 +26,10 @@ export class LoadRequest { vimIndex: number, onLoaded?: (vim: Core.Webgl.Vim) => void ) { - this.source = source + this._source = source this._callbacks = callbacks this._onLoaded = onLoaded - this._request = Core.Webgl.request(source, settings, vimIndex) + this._request = new CoreLoadRequest(source, settings, vimIndex) this.trackRequest() } @@ -38,16 +41,20 @@ export class LoadRequest { const result = await this._request.getResult() if (result.isSuccess === false) { - this._callbacks.onError({ url: this.source.url, error: result.error }) + this._callbacks.onError({ url: this._source.url, error: result.error }) } else { this._onLoaded?.(result.vim) this._callbacks.onDone() } } catch (err) { - this._callbacks.onError({ url: this.source.url, error: String(err) }) + this._callbacks.onError({ url: this._source.url, error: String(err) }) } } + get isCompleted () { + return this._request.isCompleted + } + getProgress () { return this._request.getProgress() } diff --git a/src/vim-web/react-viewers/webgl/viewerRef.ts b/src/vim-web/react-viewers/webgl/viewerRef.ts index 9e33da6fb..aaaa0f866 100644 --- a/src/vim-web/react-viewers/webgl/viewerRef.ts +++ b/src/vim-web/react-viewers/webgl/viewerRef.ts @@ -3,13 +3,13 @@ */ import * as Core from '../../core-viewers' +import { ILoadRequest } from '../../core-viewers/shared/loadResult' import { ContextMenuRef } from '../panels/contextMenu' import { AnySettings } from '../settings/anySettings' import { CameraRef } from '../state/cameraState' import { Container } from '../container' import { BimInfoPanelRef } from '../bim/bimInfoData' import { ControlBarRef } from '../controlbar' -import { LoadRequest } from '../helpers/loadRequest' import { OpenSettings } from './loading' import { ModalHandle } from '../panels/modal' import { SectionBoxRef } from '../state/sectionBoxState' @@ -86,7 +86,7 @@ export type ViewerRef = { * @param settings Optional settings * @returns LoadRequest to track progress and get result */ - load: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => LoadRequest + load: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => ILoadRequest /** * Opens a vim file without loading geometry. @@ -95,7 +95,7 @@ export type ViewerRef = { * @param settings Optional settings * @returns LoadRequest to track progress and get result */ - open: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => LoadRequest + open: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => ILoadRequest /** * Removes a vim from the viewer and disposes it. From 7cacb80b0329625717003734b75d27187ff6a002 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 4 Feb 2026 15:11:56 -0500 Subject: [PATCH 26/94] common progress --- src/vim-web/core-viewers/shared/loadResult.ts | 28 ++++++++++++------- src/vim-web/core-viewers/ultra/loadRequest.ts | 7 +++-- src/vim-web/core-viewers/ultra/vim.ts | 2 +- src/vim-web/core-viewers/webgl/index.ts | 1 - .../core-viewers/webgl/loader/index.ts | 2 +- .../webgl/loader/progressive/loadRequest.ts | 9 +++--- .../react-viewers/helpers/loadRequest.ts | 16 +++++++---- .../react-viewers/panels/loadingBox.tsx | 15 +++++----- src/vim-web/react-viewers/ultra/modal.tsx | 2 +- src/vim-web/react-viewers/webgl/loading.ts | 7 +++-- src/vim-web/react-viewers/webgl/viewerRef.ts | 5 ++-- 11 files changed, 54 insertions(+), 40 deletions(-) diff --git a/src/vim-web/core-viewers/shared/loadResult.ts b/src/vim-web/core-viewers/shared/loadResult.ts index 2c5f21ab2..b54a64669 100644 --- a/src/vim-web/core-viewers/shared/loadResult.ts +++ b/src/vim-web/core-viewers/shared/loadResult.ts @@ -1,3 +1,6 @@ +import { AsyncQueue } from '../../utils/asyncQueue' +import { ControllablePromise } from '../../utils/promise' + export interface ILoadSuccess { readonly isSuccess: true readonly isError: false @@ -11,6 +14,14 @@ export interface ILoadError { readonly details?: string } +export type ProgressType = 'bytes' | 'percent' + +export interface IProgress { + type: ProgressType + current: number + total?: number +} + export type LoadResult = ILoadSuccess | TError export class LoadSuccess implements ILoadSuccess { @@ -28,15 +39,12 @@ export class LoadError implements ILoadError { ) {} } -import { AsyncQueue } from '../../utils/asyncQueue' -import { ControllablePromise } from '../../utils/promise' - /** * Interface for load requests that can be used as a type constraint. */ -export interface ILoadRequest { +export interface ILoadRequest { readonly isCompleted: boolean - getProgress(): AsyncGenerator + getProgress(): AsyncGenerator getResult(): Promise> abort(): void } @@ -45,15 +53,15 @@ export interface ILoadRequest - implements ILoadRequest { - private _progressQueue = new AsyncQueue() +export class LoadRequest + implements ILoadRequest { + private _progressQueue = new AsyncQueue() private _result: LoadResult | undefined private _resultPromise = new ControllablePromise>() get isCompleted () { return this._result !== undefined } - async * getProgress (): AsyncGenerator { + async * getProgress (): AsyncGenerator { yield * this._progressQueue } @@ -61,7 +69,7 @@ export class LoadRequest +export type ILoadRequest = BaseILoadRequest -export class LoadRequest extends BaseLoadRequest { - onProgress (progress: number) { +export class LoadRequest extends BaseLoadRequest { + onProgress (progress: IProgress) { this.pushProgress(progress) } diff --git a/src/vim-web/core-viewers/ultra/vim.ts b/src/vim-web/core-viewers/ultra/vim.ts index b4d0d3788..e548a25cf 100644 --- a/src/vim-web/core-viewers/ultra/vim.ts +++ b/src/vim-web/core-viewers/ultra/vim.ts @@ -134,7 +134,7 @@ export class Vim implements IVim { try { const state = await this._rpc.RPCGetVimLoadingState(handle); this._logger.log('state :', state); - result.onProgress(state.progress); + result.onProgress({ type: 'percent', current: state.progress, total: 100 }); switch (state.status) { case VimLoadingStatus.Loading: case VimLoadingStatus.Downloading: diff --git a/src/vim-web/core-viewers/webgl/index.ts b/src/vim-web/core-viewers/webgl/index.ts index d37ae1d9a..e634ea9f9 100644 --- a/src/vim-web/core-viewers/webgl/index.ts +++ b/src/vim-web/core-viewers/webgl/index.ts @@ -4,7 +4,6 @@ import './style.css' // Useful definitions from vim-format import { BFastSource } from 'vim-format' export type VimSource = BFastSource -export { IProgressLogs } from 'vim-format' export * from './loader' export * from './viewer' diff --git a/src/vim-web/core-viewers/webgl/loader/index.ts b/src/vim-web/core-viewers/webgl/loader/index.ts index 6dd2fba36..d39ed6fdf 100644 --- a/src/vim-web/core-viewers/webgl/loader/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/index.ts @@ -1,7 +1,7 @@ // Full export export type { VimSettings, VimPartialSettings } from './vimSettings'; export * from './vimCollection'; -export type {RequestSource, LoadRequest} from './progressive/loadRequest'; +export type {RequestSource, LoadRequest, ILoadRequest} from './progressive/loadRequest'; export * as Materials from './materials'; // Types diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts index f030f00dc..6e377323a 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -4,13 +4,12 @@ import { Scene } from '../scene' import { ElementMapping } from '../elementMapping' import { VimSubsetBuilder } from './subsetBuilder' import { VimMeshFactory } from './legacyMeshFactory' -import { LoadRequest as BaseLoadRequest, LoadError, LoadSuccess } from '../../../shared/loadResult' +import { LoadRequest as BaseLoadRequest, ILoadRequest as BaseILoadRequest, LoadError, LoadSuccess } from '../../../shared/loadResult' import { VimSource } from '../..' import { BFast, RemoteBuffer, requestHeader, - IProgressLogs, VimDocument, G3d, G3dMaterial @@ -23,11 +22,13 @@ export type RequestSource = { headers?: Record, } +export type ILoadRequest = BaseILoadRequest + /** * A request to load a VIM file. Extends the base LoadRequest to add BFast abort handling. * Loading starts immediately upon construction. */ -export class LoadRequest extends BaseLoadRequest { +export class LoadRequest extends BaseLoadRequest { private _bfast: BFast constructor (source: VimSource, settings: VimPartialSettings, vimIndex: number) { @@ -55,7 +56,7 @@ export class LoadRequest extends BaseLoadRequest { const fullSettings = createVimSettings(settings) if (bfast.source instanceof RemoteBuffer) { - bfast.source.onProgress = (p) => this.pushProgress(p) + bfast.source.onProgress = (p) => this.pushProgress({ type: 'bytes', current: p.loaded, total: p.total }) if (fullSettings.verboseHttp) { bfast.source.logs = new DefaultLog() } diff --git a/src/vim-web/react-viewers/helpers/loadRequest.ts b/src/vim-web/react-viewers/helpers/loadRequest.ts index e1faa6aac..f0af695c2 100644 --- a/src/vim-web/react-viewers/helpers/loadRequest.ts +++ b/src/vim-web/react-viewers/helpers/loadRequest.ts @@ -1,10 +1,11 @@ import * as Core from '../../core-viewers' -import { LoadRequest as CoreLoadRequest } from '../../core-viewers/webgl/loader/progressive/loadRequest' -import { ILoadRequest } from '../../core-viewers/shared/loadResult' +import { LoadRequest as CoreLoadRequest, ILoadRequest as CoreILoadRequest } from '../../core-viewers/webgl/loader/progressive/loadRequest' +import { IProgress } from '../../core-viewers/shared/loadResult' +import { AsyncQueue } from '../../utils/asyncQueue' import { LoadingError } from '../webgl/loading' type RequestCallbacks = { - onProgress: (p: Core.Webgl.IProgressLogs) => void + onProgress: (p: IProgress) => void onError: (e: LoadingError) => void onDone: () => void } @@ -13,11 +14,12 @@ type RequestCallbacks = { * Class to handle loading a request. * Implements ILoadRequest for compatibility with Ultra viewer's load request interface. */ -export class LoadRequest implements ILoadRequest { +export class LoadRequest implements CoreILoadRequest { private _source: Core.Webgl.RequestSource private _request: Core.Webgl.LoadRequest private _callbacks: RequestCallbacks private _onLoaded?: (vim: Core.Webgl.Vim) => void + private _progressQueue = new AsyncQueue() constructor ( callbacks: RequestCallbacks, @@ -37,6 +39,7 @@ export class LoadRequest implements ILoadRequest { + yield * this._progressQueue } getResult () { diff --git a/src/vim-web/react-viewers/panels/loadingBox.tsx b/src/vim-web/react-viewers/panels/loadingBox.tsx index 755fd8a4c..73b9013c7 100644 --- a/src/vim-web/react-viewers/panels/loadingBox.tsx +++ b/src/vim-web/react-viewers/panels/loadingBox.tsx @@ -5,7 +5,7 @@ import React, { ReactNode } from 'react' import { Urls } from '..'; -export type ProgressMode = '%' | 'bytes' +export type ProgressMode = 'percent' | 'bytes' /** * Interface for message information displayed in the LoadingBox. @@ -57,15 +57,16 @@ function content (info: LoadingBoxProps) { } /** - * Formats bytes to megabytes with two decimal places. - * @param bytes - The number of bytes to format. - * @returns The formatted megabytes as a string. + * Formats progress for display. + * @param progress - The progress value (bytes for 'bytes' mode, 0-100 for 'percent' mode). + * @param mode - The display mode. + * @returns The formatted progress as a string. */ function formatProgress (progress: number, mode? : ProgressMode): string { if (progress <= 0) return '' - mode = mode ?? '%' - if (mode === '%') { - return `${(progress * 100).toFixed(0)}%` + mode = mode ?? 'percent' + if (mode === 'percent') { + return `${progress.toFixed(0)}%` } else { const BYTES_IN_MB = 1_000_000 return `${(progress / BYTES_IN_MB).toFixed(2)} MB` diff --git a/src/vim-web/react-viewers/ultra/modal.tsx b/src/vim-web/react-viewers/ultra/modal.tsx index 92c75d81d..58f154710 100644 --- a/src/vim-web/react-viewers/ultra/modal.tsx +++ b/src/vim-web/react-viewers/ultra/modal.tsx @@ -23,6 +23,6 @@ export function updateModal (modal: RefObject, state: Core.Ultra.Cl export async function updateProgress (request: Core.Ultra.ILoadRequest, modal: ModalHandle) { for await (const progress of request.getProgress()) { if (request.isCompleted) break - modal?.loading({ message: 'Loading File in VIM Ultra mode', progress }) + modal?.loading({ message: 'Loading File in VIM Ultra mode', progress: progress.current, mode: progress.type }) } } \ No newline at end of file diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index 64444c993..cffcb5048 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -8,6 +8,7 @@ import { LoadRequest } from '../helpers/loadRequest' import { ModalHandle } from '../panels/modal' import { UltraSuggestion } from '../panels/loadingBox' import { WebglSettings } from './settings' +import { IProgress } from '../../core-viewers/shared/loadResult' type AddSettings = { /** @@ -43,11 +44,11 @@ export class ComponentLoader { /** * Event emitter for progress updates. */ - onProgress (p: Core.Webgl.IProgressLogs) { + onProgress (p: IProgress) { this._modal.current?.loading({ message: 'Loading in WebGL Mode', - progress: p.loaded, - mode: 'bytes', + progress: p.current, + mode: p.type, more: this._addLink ? UltraSuggestion() : undefined }) } diff --git a/src/vim-web/react-viewers/webgl/viewerRef.ts b/src/vim-web/react-viewers/webgl/viewerRef.ts index aaaa0f866..817294d8f 100644 --- a/src/vim-web/react-viewers/webgl/viewerRef.ts +++ b/src/vim-web/react-viewers/webgl/viewerRef.ts @@ -3,7 +3,6 @@ */ import * as Core from '../../core-viewers' -import { ILoadRequest } from '../../core-viewers/shared/loadResult' import { ContextMenuRef } from '../panels/contextMenu' import { AnySettings } from '../settings/anySettings' import { CameraRef } from '../state/cameraState' @@ -86,7 +85,7 @@ export type ViewerRef = { * @param settings Optional settings * @returns LoadRequest to track progress and get result */ - load: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => ILoadRequest + load: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => Core.Webgl.ILoadRequest /** * Opens a vim file without loading geometry. @@ -95,7 +94,7 @@ export type ViewerRef = { * @param settings Optional settings * @returns LoadRequest to track progress and get result */ - open: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => ILoadRequest + open: (source: Core.Webgl.RequestSource, settings?: OpenSettings) => Core.Webgl.ILoadRequest /** * Removes a vim from the viewer and disposes it. From 53189e05e2a7eeb21c1d495275f86fe65f9d4eda Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 5 Feb 2026 11:36:37 -0500 Subject: [PATCH 27/94] gizmo to match ultra --- .../webgl/viewer/gizmos/gizmoOrbit.ts | 214 +++++++++++++----- .../viewer/settings/viewerDefaultSettings.ts | 7 +- .../webgl/viewer/settings/viewerSettings.ts | 18 +- 3 files changed, 169 insertions(+), 70 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts index 3a3ba3bd1..8a2034281 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts @@ -6,9 +6,20 @@ import { Renderer } from '../rendering/renderer' import { Camera } from '../camera/camera' import { ViewerSettings } from '../settings/viewerSettings' import {type InputHandler, PointerMode} from '../../../shared' +import { Layers } from '../raycaster' + +// Torus geometry parameters +const TORUS_RADIUS = 1 +const TUBE_RADIUS = 0.1 +const RADIAL_SEGMENTS = 64 +const TUBULAR_SEGMENTS = 16 + +const SQRT1_2 = Math.SQRT1_2 // √2/2 ≈ 0.707 /** - * Manages the camera target gizmo + * Manages the camera target gizmo - displays orbital rings at the camera target + * 2 vertical rings (great circles) + 3 horizontal rings (latitude circles) + * Each rendered twice: once with depth test, once always visible (for see-through effect) */ export class GizmoOrbit { // Dependencies @@ -17,24 +28,31 @@ export class GizmoOrbit { private _inputs: InputHandler // Settings - private _size: number = 1 - private _color: THREE.Color = new THREE.Color(0x000000) - private _opacity: number = 0.2 - private _opacityAlways: number = 0.5 + private _size: number = 0.1 private _showDurationMs: number = 1000 // Resources - private _box: THREE.BufferGeometry | undefined - private _wireframe: THREE.BufferGeometry | undefined - private _material: THREE.LineBasicMaterial | undefined - private _materialAlways: THREE.LineBasicMaterial | undefined - private _gizmos: THREE.LineSegments | undefined + private _torusGeometry: THREE.TorusGeometry | undefined + private _verticalMaterialDepth: THREE.MeshBasicMaterial | undefined + private _verticalMaterialAlways: THREE.MeshBasicMaterial | undefined + private _horizontalMaterialDepth: THREE.MeshBasicMaterial | undefined + private _horizontalMaterialAlways: THREE.MeshBasicMaterial | undefined + private _verticalMeshDepth: THREE.InstancedMesh | undefined + private _verticalMeshAlways: THREE.InstancedMesh | undefined + private _horizontalMeshDepth: THREE.InstancedMesh | undefined + private _horizontalMeshAlways: THREE.InstancedMesh | undefined + private _gizmos: THREE.Group | undefined private _disconnectCamera: () => void // State private _timeout: ReturnType | undefined private _active: boolean = true - private _animation: number = 0 + + // Cached settings for material updates + private _color: THREE.Color = new THREE.Color(0x0590cc) + private _colorHorizontal: THREE.Color = new THREE.Color(0x58b5dd) + private _opacity: number = 0.5 + private _opacityAlways: number = 0.1 constructor ( renderer: Renderer, @@ -95,7 +113,7 @@ export class GizmoOrbit { clearTimeout(this._timeout) this._gizmos.visible = show this._renderer.needsUpdate = true - + // Hide after one second since last request if (show) { this._timeout = setTimeout(() => { @@ -116,101 +134,175 @@ export class GizmoOrbit { /** * Updates the size of the orbit gizmo. - * @param {number} size - The new size of the orbit gizmo. + * @param {number} size - The new size as fraction of screen (0-1). */ setSize (size: number) { this._size = size } /** - * Updates the opacity of the orbit gizmo. - * @param {number} opacity - The opacity of the non-occluded part. - * @param {number} opacityAlways - The opacity of the occluded part. + * Updates the colors of the orbit gizmo. */ - setOpacity (opacity: number, opacityAlways: number) { - this._opacity = opacity - this._opacityAlways = opacityAlways - if (!this._gizmos) return - this._material.opacity = opacity - this._materialAlways.opacity = opacityAlways + setColors (color: THREE.Color, colorHorizontal: THREE.Color) { + this._color = color + this._colorHorizontal = colorHorizontal + if (this._verticalMaterialDepth) { + this._verticalMaterialDepth.color = color + this._verticalMaterialAlways.color = color + this._horizontalMaterialDepth.color = colorHorizontal + this._horizontalMaterialAlways.color = colorHorizontal + } } /** - * Updates the color of the orbit gizmo. - * @param {THREE.Color} color - The new color for the orbit gizmo. + * Updates the opacities of the orbit gizmo. */ - setColor (color: THREE.Color) { - this._color = color - if (!this._gizmos) return - this._material.color = color - this._materialAlways.color = color + setOpacity (opacity: number, opacityAlways: number) { + this._opacity = opacity + this._opacityAlways = opacityAlways + if (this._verticalMaterialDepth) { + this._verticalMaterialDepth.opacity = opacity + this._verticalMaterialAlways.opacity = opacityAlways + this._horizontalMaterialDepth.opacity = opacity + this._horizontalMaterialAlways.opacity = opacityAlways + } } private applySettings (settings: ViewerSettings) { this._active = settings.camera.gizmo.enable - this.setColor(settings.camera.gizmo.color) this.setSize(settings.camera.gizmo.size) - - this.setOpacity( - settings.camera.gizmo.opacity, - settings.camera.gizmo.opacityAlways - ) + this.setColors(settings.camera.gizmo.color, settings.camera.gizmo.colorHorizontal) + this.setOpacity(settings.camera.gizmo.opacity, settings.camera.gizmo.opacityAlways) } private updateScale () { if (!this._gizmos) return const frustrum = this._camera.frustrumSizeAt(this._gizmos.position) - const min = Math.min(frustrum.x, frustrum.y) / 2 - const h = min * this._size + // Size is fraction of screen (0-1), use smaller dimension + const screenSize = Math.min(frustrum.x, frustrum.y) + const h = screenSize * this._size this._gizmos.scale.set(h, h, h) } private createGizmo () { - this._box = new THREE.SphereGeometry(1) - this._wireframe = new THREE.WireframeGeometry(this._box) - this._wireframe.addGroup(0, Infinity, 0) - this._wireframe.addGroup(0, Infinity, 1) + this._gizmos = new THREE.Group() + + // Shared torus geometry (unit radius) + this._torusGeometry = new THREE.TorusGeometry( + TORUS_RADIUS, + TUBE_RADIUS, + TUBULAR_SEGMENTS, + RADIAL_SEGMENTS + ) - this._material = new THREE.LineBasicMaterial({ + // Materials for vertical rings + this._verticalMaterialDepth = new THREE.MeshBasicMaterial({ + color: this._color, depthTest: true, + transparent: true, opacity: this._opacity, + side: THREE.DoubleSide + }) + this._verticalMaterialAlways = new THREE.MeshBasicMaterial({ color: this._color, - transparent: true + depthTest: false, + transparent: true, + opacity: this._opacityAlways, + side: THREE.DoubleSide + }) + + // Materials for horizontal rings + this._horizontalMaterialDepth = new THREE.MeshBasicMaterial({ + color: this._colorHorizontal, + depthTest: true, + transparent: true, + opacity: this._opacity, + side: THREE.DoubleSide }) - this._materialAlways = new THREE.LineBasicMaterial({ + this._horizontalMaterialAlways = new THREE.MeshBasicMaterial({ + color: this._colorHorizontal, depthTest: false, + transparent: true, opacity: this._opacityAlways, - color: this._color, - transparent: true + side: THREE.DoubleSide }) - // Add to scene as group - this._gizmos = new THREE.LineSegments(this._wireframe, [ - this._material, - this._materialAlways - ]) + // Instance matrices for vertical rings (2 rings) + const verticalMatrices = [ + new THREE.Matrix4().makeRotationY(Math.PI / 2), // YZ plane + new THREE.Matrix4().makeRotationX(Math.PI / 2) // XZ plane + ] + + // Instance matrices for horizontal rings (3 rings) + const pos = new THREE.Vector3() + const quat = new THREE.Quaternion() + const scale = new THREE.Vector3() - this._gizmos.layers.set(2) + const horizontalMatrices = [ + new THREE.Matrix4().compose(pos.set(0, 0, SQRT1_2), quat, scale.setScalar(SQRT1_2)), + new THREE.Matrix4().compose(pos.set(0, 0, 0), quat, scale.setScalar(1)), + new THREE.Matrix4().compose(pos.set(0, 0, -SQRT1_2), quat, scale.setScalar(SQRT1_2)) + ] + + // Create vertical instanced meshes (depth and always) + this._verticalMeshDepth = this.createInstancedMesh(this._verticalMaterialDepth, verticalMatrices, 0) + this._verticalMeshAlways = this.createInstancedMesh(this._verticalMaterialAlways, verticalMatrices, 1) + this._gizmos.add(this._verticalMeshDepth) + this._gizmos.add(this._verticalMeshAlways) + + // Create horizontal instanced meshes (depth and always) + this._horizontalMeshDepth = this.createInstancedMesh(this._horizontalMaterialDepth, horizontalMatrices, 0) + this._horizontalMeshAlways = this.createInstancedMesh(this._horizontalMaterialAlways, horizontalMatrices, 1) + this._gizmos.add(this._horizontalMeshDepth) + this._gizmos.add(this._horizontalMeshAlways) + + this._gizmos.layers.set(Layers.NoRaycast) this._renderer.add(this._gizmos) this.updateScale() } + private createInstancedMesh ( + material: THREE.MeshBasicMaterial, + matrices: THREE.Matrix4[], + renderOrder: number + ): THREE.InstancedMesh { + const mesh = new THREE.InstancedMesh( + this._torusGeometry, + material, + matrices.length + ) + mesh.layers.set(Layers.NoRaycast) + mesh.renderOrder = renderOrder + + for (let i = 0; i < matrices.length; i++) { + mesh.setMatrixAt(i, matrices[i]) + } + mesh.instanceMatrix.needsUpdate = true + + return mesh + } + /** * Disposes of all resources. */ dispose () { - cancelAnimationFrame(this._animation) clearTimeout(this._timeout) - this._box?.dispose() - this._wireframe?.dispose() - this._material?.dispose() - this._materialAlways?.dispose() + this._torusGeometry?.dispose() + this._verticalMaterialDepth?.dispose() + this._verticalMaterialAlways?.dispose() + this._horizontalMaterialDepth?.dispose() + this._horizontalMaterialAlways?.dispose() this._disconnectCamera?.() - this._box = undefined - this._wireframe = undefined - this._material = undefined - this._materialAlways = undefined + this._torusGeometry = undefined + this._verticalMaterialDepth = undefined + this._verticalMaterialAlways = undefined + this._horizontalMaterialDepth = undefined + this._horizontalMaterialAlways = undefined + this._verticalMeshDepth = undefined + this._verticalMeshAlways = undefined + this._horizontalMeshDepth = undefined + this._horizontalMeshAlways = undefined this._disconnectCamera = undefined if (this._gizmos) { diff --git a/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts b/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts index 5920b0b19..cb8002722 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts @@ -32,9 +32,10 @@ export function getDefaultViewerSettings(): ViewerSettings { gizmo: { enable: true, size: 0.01, - color: new THREE.Color(0x444444), - opacity: 0.3, - opacityAlways: 0.02 + color: new THREE.Color(0x0590cc), + colorHorizontal: new THREE.Color(0x58b5dd), + opacity: 0.5, + opacityAlways: 0.1 } }, background: { color: new THREE.Color(0xc1c2c6) }, diff --git a/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts b/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts index 756794d81..f7416237c 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts @@ -128,26 +128,32 @@ export type ViewerSettings = { enable: boolean /** - * Size of camera gizmo. - * Default: 0.01 + * Size of camera gizmo as fraction of screen (0-1). + * Default: 0.1 */ size: number /** - * Color of camera gizmo. - * Default: THREE.Color(255, 255, 255) + * Color of vertical rings (great circles). + * Default: THREE.Color(0x0590cc) - VIM blue */ color: THREE.Color /** - * Opacity of the camera gizmo. + * Color of horizontal rings (latitude circles). + * Default: THREE.Color(0x58b5dd) - Primary_300 + */ + colorHorizontal: THREE.Color + + /** + * Opacity of the camera gizmo when in front of objects. * Default: 0.5 */ opacity: number /** * Opacity of the camera gizmo when behind objects. - * Default: 0.125 + * Default: 0.1 */ opacityAlways: number } From 74b0fa5c42cf4a2efe7fc113ff4c6993c1091992 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 5 Feb 2026 12:56:37 -0500 Subject: [PATCH 28/94] almost good camera detached orbit --- .../core-viewers/shared/inputAdapter.ts | 2 +- .../core-viewers/shared/inputHandler.ts | 7 +- .../core-viewers/shared/mouseHandler.ts | 4 +- .../core-viewers/ultra/inputAdapter.ts | 3 +- .../webgl/viewer/camera/cameraMovement.ts | 8 ++ .../webgl/viewer/camera/cameraMovementLerp.ts | 86 ++++++++++++--- .../webgl/viewer/camera/cameraMovementSnap.ts | 103 +++++++++++++----- .../core-viewers/webgl/viewer/inputAdapter.ts | 9 +- 8 files changed, 172 insertions(+), 50 deletions(-) diff --git a/src/vim-web/core-viewers/shared/inputAdapter.ts b/src/vim-web/core-viewers/shared/inputAdapter.ts index 0d90cb88d..3b0aa1588 100644 --- a/src/vim-web/core-viewers/shared/inputAdapter.ts +++ b/src/vim-web/core-viewers/shared/inputAdapter.ts @@ -23,5 +23,5 @@ export interface IInputAdapter{ selectAtPointer: (pos: THREE.Vector2, add: boolean) => void frameAtPointer: (pos: THREE.Vector2) => void - zoom: (value: number) => void + zoom: (value: number, screenPos?: THREE.Vector2) => void } \ No newline at end of file diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index 549c8197a..4c747ddef 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -114,13 +114,16 @@ export class InputHandler extends BaseInputHandler { this.mouse.onClick = (pos: THREE.Vector2, modif: boolean) => adapter.selectAtPointer(pos, modif) this.mouse.onDoubleClick = adapter.frameAtPointer - this.mouse.onWheel = (value: number, ctrl: boolean) => { + this.mouse.onWheel = (value: number, ctrl: boolean, clientX: number, clientY: number) => { if(ctrl){ console.log('ctrl', value) this.moveSpeed -= Math.sign(value) } else{ - adapter.zoom(this.getZoomValue(value)) + const rect = this._canvas.getBoundingClientRect() + const screenX = (clientX - rect.left) / rect.width + const screenY = (clientY - rect.top) / rect.height + adapter.zoom(this.getZoomValue(value), new THREE.Vector2(screenX, screenY)) } } diff --git a/src/vim-web/core-viewers/shared/mouseHandler.ts b/src/vim-web/core-viewers/shared/mouseHandler.ts index 63145587f..93f689a34 100644 --- a/src/vim-web/core-viewers/shared/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/mouseHandler.ts @@ -19,7 +19,7 @@ export class MouseHandler extends BaseInputHandler { onDrag: DragCallback; // Callback for drag movement onClick: (position: THREE.Vector2, ctrl: boolean) => void; onDoubleClick: (position: THREE.Vector2) => void; - onWheel: (value: number, ctrl: boolean) => void; + onWheel: (value: number, ctrl: boolean, clientX: number, clientY: number) => void; onContextMenu: (position: THREE.Vector2) => void; constructor(canvas: HTMLCanvasElement) { @@ -124,7 +124,7 @@ export class MouseHandler extends BaseInputHandler { } private onMouseScroll(event: WheelEvent): void { - this.onWheel?.(Math.sign(event.deltaY), event.ctrlKey); + this.onWheel?.(Math.sign(event.deltaY), event.ctrlKey, event.clientX, event.clientY); event.preventDefault(); } diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index 5b5a7ef3a..c2a276d77 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -94,7 +94,8 @@ function createAdapter(viewer: Viewer): IInputAdapter { viewer.camera.frameAll(1); } }, - zoom: (value: number) => { + zoom: (value: number, screenPos?: THREE.Vector2) => { + // Ultra handles zoom server-side, screenPos not used viewer.rpc.RPCMouseScrollEvent(value >= 1 ? 1 : -1); }, moveCamera: (value: THREE.Vector3) => { diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 1bb366c51..4651400bc 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -72,6 +72,14 @@ export abstract class CameraMovement { */ abstract zoom(amount: number): void + /** + * Zooms the camera toward a specific world point while preserving camera orientation. + * The orbit target is updated to the world point for future orbit operations. + * @param {number} amount - The zoom factor (e.g., 0.5 to move closer, 2 to move farther). + * @param {THREE.Vector3} worldPoint - The world position to zoom toward. + */ + abstract zoomTowards(amount: number, worldPoint: THREE.Vector3): void + /** * Sets the distance between the camera and its target to the specified value. * @param {number} dist - The new distance between the camera and its target. diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 2e4bfc707..ad152dd3a 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -60,16 +60,15 @@ export class CameraLerp extends CameraMovement { override move3 (vector: THREE.Vector3): void { const v = vector.clone() v.applyQuaternion(this._camera.quaternion) - const start = this._camera.position.clone() - const end = this._camera.position.clone().add(v) - const pos = new THREE.Vector3() - - const offset = this._camera.forward.multiplyScalar(this._camera.orbitDistance) + const startPos = this._camera.position.clone() + const endPos = this._camera.position.clone().add(v) + const startTarget = this._camera.target.clone() + const endTarget = this._camera.target.clone().add(v) this.onProgress = (progress) => { - pos.copy(start) - pos.lerp(end, progress) - this._movement.set(pos, pos.clone().add(offset)) + const pos = startPos.clone().lerp(endPos, progress) + const target = startTarget.clone().lerp(endTarget, progress) + this._movement.set(pos, target, false) } } @@ -114,16 +113,75 @@ export class CameraLerp extends CameraMovement { } } + zoomTowards(amount: number, worldPoint: THREE.Vector3): void { + const startPos = this._camera.position.clone() + + // Direction from world point to camera + const direction = startPos.clone().sub(worldPoint).normalize() + + // Calculate end position + const currentDist = startPos.distanceTo(worldPoint) + const newDist = currentDist * amount + const endPos = worldPoint.clone().add(direction.multiplyScalar(newDist)) + + // Set orbit target immediately (not animated) + this._camera.target.copy(worldPoint) + + this.onProgress = (progress) => { + // Only lerp position, orientation stays unchanged + this._camera.position.copy(startPos).lerp(endPos, progress) + } + } + orbit (angle: THREE.Vector2): void { const startPos = this._camera.position.clone() - const startTarget = this._camera.target.clone() - const a = new THREE.Vector2() + const startForward = this._camera.forward.clone() + const locked = angle.clone().multiply(this._camera.allowedRotation) + + const worldUp = new THREE.Vector3(0, 0, 1) + + // Get horizontal right axis + let right = new THREE.Vector3().crossVectors(worldUp, startForward) + if (right.lengthSq() < 0.001) { + right.set(1, 0, 0).applyQuaternion(this._camera.quaternion) + right.z = 0 + } + right.normalize() + + // Azimuth: rotate around world Z + const azimuthQuat = new THREE.Quaternion().setFromAxisAngle( + worldUp, + (-locked.y * Math.PI) / 180 + ) + + // Elevation: rotate around horizontal right axis + const elevationQuat = new THREE.Quaternion().setFromAxisAngle( + right, + (-locked.x * Math.PI) / 180 + ) + + // Combined rotation + const orbitQuat = new THREE.Quaternion().multiplyQuaternions(elevationQuat, azimuthQuat) + + // Calculate end position + const offset = startPos.clone().sub(this._camera.target) + offset.applyQuaternion(orbitQuat) + const endPos = this._camera.target.clone().add(offset) + + // Calculate end forward direction + const endForward = startForward.clone().applyQuaternion(orbitQuat) this.onProgress = (progress) => { - a.set(0, 0) - a.lerp(angle, progress) - this._movement.set(startPos, startTarget) - this._movement.orbit(a) + // Lerp position + this._camera.position.copy(startPos).lerp(endPos, progress) + + // Slerp forward direction + const currentForward = startForward.clone().lerp(endForward, progress).normalize() + + // Orient camera along current forward with Z up (no roll) + const lookTarget = this._camera.position.clone().add(currentForward) + this._camera.camPerspective.camera.up.set(0, 0, 1) + this._camera.camPerspective.camera.lookAt(lookTarget) } } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index 1707d7496..76cd2cfd3 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -22,7 +22,7 @@ export class CameraMovementSnap extends CameraMovement { .clone() .sub(this._camera.forward.multiplyScalar(dist)) - this.set(pos, this._camera.target) + this.set(pos, this._camera.target, false) } rotate (angle: THREE.Vector2): void { @@ -48,8 +48,49 @@ export class CameraMovementSnap extends CameraMovement { orbit (angle: THREE.Vector2): void { const locked = angle.clone().multiply(this._camera.allowedRotation) - const pos = this.predictOrbit(locked) - this.set(pos) + + const worldUp = new THREE.Vector3(0, 0, 1) + const forward = this._camera.forward.clone() + + // Get horizontal right axis (perpendicular to world up and view direction) + let right = new THREE.Vector3().crossVectors(worldUp, forward) + if (right.lengthSq() < 0.001) { + // Looking straight up/down - use camera's right projected to horizontal + right.set(1, 0, 0).applyQuaternion(this._camera.quaternion) + right.z = 0 + } + right.normalize() + + // Azimuth: rotate around world Z (no roll) + const azimuthQuat = new THREE.Quaternion().setFromAxisAngle( + worldUp, + (-locked.y * Math.PI) / 180 + ) + + // Elevation: rotate around horizontal right axis (no roll) + const elevationQuat = new THREE.Quaternion().setFromAxisAngle( + right, + (-locked.x * Math.PI) / 180 + ) + + // Combined rotation (apply azimuth first, then elevation) + const orbitQuat = new THREE.Quaternion().multiplyQuaternions(elevationQuat, azimuthQuat) + + // Rotate position offset around target + const offset = this._camera.position.clone().sub(this._camera.target) + offset.applyQuaternion(orbitQuat) + const newPos = this._camera.target.clone().add(offset) + + // Rotate forward direction by same amount + const newForward = forward.applyQuaternion(orbitQuat) + + // Set position (with clamping and locking) + this.set(newPos, this._camera.target, false) + + // Orient camera along new forward with Z up (removes any accumulated roll) + const lookTarget = this._camera.position.clone().add(newForward) + this._camera.camPerspective.camera.up.set(0, 0, 1) + this._camera.camPerspective.camera.lookAt(lookTarget) } override move3 (vector: THREE.Vector3): void { @@ -58,27 +99,27 @@ export class CameraMovementSnap extends CameraMovement { const locked = this.lockVector(v, new THREE.Vector3()) const pos = this._camera.position.clone().add(locked) const target = this._camera.target.clone().add(locked) - this.set(pos, target) + this.set(pos, target, false) } - set(position: THREE.Vector3, target?: THREE.Vector3) { + set(position: THREE.Vector3, target?: THREE.Vector3, lookAt: boolean = true) { // Use the existing camera's target if none is provided target = target ?? this._camera.target; - + // direction = (desired camera position) - (fixed target) const direction = new THREE.Vector3().subVectors(position, target); const dist = direction.length(); - + // If camera and target coincide, skip angle clamping if (dist > 1e-6) { // Angle between direction and "up" (0,0,1) in [0..PI] const up = new THREE.Vector3(0, 0, 1); const angle = direction.angleTo(up); - + // We'll clamp angle to the range [5°, 175°] const minAngle = THREE.MathUtils.degToRad(5); const maxAngle = THREE.MathUtils.degToRad(175); - + if (angle < minAngle) { // direction is too close to straight up // rotate 'direction' so angle becomes exactly minAngle @@ -92,22 +133,39 @@ export class CameraMovementSnap extends CameraMovement { const delta = maxAngle - angle; // negative => rotate back toward up direction.applyQuaternion(new THREE.Quaternion().setFromAxisAngle(axis, delta)); } - + // 'direction' now has the same length but is clamped in angle // Recompute the actual camera position position.copy(target).add(direction); } - + // 2) Pass the adjusted position through your locking logic const lockedPos = this.lockVector(position, this._camera.position); this._camera.position.copy(lockedPos); - + // 3) The target remains exactly as given this._camera.target.copy(target); - - // 4) Orient the camera to look at the target, with Z as up - this._camera.camPerspective.camera.up.set(0, 0, 1); - this._camera.camPerspective.camera.lookAt(target); + + // 4) Orient the camera to look at the target, with Z as up (only if lookAt is true) + if (lookAt) { + this._camera.camPerspective.camera.up.set(0, 0, 1); + this._camera.camPerspective.camera.lookAt(target); + } + } + + zoomTowards(amount: number, worldPoint: THREE.Vector3): void { + // Direction from world point to camera + const direction = this._camera.position.clone().sub(worldPoint).normalize() + + // Calculate new distance + const currentDist = this._camera.position.distanceTo(worldPoint) + const newDist = currentDist * amount + + // New camera position + const newPos = worldPoint.clone().add(direction.multiplyScalar(newDist)) + + // Set position and update orbit target without changing orientation + this.set(newPos, worldPoint, false) } @@ -119,19 +177,6 @@ export class CameraMovementSnap extends CameraMovement { return new THREE.Vector3(x, y, z) } - predictOrbit (angle: THREE.Vector2) { - const rotation = this.predictRotate(angle) - - const delta = new THREE.Vector3(0, 0, 1) - .applyQuaternion(rotation) - .multiplyScalar(this._camera.orbitDistance) - - const pos = this._camera.target.clone().add(delta) - - - return pos - } - predictRotate(angle: THREE.Vector2) { // Create quaternions for rotation around X and Z axes const xQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), (angle.x * Math.PI) / 180) diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index 2852e8bca..6c009cf76 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -70,7 +70,14 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { const result = await viewer.raycaster.raycastFromScreen(pos) viewer.camera.lerp(0.75).frame(result.object ?? 'all') }, - zoom: (value: number) => { + zoom: async (value: number, screenPos?: THREE.Vector2) => { + if (screenPos) { + const result = await viewer.raycaster.raycastFromScreen(screenPos) + if (result?.worldPosition) { + viewer.camera.lerp(0.75).zoomTowards(value, result.worldPosition) + return + } + } viewer.camera.lerp(0.75).zoom(value) }, moveCamera: (value : THREE.Vector3) => { From 89391cbbca63810418e5ee08e8391a101ce1e52f Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 5 Feb 2026 12:56:37 -0500 Subject: [PATCH 29/94] zoom resets orbit target --- .../core-viewers/shared/inputAdapter.ts | 2 +- .../core-viewers/shared/inputHandler.ts | 4 ++-- .../core-viewers/shared/mouseHandler.ts | 5 +++-- .../core-viewers/ultra/inputAdapter.ts | 3 ++- .../webgl/viewer/camera/cameraMovement.ts | 19 +++++++++++++++++++ .../core-viewers/webgl/viewer/inputAdapter.ts | 7 +++++-- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/vim-web/core-viewers/shared/inputAdapter.ts b/src/vim-web/core-viewers/shared/inputAdapter.ts index 0d90cb88d..03837d3dc 100644 --- a/src/vim-web/core-viewers/shared/inputAdapter.ts +++ b/src/vim-web/core-viewers/shared/inputAdapter.ts @@ -23,5 +23,5 @@ export interface IInputAdapter{ selectAtPointer: (pos: THREE.Vector2, add: boolean) => void frameAtPointer: (pos: THREE.Vector2) => void - zoom: (value: number) => void + zoom: (value: number, pos?: THREE.Vector2) => void } \ No newline at end of file diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index 549c8197a..a6f8679ce 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -114,13 +114,13 @@ export class InputHandler extends BaseInputHandler { this.mouse.onClick = (pos: THREE.Vector2, modif: boolean) => adapter.selectAtPointer(pos, modif) this.mouse.onDoubleClick = adapter.frameAtPointer - this.mouse.onWheel = (value: number, ctrl: boolean) => { + this.mouse.onWheel = (value: number, ctrl: boolean, pos: THREE.Vector2) => { if(ctrl){ console.log('ctrl', value) this.moveSpeed -= Math.sign(value) } else{ - adapter.zoom(this.getZoomValue(value)) + adapter.zoom(this.getZoomValue(value), pos) } } diff --git a/src/vim-web/core-viewers/shared/mouseHandler.ts b/src/vim-web/core-viewers/shared/mouseHandler.ts index 63145587f..244180377 100644 --- a/src/vim-web/core-viewers/shared/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/mouseHandler.ts @@ -19,7 +19,7 @@ export class MouseHandler extends BaseInputHandler { onDrag: DragCallback; // Callback for drag movement onClick: (position: THREE.Vector2, ctrl: boolean) => void; onDoubleClick: (position: THREE.Vector2) => void; - onWheel: (value: number, ctrl: boolean) => void; + onWheel: (value: number, ctrl: boolean, pos: THREE.Vector2) => void; onContextMenu: (position: THREE.Vector2) => void; constructor(canvas: HTMLCanvasElement) { @@ -124,7 +124,8 @@ export class MouseHandler extends BaseInputHandler { } private onMouseScroll(event: WheelEvent): void { - this.onWheel?.(Math.sign(event.deltaY), event.ctrlKey); + const pos = this.relativePosition(event as PointerEvent); + this.onWheel?.(Math.sign(event.deltaY), event.ctrlKey, pos); event.preventDefault(); } diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index 5b5a7ef3a..852f459dc 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -94,7 +94,8 @@ function createAdapter(viewer: Viewer): IInputAdapter { viewer.camera.frameAll(1); } }, - zoom: (value: number) => { + zoom: (value: number, pos?: THREE.Vector2) => { + // Position is ignored - server handles zoom-to-cursor viewer.rpc.RPCMouseScrollEvent(value >= 1 ? 1 : -1); }, moveCamera: (value: THREE.Vector3) => { diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 1bb366c51..a75907950 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -72,6 +72,25 @@ export abstract class CameraMovement { */ abstract zoom(amount: number): void + /** + * Zooms toward a specific point, making it the new orbit target. + * Preserves the camera's forward direction while moving closer/farther from the point. + * If point is undefined, falls back to regular zoom. + * @param {THREE.Vector3 | undefined} point - The world position to zoom toward, or undefined for regular zoom + * @param {number} amount - The zoom factor (< 1 zooms in, > 1 zooms out) + */ + zoomTo(point: THREE.Vector3 | undefined, amount: number): void { + if (!point) { + this.zoom(amount) + return + } + const forward = this._camera.forward.clone().normalize() + const newTarget = point.clone() + const newOrbitDistance = this._camera.orbitDistance * amount + const newPos = newTarget.clone().sub(forward.multiplyScalar(newOrbitDistance)) + this.set(newPos, newTarget) + } + /** * Sets the distance between the camera and its target to the specified value. * @param {number} dist - The new distance between the camera and its target. diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index 2852e8bca..3c2c96075 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -70,8 +70,11 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { const result = await viewer.raycaster.raycastFromScreen(pos) viewer.camera.lerp(0.75).frame(result.object ?? 'all') }, - zoom: (value: number) => { - viewer.camera.lerp(0.75).zoom(value) + zoom: async (value: number, pos?: THREE.Vector2) => { + if (pos) { + const result = await viewer.raycaster.raycastFromScreen(pos) + viewer.camera.lerp(0.75).zoomTo(result?.worldPosition, value) + } }, moveCamera: (value : THREE.Vector3) => { viewer.camera.localVelocity = value From 3156bd8d16559f73c32a76d0f9a44c8583fb3f40 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 5 Feb 2026 14:12:19 -0500 Subject: [PATCH 30/94] claude md --- CLAUDE.md | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8045c4ef5..0d4063d27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ React-based 3D viewers for VIM files with BIM (Building Information Modeling) su | InsertableGeometry | `src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts` | | InstancedMeshFactory | `src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts` | | VimMeshFactory | `src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts` | +| VimSettings | `src/vim-web/core-viewers/webgl/loader/vimSettings.ts` | ### Import Pattern @@ -183,6 +184,7 @@ camera.snap().frame(box) // Instant frame box camera.lerp(1).orbit(new THREE.Vector2(45, 0)) // Orbit by degrees camera.lerp(1).orbitTowards(new THREE.Vector3(0, 0, -1)) // Top-down camera.lerp(1).zoom(1.5) // Zoom out +camera.lerp(1).zoomTo(point, 0.8) // Zoom toward point (becomes new orbit target) camera.snap().set(position, target) // Set position/target // State @@ -249,7 +251,7 @@ state.useMemo((v) => compute(v)) | Left Drag | Orbit (or mode-specific) | | Right Drag | Look | | Middle Drag | Pan | -| Wheel | Zoom | +| Wheel | Zoom to cursor (if over geometry) or zoom toward current target | | Click | Select | | Shift+Click | Add to selection | | Double-Click | Frame | @@ -437,6 +439,24 @@ viewer.core.selection.onSelectionChanged.subscribe(async () => { }) ``` +### Load Multiple VIMs in a Grid + +```typescript +const gridSize = 3 +const spacing = 50 + +for (let row = 0; row < gridSize; row++) { + for (let col = 0; col < gridSize; col++) { + const position = new VIM.THREE.Vector3( + col * spacing - (gridSize - 1) * spacing / 2, + row * spacing - (gridSize - 1) * spacing / 2, + 0 + ) + viewer.load({ url }, { position }) + } +} +``` + --- ## Naming Conventions @@ -570,6 +590,27 @@ RpcSafeClient (validation, batching) → RpcClient (marshaling) → SocketClient - Fire-and-forget for input, request-response for queries - Key files: `rpcSafeClient.ts`, `rpcClient.ts`, `socketClient.ts` +### VimSettings (Load Options) + +Settings passed to `viewer.load()` to configure vim transformation and rendering: + +```typescript +type VimSettings = { + position: THREE.Vector3 // Positional offset + rotation: THREE.Vector3 // XYZ rotation in degrees + scale: number // Uniform scale factor + matrix: THREE.Matrix4 // Override transform (replaces position/rotation/scale) + transparency: 'all' | 'opaque' | 'transparent' // What to render + verboseHttp: boolean // Enable HTTP logging +} + +// Example: Load with offset +viewer.load({ url }, { position: new THREE.Vector3(100, 0, 0) }) + +// Example: Load rotated and scaled +viewer.load({ url }, { rotation: new THREE.Vector3(0, 0, 45), scale: 2 }) +``` + ### VIM Data Model | Concept | Description | From 6c2c0c8abc7de6b2f4537711e0381fb6191f5195 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 5 Feb 2026 15:20:45 -0500 Subject: [PATCH 31/94] type fix --- src/vim-web/core-viewers/shared/mouseHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vim-web/core-viewers/shared/mouseHandler.ts b/src/vim-web/core-viewers/shared/mouseHandler.ts index 244180377..1d3e53562 100644 --- a/src/vim-web/core-viewers/shared/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/mouseHandler.ts @@ -124,7 +124,7 @@ export class MouseHandler extends BaseInputHandler { } private onMouseScroll(event: WheelEvent): void { - const pos = this.relativePosition(event as PointerEvent); + const pos = this.relativePosition(event); this.onWheel?.(Math.sign(event.deltaY), event.ctrlKey, pos); event.preventDefault(); } From 83b9c6b7e66e349f5cf8b50b51bd74b0d9c7c754 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 5 Feb 2026 15:31:57 -0500 Subject: [PATCH 32/94] fixed bb not beeing computed --- package.json | 2 +- .../loader/progressive/insertableGeometry.ts | 1 + .../loader/progressive/insertableMesh.ts | 1 + .../react-viewers/helpers/loadRequest.ts | 20 +++++++++++-------- src/vim-web/react-viewers/webgl/loading.ts | 4 ++-- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 438d16984..90a9d1ef8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vim-web", - "version": "0.5.1", + "version": "0.6.0-dev.1", "description": "A demonstration app built on top of the vim-webgl-viewer", "type": "module", "files": [ diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 72e0630b4..e86101b3b 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -349,6 +349,7 @@ export class InsertableGeometry { if (this._computeBoundingBox) { this.geometry.computeBoundingBox() this.geometry.computeBoundingSphere() + this.boundingBox = this.geometry.boundingBox } } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts index 96904fccb..45c4142b9 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts @@ -89,6 +89,7 @@ export class InsertableMesh { update () { this.geometry.update() + this.vim?.scene.updateBox(this.geometry.boundingBox) } clearUpdate () { diff --git a/src/vim-web/react-viewers/helpers/loadRequest.ts b/src/vim-web/react-viewers/helpers/loadRequest.ts index f0af695c2..d4642aea2 100644 --- a/src/vim-web/react-viewers/helpers/loadRequest.ts +++ b/src/vim-web/react-viewers/helpers/loadRequest.ts @@ -1,6 +1,6 @@ import * as Core from '../../core-viewers' import { LoadRequest as CoreLoadRequest, ILoadRequest as CoreILoadRequest } from '../../core-viewers/webgl/loader/progressive/loadRequest' -import { IProgress } from '../../core-viewers/shared/loadResult' +import { IProgress, LoadResult } from '../../core-viewers/shared/loadResult' import { AsyncQueue } from '../../utils/asyncQueue' import { LoadingError } from '../webgl/loading' @@ -18,24 +18,25 @@ export class LoadRequest implements CoreILoadRequest { private _source: Core.Webgl.RequestSource private _request: Core.Webgl.LoadRequest private _callbacks: RequestCallbacks - private _onLoaded?: (vim: Core.Webgl.Vim) => void + private _onLoaded?: (vim: Core.Webgl.Vim) => Promise | void private _progressQueue = new AsyncQueue() + private _resultPromise: Promise> constructor ( callbacks: RequestCallbacks, source: Core.Webgl.RequestSource, settings: Core.Webgl.VimPartialSettings, vimIndex: number, - onLoaded?: (vim: Core.Webgl.Vim) => void + onLoaded?: (vim: Core.Webgl.Vim) => Promise | void ) { this._source = source this._callbacks = callbacks this._onLoaded = onLoaded this._request = new CoreLoadRequest(source, settings, vimIndex) - this.trackRequest() + this._resultPromise = this.trackAndGetResult() } - private async trackRequest () { + private async trackAndGetResult (): Promise> { try { for await (const progress of this._request.getProgress()) { this._callbacks.onProgress(progress) @@ -46,13 +47,16 @@ export class LoadRequest implements CoreILoadRequest { if (result.isSuccess === false) { this._callbacks.onError({ url: this._source.url, error: result.error }) } else { - this._onLoaded?.(result.vim) + await this._onLoaded?.(result.vim) this._callbacks.onDone() } + this._progressQueue.close() + return result } catch (err) { this._callbacks.onError({ url: this._source.url, error: String(err) }) + this._progressQueue.close() + throw err } - this._progressQueue.close() } get isCompleted () { @@ -64,7 +68,7 @@ export class LoadRequest implements CoreILoadRequest { } getResult () { - return this._request.getResult() + return this._resultPromise } abort () { diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index cffcb5048..845de2988 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -111,7 +111,7 @@ export class ComponentLoader { ) } - private initVim (vim: Core.Webgl.Vim, settings: AddSettings, loadGeometry: boolean) { + private async initVim (vim: Core.Webgl.Vim, settings: AddSettings, loadGeometry: boolean) { this._viewer.add(vim) vim.onLoadingUpdate.subscribe(() => { this._viewer.gizmos.loading.visible = vim.isLoading @@ -121,7 +121,7 @@ export class ComponentLoader { } }) if (loadGeometry) { - void vim.loadAll() + await vim.loadAll() } } } From 75d1dc487f2c36ce71ac16007cdc9e02d0190d55 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 5 Feb 2026 15:43:09 -0500 Subject: [PATCH 33/94] fixed raycast result --- src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index 3c2c96075..2d86f730e 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -59,16 +59,16 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { const result = await viewer.raycaster.raycastFromScreen(pos) if(add){ - viewer.selection.add(result.object) + viewer.selection.add(result?.object) } else{ - viewer.selection.select(result.object) + viewer.selection.select(result?.object) } }, frameAtPointer: async (pos: THREE.Vector2) => { //TODO: This logic should happen in shared code const result = await viewer.raycaster.raycastFromScreen(pos) - viewer.camera.lerp(0.75).frame(result.object ?? 'all') + viewer.camera.lerp(0.75).frame(result?.object ?? 'all') }, zoom: async (value: number, pos?: THREE.Vector2) => { if (pos) { From 3a6c3c270f796b57e6316a69c01c349a3e6dd5a8 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 5 Feb 2026 16:38:34 -0500 Subject: [PATCH 34/94] gpu pickable markers --- .../webgl/loader/vimCollection.ts | 5 +- .../viewer/gizmos/markers/gizmoMarkers.ts | 41 +++++++++++-- .../webgl/viewer/rendering/gpuPicker.ts | 58 +++++++++++++++---- .../core-viewers/webgl/viewer/viewer.ts | 4 +- 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/vimCollection.ts b/src/vim-web/core-viewers/webgl/loader/vimCollection.ts index e917c5fe2..9bd5c4359 100644 --- a/src/vim-web/core-viewers/webgl/loader/vimCollection.ts +++ b/src/vim-web/core-viewers/webgl/loader/vimCollection.ts @@ -8,9 +8,10 @@ import { Vim } from './vim' /** * Maximum number of vims that can be loaded simultaneously. - * Limited by the 8-bit vimIndex in GPU picking (256 values: 0-255). + * Limited by the 8-bit vimIndex in GPU picking. + * Index 255 is reserved for marker gizmos, so vims use 0-254. */ -export const MAX_VIMS = 256 +export const MAX_VIMS = 255 /** * Manages a collection of Vim objects with stable IDs for GPU picking. diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/gizmoMarkers.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/gizmoMarkers.ts index 802056359..e962871a6 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/gizmoMarkers.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/gizmoMarkers.ts @@ -3,6 +3,7 @@ import * as THREE from 'three' import { Marker } from './gizmoMarker' import { StandardMaterial } from '../../../loader/materials/standardMaterial' import { SimpleInstanceSubmesh } from '../../../loader/mesh' +import { packPickingId, MARKER_VIM_INDEX } from '../../rendering/gpuPicker' /** * API for adding and managing sprite markers in the scene. @@ -38,7 +39,7 @@ export class GizmoMarkers { * @param capacity - Number of instances the mesh should support. * @returns A new THREE.InstancedMesh. */ - private createMesh (previous : THREE.InstancedMesh, capacity : number): THREE.InstancedMesh { + private createMesh (previous : THREE.InstancedMesh | undefined, capacity : number): THREE.InstancedMesh { const geometry = previous?.geometry ?? new THREE.SphereGeometry(1, 8, 8) const mat = previous?.material ?? new StandardMaterial(new THREE.MeshPhongMaterial({ @@ -57,6 +58,17 @@ export class GizmoMarkers { mesh.frustumCulled = false mesh.layers.enableAll() + // Add picking attributes for GPU picker + // packedId: marker index packed with MARKER_VIM_INDEX (255) + const packedIdArray = new Uint32Array(capacity) + const packedIdAttr = new THREE.InstancedBufferAttribute(packedIdArray, 1) + mesh.geometry.setAttribute('packedId', packedIdAttr) + + // ignore: visibility flag (0 = visible, 1 = hidden) + const ignoreArray = new Float32Array(capacity) + const ignoreAttr = new THREE.InstancedBufferAttribute(ignoreArray, 1) + mesh.geometry.setAttribute('ignore', ignoreAttr) + this._viewer.renderer.add(mesh) return mesh } @@ -68,12 +80,21 @@ export class GizmoMarkers { const larger = this.createMesh(this._mesh, this._mesh.count * 2) larger.count = this._mesh.count + const oldPackedId = this._mesh.geometry.getAttribute('packedId') as THREE.InstancedBufferAttribute + const oldIgnore = this._mesh.geometry.getAttribute('ignore') as THREE.InstancedBufferAttribute + const newPackedId = larger.geometry.getAttribute('packedId') as THREE.InstancedBufferAttribute + const newIgnore = larger.geometry.getAttribute('ignore') as THREE.InstancedBufferAttribute + for (let i = 0; i < this._mesh.count; i++) { this._mesh.getMatrixAt(i, this._reusableMatrix) larger.setMatrixAt(i, this._reusableMatrix) + newPackedId.setX(i, oldPackedId.getX(i)) + newIgnore.setX(i, oldIgnore.getX(i)) const sub = new SimpleInstanceSubmesh(larger, i) this._markers[i].updateMesh(sub) } + newPackedId.needsUpdate = true + newIgnore.needsUpdate = true this._viewer.renderer.remove(this._mesh) this._mesh = larger @@ -90,8 +111,15 @@ export class GizmoMarkers { this.resizeMesh() } + const markerIndex = this._mesh.count this._mesh.count += 1 - const sub = new SimpleInstanceSubmesh(this._mesh, this._mesh.count - 1) + + // Set picking ID for GPU picker + const packedIdAttr = this._mesh.geometry.getAttribute('packedId') as THREE.InstancedBufferAttribute + packedIdAttr.setX(markerIndex, packPickingId(MARKER_VIM_INDEX, markerIndex)) + packedIdAttr.needsUpdate = true + + const sub = new SimpleInstanceSubmesh(this._mesh, markerIndex) const marker = new Marker(this._viewer, sub) marker.position = position this._markers.push(marker) @@ -110,15 +138,20 @@ export class GizmoMarkers { const destIndex = marker.index // Swap with last marker - if(fromIndex !== destIndex) { + if (fromIndex !== destIndex) { const lastMarker = this._markers[fromIndex] this._markers[destIndex] = lastMarker this._mesh.getMatrixAt(fromIndex, this._reusableMatrix) this._mesh.setMatrixAt(destIndex, this._reusableMatrix) this._mesh.instanceMatrix.needsUpdate = true + // Update picking ID for moved marker (now at destIndex) + const packedIdAttr = this._mesh.geometry.getAttribute('packedId') as THREE.InstancedBufferAttribute + packedIdAttr.setX(destIndex, packPickingId(MARKER_VIM_INDEX, destIndex)) + packedIdAttr.needsUpdate = true + // This updates marker.index too - const sub = new SimpleInstanceSubmesh(this._mesh, marker.index) + const sub = new SimpleInstanceSubmesh(this._mesh, destIndex) lastMarker.updateMesh(sub) } diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index d96146f23..67a09d618 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -12,9 +12,14 @@ import { Vim } from '../../loader/vim' import { VimCollection } from '../../loader/vimCollection' import type { IRaycaster, IRaycastResult } from '../../../shared' import { Marker } from '../gizmos/markers/gizmoMarker' +import type { GizmoMarkers } from '../gizmos/markers/gizmoMarkers' +import type { Selectable } from '../selection' -/** Raycastable objects for the GpuPicker */ -export type GpuRaycastableObject = Element3D | Marker +/** + * Reserved vimIndex for marker gizmos in GPU picking. + * Markers use this index to distinguish them from vim elements. + */ +export const MARKER_VIM_INDEX = 255 /** * Packs vimIndex (8 bits) and elementIndex (24 bits) into a single uint32. @@ -38,10 +43,10 @@ export function unpackPickingId(packedId: number): { vimIndex: number; elementIn * Result of a GPU pick operation containing element index, world position, and surface normal. * Implements IRaycastResult for compatibility with the raycaster interface. */ -export class GpuPickResult implements IRaycastResult { - /** The element index in the vim */ +export class GpuPickResult implements IRaycastResult { + /** The element index in the vim (or marker index if vimIndex === MARKER_VIM_INDEX) */ readonly elementIndex: number - /** The vim index identifying which vim the element belongs to */ + /** The vim index identifying which vim the element belongs to (255 = marker) */ readonly vimIndex: number /** The world position of the hit */ readonly worldPosition: THREE.Vector3 @@ -49,36 +54,48 @@ export class GpuPickResult implements IRaycastResult { readonly worldNormal: THREE.Vector3 /** Reference to the vim containing the element */ private _vim: Vim | undefined + /** Reference to the marker if this is a marker hit */ + private _marker: Marker | undefined constructor( elementIndex: number, vimIndex: number, worldPosition: THREE.Vector3, worldNormal: THREE.Vector3, - vim: Vim | undefined + vim: Vim | undefined, + marker?: Marker ) { this.elementIndex = elementIndex this.vimIndex = vimIndex this.worldPosition = worldPosition this.worldNormal = worldNormal this._vim = vim + this._marker = marker } /** * The object property for IRaycastResult interface. - * Returns the Element3D for the picked element. + * Returns the Element3D or Marker for the picked object. */ - get object(): Element3D | undefined { - return this.getElement() + get object(): Selectable | undefined { + return this._marker ?? this.getElement() } /** * Gets the Element3D object for the picked element. - * @returns The Element3D object, or undefined if not found + * @returns The Element3D object, or undefined if not found or if this is a marker hit */ getElement(): Element3D | undefined { return this._vim?.getElementFromIndex(this.elementIndex) } + + /** + * Gets the Marker object if this is a marker hit. + * @returns The Marker object, or undefined if this is an element hit + */ + getMarker(): Marker | undefined { + return this._marker + } } /** @@ -93,11 +110,12 @@ export class GpuPickResult implements IRaycastResult { * * Normal.z is reconstructed as: sqrt(1 - x² - y²), always positive since normal faces camera. */ -export class GpuPicker implements IRaycaster { +export class GpuPicker implements IRaycaster { private _renderer: THREE.WebGLRenderer private _camera: Camera private _scene: RenderScene private _vims: VimCollection + private _markers: GizmoMarkers | undefined private _section: RenderingSection private _renderTarget: THREE.WebGLRenderTarget @@ -139,6 +157,14 @@ export class GpuPicker implements IRaycaster { this._readBuffer = new Float32Array(4) } + /** + * Sets the GizmoMarkers reference for marker picking. + * Must be called after gizmos are initialized. + */ + setMarkers(markers: GizmoMarkers): void { + this._markers = markers + } + /** * Updates the render target size to match viewport. */ @@ -229,6 +255,16 @@ export class GpuPicker implements IRaycaster { // Reconstruct world position from depth const worldPosition = this.reconstructWorldPosition(screenPos, depth, camera) + // Check if this is a marker hit + if (vimIndex === MARKER_VIM_INDEX) { + const marker = this._markers?.getMarkerFromIndex(elementIndex) + const result = new GpuPickResult(elementIndex, vimIndex, worldPosition, worldNormal, undefined, marker) + if (this.debug) { + this.showDebugVisuals(result) + } + return result + } + // Get the vim by its stable ID const vim = this._vims.getFromId(vimIndex) diff --git a/src/vim-web/core-viewers/webgl/viewer/viewer.ts b/src/vim-web/core-viewers/webgl/viewer/viewer.ts index f0b4c7cf2..fa0be35d4 100644 --- a/src/vim-web/core-viewers/webgl/viewer/viewer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/viewer.ts @@ -129,7 +129,7 @@ export class Viewer { // GPU-based raycaster for element picking and world position queries const size = this.renderer.renderer.getSize(new THREE.Vector2()) - this.raycaster = new GpuPicker( + const gpuPicker = new GpuPicker( this.renderer.renderer, this._camera, scene, @@ -138,6 +138,8 @@ export class Viewer { size.x || 1, size.y || 1 ) + gpuPicker.setMarkers(this.gizmos.markers) + this.raycaster = gpuPicker // Update raycaster size on viewport resize this.viewport.onResize.sub(() => { From 137bf0f7dbf24df3ab641e6394ba597847f1eeec Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Fri, 6 Feb 2026 14:05:28 -0500 Subject: [PATCH 35/94] reemomved more vimx --- .../webgl/loader/elementMapping.ts | 110 +------ .../core-viewers/webgl/loader/geometry.ts | 275 ------------------ .../core-viewers/webgl/loader/index.ts | 4 +- .../webgl/loader/progressive/g3dSubset.ts | 70 +---- .../loader/progressive/insertableGeometry.ts | 116 +------- .../loader/progressive/insertableMesh.ts | 13 +- .../webgl/loader/progressive/instancedMesh.ts | 29 +- .../progressive/instancedMeshFactory.ts | 108 +------ .../webgl/loader/progressive/loadRequest.ts | 5 +- .../loader/progressive/loadingSynchronizer.ts | 113 ------- .../webgl/loader/progressive/subsetBuilder.ts | 113 +------ .../webgl/loader/progressive/subsetRequest.ts | 171 ----------- ...legacyMeshFactory.ts => vimMeshFactory.ts} | 0 .../webgl/loader/progressive/vimx.ts | 48 --- src/vim-web/core-viewers/webgl/loader/vim.ts | 44 +-- .../core-viewers/webgl/loader/vimSettings.ts | 39 +-- 16 files changed, 42 insertions(+), 1216 deletions(-) delete mode 100644 src/vim-web/core-viewers/webgl/loader/progressive/loadingSynchronizer.ts delete mode 100644 src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts rename src/vim-web/core-viewers/webgl/loader/progressive/{legacyMeshFactory.ts => vimMeshFactory.ts} (100%) delete mode 100644 src/vim-web/core-viewers/webgl/loader/progressive/vimx.ts diff --git a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts index c1cb97740..035cb415c 100644 --- a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts +++ b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts @@ -2,7 +2,7 @@ * @module vim-loader */ -import { G3d, G3dScene, VimDocument } from 'vim-format' +import { G3d, VimDocument } from 'vim-format' export class ElementNoMapping { getElementsFromElementId (id: number) { @@ -160,111 +160,3 @@ export class ElementMapping { return result } } - -export class ElementMapping2 { - private _instanceToElement: Map - private _elementToInstances: Map - private _instanceToElementId: Map - - constructor (scene: G3dScene) { - this._instanceToElement = new Map() - this._instanceToElementId = new Map() - - for (let i = 0; i < scene.instanceNodes.length; i++) { - this._instanceToElement.set( - scene.instanceNodes[i], - scene.instanceGroups[i] - ) - this._instanceToElementId.set( - scene.instanceNodes[i], - scene.instanceTags[i] - ) - } - this._elementToInstances = ElementMapping2.invertMap( - this._instanceToElement - ) - } - - /** - * Retrieves element indices associated with the given element ID. - * @param {number | bigint} id The element ID. - * @returns {number[] | undefined} An array of element indices associated with the element ID, - * or undefined if no elements are associated with the ID. - */ - getElementsFromElementId (id: number | bigint) { - return undefined - } - - /** - * Checks if the element exists in the vim. - * @param {number} element The element to check for existence. - * @returns {boolean} True if the element exists in the vim, otherwise false. - */ - hasElement (element: number) { - return this._elementToInstances.has(element) - } - - /** - * Checks if the element has a mesh in the vim. - * @param {number} element The element to check for mesh existence. - * @returns {boolean} True if the element has a mesh in the vim, otherwise false. - */ - hasMesh (element: number) { - // All elements have meshes in vimx - return this.hasElement(element) - } - - /** - * Retrieves all element indices of the vim. - * @returns {IterableIterator} An iterator of all element indices in the vim. - */ - getElements () { - return this._elementToInstances.keys() - } - - /** - * Retrieves instance indices associated with the specified vim element index. - * @param {number} element The vim element index. - * @returns {number[] | undefined} An array of instance indices associated with the vim element index, - * or undefined if the element does not exist in the vim. - */ - getInstancesFromElement (element: number): number[] | undefined { - if (!this.hasElement(element)) return - return this._elementToInstances.get(element) ?? [] - } - - /** - * Retrieves the element index associated with the g3d instance index. - * @param {number} instance The g3d instance index. - * @returns {number | undefined} The element index associated with the instance, or undefined if not found. - */ - getElementFromInstance (instance: number) { - return this._instanceToElement.get(instance) - } - - /** - * Retrieves the element ID associated with the specified element index. - * @param {number} element The element index. - * @returns {bigint | undefined} The element ID associated with the element index, or undefined if not found. - */ - getElementId (element: number) { - const instance = this.getInstancesFromElement(element)?.[0] - return this._instanceToElementId.get(instance) - } - - /** - * Returns a map where data[i] -> i - */ - private static invertMap (data: Map) { - const result = new Map() - for (const [key, value] of data.entries()) { - const list = result.get(value) - if (list) { - list.push(key) - } else { - result.set(value, [key]) - } - } - return result - } -} diff --git a/src/vim-web/core-viewers/webgl/loader/geometry.ts b/src/vim-web/core-viewers/webgl/loader/geometry.ts index 1e24e745a..5da1a308e 100644 --- a/src/vim-web/core-viewers/webgl/loader/geometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/geometry.ts @@ -5,14 +5,6 @@ import * as THREE from 'three' import { G3d, MeshSection } from 'vim-format' -export type MergeArgs = { - matrix: THREE.Matrix4 - section: MeshSection - legacyInstances: number[] - legacyLoadRooms: boolean - transparent: boolean -} - export namespace Transparency { /** * Determines how to draw (or not) transparent and opaque objects @@ -37,15 +29,6 @@ export namespace Transparency { } } -/** - * Creates a BufferGeometry with all given instances merged - * @param instances indices of the instances from the g3d to merge - * @returns a BufferGeometry - */ -export function createGeometryFromInstances (g3d: G3d, args: MergeArgs) { - return mergeInstanceMeshes(g3d, args)?.geometry -} - /** * Creates a BufferGeometry from a given mesh index in the g3d * @param mesh g3d mesh index @@ -150,261 +133,3 @@ export function createGeometryFromArrays ( return geometry } - -/** - * Returns a merged mesh of all meshes related to given instances along with picking related metadata - * Returns undefined if mesh would be empty - * @param section mesh sections to include in the merged mesh. - * @param transparent true to use a transparent material. - * @param instances instances for which to merge meshes. - */ -export function mergeInstanceMeshes (g3d: G3d, mergeArgs: MergeArgs) { - const info = getInstanceMergeInfo(g3d, mergeArgs) - if (info.instances.length === 0 || info.indexCount === 0) return - return merge(g3d, info) -} - -/** - * Returns a merged mesh of all unique meshes along with picking related metadata - * @param section mesh sections to include in the merged mesh. - * @param transparent true to use a transparent material. - * @param instances instances for which to merge meshes. - */ -export function mergeUniqueMeshes (g3d: G3d, args: MergeArgs) { - const info = getUniqueMeshMergeInfo(g3d, args) - if (info.instances.length === 0 || info.indexCount === 0) return - return merge(g3d, info) -} - -/** - * Returns merged geometry and meta data for picking. - */ -function merge (g3d: G3d, info: MergeInfo) { - const buffer = info.createBuffer() - fillBuffers(g3d, buffer, info) - const geometry = buffer.toBufferGeometry() - return new MergeResult( - geometry, - info.instances, - buffer.groups, - buffer.boxes - ) -} - -/** - * Precomputes array sizes required to merge all unique meshes - */ -function getUniqueMeshMergeInfo (g3d: G3d, args: MergeArgs) { - let vertexCount = 0 - let indexCount = 0 - const instances = [] - - const meshCount = g3d.getMeshCount() - for (let mesh = 0; mesh < meshCount; mesh++) { - const meshInstances = g3d.meshInstances[mesh] - if (!meshInstances || meshInstances.length !== 1) continue - - const instance = meshInstances[0] - if (!args.legacyLoadRooms && g3d.getInstanceHasFlag(instance, 1)) continue - - const count = g3d.getMeshIndexCount(mesh, args.section) - if (count <= 0) continue - - indexCount += count - vertexCount += g3d.getMeshVertexCount(mesh) - instances.push(instance) - } - - return new MergeInfo( - args.section, - args.transparent, - instances, - indexCount, - vertexCount - ) -} - -/** - * Precomputes array sizes required to merge all meshes of given instances. - */ -function getInstanceMergeInfo (g3d: G3d, args: MergeArgs) { - let vertexCount = 0 - let indexCount = 0 - const instancesFiltered = [] - - for (let i = 0; i < args.legacyInstances.length; i++) { - const instance = args.legacyInstances[i] - if (!args.legacyLoadRooms && g3d.getInstanceHasFlag(instance, 1)) { - continue - } - const mesh = g3d.instanceMeshes[instance] - - const start = g3d.getMeshIndexStart(mesh, args.section) - const end = g3d.getMeshIndexEnd(mesh, args.section) - const count = end - start - if (count <= 0) continue - indexCount += count - vertexCount += g3d.getMeshVertexCount(mesh) - instancesFiltered.push(instance) - } - - return new MergeInfo( - args.section, - args.transparent, - instancesFiltered, - indexCount, - vertexCount - ) -} - -/** - * Concatenates all required mesh data into the merge buffer. - */ -function fillBuffers (g3d: G3d, buffer: MergeBuffer, info: MergeInfo) { - let index = 0 - let vertex = 0 - let offset = 0 - - // matrix and vector is reused to avoid needless allocations - const matrix = new THREE.Matrix4() - const vector = new THREE.Vector3() - - for (let i = 0; i < info.instances.length; i++) { - const instance = info.instances[i] - const mesh = g3d.getInstanceMesh(instance) - buffer.groups[i] = index - - const subStart = g3d.getMeshSubmeshStart(mesh, info.section) - const subEnd = g3d.getMeshSubmeshEnd(mesh, info.section) - for (let sub = subStart; sub < subEnd; sub++) { - const subColor = g3d.getSubmeshColor(sub) - const start = g3d.getSubmeshIndexStart(sub) - const end = g3d.getSubmeshIndexEnd(sub) - - for (let s = start; s < end; s++) { - // Copy index - const newIndex = g3d.indices[s] + offset - buffer.indices[index++] = newIndex - - // Copy color - const v = newIndex * buffer.colorSize - buffer.colors[v] = subColor[0] - buffer.colors[v + 1] = subColor[1] - buffer.colors[v + 2] = subColor[2] - if (buffer.colorSize > 3) { - buffer.colors[v + 3] = subColor[3] - } - } - } - - // Apply Matrices and copy vertices to merged array - getInstanceMatrix(g3d, instance, matrix) - const vertexStart = g3d.getMeshVertexStart(mesh) - const vertexEnd = g3d.getMeshVertexEnd(mesh) - - if (vertexEnd > vertexStart) { - // First point is computed before to initialize box - vector.fromArray(g3d.positions, vertexStart * G3d.POSITION_SIZE) - vector.applyMatrix4(matrix) - vector.toArray(buffer.vertices, vertex) - vertex += G3d.POSITION_SIZE - buffer.boxes[i] = new THREE.Box3(vector.clone(), vector.clone()) - } - - for (let p = vertexStart + 1; p < vertexEnd; p++) { - vector.fromArray(g3d.positions, p * G3d.POSITION_SIZE) - vector.applyMatrix4(matrix) - vector.toArray(buffer.vertices, vertex) - vertex += G3d.POSITION_SIZE - buffer.boxes[i].expandByPoint(vector) - } - - // Keep offset for next mesh - offset += vertexEnd - vertexStart - } -} - -/** - * Holds the info that needs to be precomputed for a merge. - */ -export class MergeInfo { - section: MeshSection - transparent: boolean - instances: number[] - indexCount: number - vertexCount: number - - constructor ( - section: MeshSection, - transparent: boolean, - instance: number[], - indexCount: number, - vertexCount: number - ) { - this.section = section - this.transparent = transparent - this.instances = instance - this.indexCount = indexCount - this.vertexCount = vertexCount - } - - createBuffer () { - return new MergeBuffer(this, G3d.POSITION_SIZE, this.transparent ? 4 : 3) - } -} - -/** - * Allocates and holds all arrays needed to merge meshes. - */ -export class MergeBuffer { - // output - indices: Uint32Array - vertices: Float32Array - colors: Float32Array - groups: number[] - colorSize: number - boxes: THREE.Box3[] - - constructor (info: MergeInfo, positionSize: number, colorSize: number) { - // allocate all memory required for merge - this.indices = new Uint32Array(info.indexCount) - this.vertices = new Float32Array(info.vertexCount * positionSize) - this.colors = new Float32Array(info.vertexCount * colorSize) - this.groups = new Array(info.instances.length) - this.boxes = new Array(info.instances.length) - this.colorSize = colorSize - } - - toBufferGeometry () { - const geometry = createGeometryFromArrays( - this.vertices, - this.indices, - this.colors, - this.colorSize - ) - - return geometry - } -} - -/** - * Holds the result from a merge operation. - */ -export class MergeResult { - geometry: THREE.BufferGeometry - instances: number[] - submeshes: number[] - boxes: THREE.Box3[] - - constructor ( - geometry: THREE.BufferGeometry, - instance: number[], - submeshes: number[], - boxes: THREE.Box3[] - ) { - this.geometry = geometry - this.instances = instance - this.submeshes = submeshes - this.boxes = boxes - } -} diff --git a/src/vim-web/core-viewers/webgl/loader/index.ts b/src/vim-web/core-viewers/webgl/loader/index.ts index d39ed6fdf..9983feb50 100644 --- a/src/vim-web/core-viewers/webgl/loader/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/index.ts @@ -22,10 +22,8 @@ export type * from './progressive/insertableSubmesh'; export type * from './progressive/instancedMesh'; export type * from './progressive/instancedMeshFactory'; export type * from './progressive/instancedSubmesh'; -export type * from './progressive/legacyMeshFactory'; -export type * from './progressive/loadingSynchronizer'; +export type * from './progressive/vimMeshFactory'; export type * from './progressive/subsetBuilder'; -export type * from './progressive/subsetRequest'; // Not exported // export * from './progressive/open'; diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts index d34efc663..9eaf2a350 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts @@ -2,16 +2,15 @@ * @module vim-loader */ -import { G3d, MeshSection, G3dScene, FilterMode } from 'vim-format' +import { G3d, MeshSection, FilterMode } from 'vim-format' import { G3dMeshOffsets, G3dMeshCounts } from './g3dOffsets' -import * as THREE from 'three' /** * Represents a subset of a complete scene definition. * Allows for further filtering or to get offsets needed to build the scene. */ export class G3dSubset { - private _source: G3dScene | G3d + private _source: G3d // source-based indices of included instanced private _instances: number[] @@ -25,7 +24,7 @@ export class G3dSubset { * @param instances source-based instance indices of included instances. */ constructor ( - source: G3dScene | G3d, + source: G3d, // source-based indices of included instanced instances?: number[] ) { @@ -152,8 +151,7 @@ export class G3dSubset { getMeshVertexCount (mesh: number, section: MeshSection) { const instances = this.getMeshInstanceCount(mesh) const vertices = this._source.getMeshVertexCount( - this.getSourceMesh(mesh), - section + this.getSourceMesh(mesh) ) return vertices * instances } @@ -190,34 +188,6 @@ export class G3dSubset { return this.filterByCount((count) => count === 1) } - /** - * Returns a new subset that contains only the N largest meshes sorted by largest - */ - filterLargests (count: number) { - if (this._source instanceof G3d) { - throw new Error('Feature requires a vimx file') - } - - // reuse vector3 to avoid wateful allocations - const min = new THREE.Vector3() - const max = new THREE.Vector3() - - // Compute all sizes - const values = new Array<[number, number]>(this._instances.length) - for (let i = 0; i < this._instances.length; i++) { - const instance = this._instances[i] - min.fromArray(this._source.getInstanceMin(instance)) - max.fromArray(this._source.getInstanceMax(instance)) - const size = min.distanceToSquared(max) - values.push([i, size]) - } - - // Take top 100 instances - values.sort((v1, v2) => v2[1] - v1[1]) - const instances = values.slice(0, count).map((v) => v[0]) - return new G3dSubset(this._source, instances) - } - /** * Returns a new subset that only contains non-unique meshes. */ @@ -336,36 +306,4 @@ export class G3dSubset { return result } - /** - * Return the bounding box of the current subset or undefined if subset is empty. - */ - getBoundingBox () { - if (this._instances.length === 0) return - if (this._source instanceof G3dScene) { - // To avoid including (0,0,0) - const box = new THREE.Box3() - const first = this._instances[0] - box.min.fromArray(this._source.getInstanceMin(first)) - box.max.fromArray(this._source.getInstanceMax(first)) - - for (let i = 1; i < this._instances.length; i++) { - const instance = this._instances[i] - minBox(box, this._source.getInstanceMin(instance)) - maxBox(box, this._source.getInstanceMax(instance)) - } - return box - } - } -} - -function minBox (box: THREE.Box3, other: Float32Array) { - box.min.x = Math.min(box.min.x, other[0]) - box.min.y = Math.min(box.min.y, other[1]) - box.min.z = Math.min(box.min.z, other[2]) -} - -function maxBox (box: THREE.Box3, other: Float32Array) { - box.max.x = Math.max(box.max.x, other[0]) - box.max.y = Math.max(box.max.y, other[1]) - box.max.z = Math.max(box.max.z, other[2]) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index e86101b3b..1cf7efc5b 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -3,7 +3,7 @@ */ import * as THREE from 'three' -import { G3d, G3dMesh, G3dMaterial } from 'vim-format' +import { G3d, G3dMaterial } from 'vim-format' import { Scene } from '../scene' import { G3dMeshOffsets } from './g3dOffsets' import { ElementMapping } from '../elementMapping' @@ -83,125 +83,13 @@ export class InsertableGeometry { this.geometry.setAttribute('color', this._colorAttribute) this.geometry.setAttribute('packedId', this._packedIdAttribute) - this.boundingBox = offsets.subset.getBoundingBox() - if (this.boundingBox) { - this.geometry.boundingBox = this.boundingBox - this.geometry.boundingSphere = new THREE.Sphere() - this.boundingBox.getBoundingSphere(this.geometry.boundingSphere) - } else { - this._computeBoundingBox = true - } + this._computeBoundingBox = true } get progress () { return this._indexAttribute.count / this._indexAttribute.array.length } - // TODO: remove the need for mesh argument. - insert (mesh: G3dMesh, at: number) { - const added: number[] = [] - const section = this.offsets.section - const indexStart = mesh.getIndexStart(section) - const indexEnd = mesh.getIndexEnd(section) - - // Skip empty mesh - if (indexStart === indexEnd) { - this._meshToUpdate.add(at) - return added - } - - // Reusable matrix and vector3 to avoid allocations - const matrix = new THREE.Matrix4() - const vector = new THREE.Vector3() - - const vertexStart = mesh.getVertexStart(section) - const vertexEnd = mesh.getVertexEnd(section) - const vertexCount = vertexEnd - vertexStart - const sectionOffset = mesh.getVertexStart(section) - - const subStart = mesh.getSubmeshStart(section) - const subEnd = mesh.getSubmeshEnd(section) - - const indexOffset = this.offsets.getIndexOffset(at) - const vertexOffset = this.offsets.getVertexOffset(at) - - let indexOut = 0 - let vertexOut = 0 - let colorOut = 0 - - const instanceCount = this.offsets.getMeshInstanceCount(at) - for (let instanceIndex = 0; instanceIndex < instanceCount; instanceIndex++) { - const instance = this.offsets.getMeshInstance(at, instanceIndex) - const arr1 = mesh.scene.getInstanceMatrix(instance) - // console.assert(this.float32ArraysAreEqual(arr1, arr2)) - - // matrix.fromArray(this.offsets.subset.getTransform(instance)) - matrix.fromArray(arr1) - const submesh = new GeometrySubmesh() - submesh.instance = mesh.scene.instanceNodes[instance] - - // Append indices - submesh.start = indexOffset + indexOut - const vertexMergeOffset = vertexCount * instanceIndex - for (let index = indexStart; index < indexEnd; index++) { - this.setIndex( - indexOffset + indexOut, - vertexOffset + - vertexMergeOffset + - mesh.chunk.indices[index] - - sectionOffset - ) - indexOut++ - } - submesh.end = indexOffset + indexOut - - // Append vertices - for (let vertex = vertexStart; vertex < vertexEnd; vertex++) { - vector.fromArray(mesh.chunk.positions, vertex * G3d.POSITION_SIZE) - vector.applyMatrix4(matrix) - this.setVertex(vertexOffset + vertexOut, vector) - // submesh.expandBox(vector) - vertexOut++ - } - - // Append Colors - for (let sub = subStart; sub < subEnd; sub++) { - const color = this.materials.getMaterialColor( - mesh.chunk.submeshMaterial[sub] - ) - const vertexCount = mesh.getSubmeshVertexCount(sub) - for (let v = 0; v < vertexCount; v++) { - this.setColor(vertexOffset + colorOut, color, 0.25) - colorOut++ - } - } - - submesh.boundingBox.min.fromArray(mesh.scene.getInstanceMin(instance)) - submesh.boundingBox.max.fromArray(mesh.scene.getInstanceMax(instance)) - this.submeshes.push(submesh) - added.push(this.submeshes.length - 1) - } - this._meshToUpdate.add(at) - return added - } - - float32ArraysAreEqual (array1: Float32Array, array2: Float32Array): boolean { - // Check if arrays have the same length - if (array1.length !== array2.length) { - return false - } - - // Check if each element is equal - for (let i = 0; i < array1.length; i++) { - if (array1[i] !== array2[i]) { - return false - } - } - - // Arrays are equal - return true - } - insertFromG3d (g3d: G3d, mesh: number) { const added: number[] = [] const meshG3dIndex = this.offsets.getSourceMesh(mesh) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts index 45c4142b9..f3024cd8c 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts @@ -3,7 +3,7 @@ */ import * as THREE from 'three' -import { G3d, G3dMesh, G3dMaterial } from 'vim-format' +import { G3d, G3dMaterial } from 'vim-format' import { InsertableGeometry } from './insertableGeometry' import { InsertableSubmesh } from './insertableSubmesh' import { G3dMeshOffsets } from './g3dOffsets' @@ -72,17 +72,6 @@ export class InsertableMesh { return this.geometry.progress } - insert (g3d: G3dMesh, mesh: number) { - const added = this.geometry.insert(g3d, mesh) - if (!this.vim) { - return - } - - for (const i of added) { - this.vim.scene.addSubmesh(new InsertableSubmesh(this, i)) - } - } - insertFromVim (g3d: G3d, mesh: number) { this.geometry.insertFromG3d(g3d, mesh) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts index 504cbc49c..e049a1dcb 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts @@ -5,11 +5,11 @@ import * as THREE from 'three' import { Vim } from '../vim' import { InstancedSubmesh } from './instancedSubmesh' -import { G3d, G3dMesh } from 'vim-format' +import { G3d } from 'vim-format' import { ModelMaterial } from '../materials/materials' export class InstancedMesh { - g3dMesh: G3dMesh | G3d + g3dMesh: G3d vim: Vim mesh: THREE.InstancedMesh @@ -26,23 +26,17 @@ export class InstancedMesh { readonly size: number = 0 constructor ( - g3d: G3dMesh | G3d, + g3d: G3d, mesh: THREE.InstancedMesh, instances: Array ) { this.g3dMesh = g3d this.mesh = mesh this.mesh.userData.vim = this - this.bimInstances = - g3d instanceof G3dMesh - ? instances.map((i) => g3d.scene.instanceNodes[i]) - : instances + this.bimInstances = instances this.meshInstances = instances - this.boxes = - g3d instanceof G3dMesh - ? this.importBoundingBoxes() - : this.computeBoundingBoxes() + this.boxes = this.computeBoundingBoxes() this.size = this.boxes[0]?.getSize(new THREE.Vector3()).length() ?? 0 this.boundingBox = this.computeBoundingBox(this.boxes) this._material = this.mesh.material @@ -129,19 +123,6 @@ export class InstancedMesh { return boxes } - private importBoundingBoxes () { - if (this.g3dMesh instanceof G3d) throw new Error('Wrong type') - const boxes = new Array(this.meshInstances.length) - for (let i = 0; i < this.meshInstances.length; i++) { - const box = new THREE.Box3() - const instance = this.meshInstances[i] - box.min.fromArray(this.g3dMesh.scene.getInstanceMin(instance)) - box.max.fromArray(this.g3dMesh.scene.getInstanceMax(instance)) - boxes[i] = box - } - return boxes - } - computeBoundingBox (boxes: THREE.Box3[]) { const box = boxes[0].clone() for (let i = 1; i < boxes.length; i++) { diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index bef6d6ef8..b761d58af 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -3,7 +3,7 @@ */ import * as THREE from 'three' -import { G3d, G3dMesh, G3dMaterial, MeshSection, G3dScene } from 'vim-format' +import { G3d, G3dMaterial, MeshSection } from 'vim-format' import { InstancedMesh } from './instancedMesh' import { Materials } from '../materials/materials' import * as Geometry from '../geometry' @@ -21,14 +21,6 @@ export class InstancedMeshFactory { this._vimIndex = vimIndex } - createTransparent (mesh: G3dMesh, instances: number[]) { - return this.createFromVimx(mesh, instances, 'transparent', true) - } - - createOpaque (mesh: G3dMesh, instances: number[]) { - return this.createFromVimx(mesh, instances, 'opaque', false) - } - createOpaqueFromVim (g3d: G3d, mesh: number, instances: number[]) { return this.createFromVim(g3d, mesh, instances, 'opaque', false) } @@ -37,37 +29,6 @@ export class InstancedMeshFactory { return this.createFromVim(g3d, mesh, instances, 'transparent', true) } - createFromVimx ( - mesh: G3dMesh, - instances: number[], - section: MeshSection, - transparent: boolean - ) { - if (mesh.getIndexCount(section) <= 1) { - return undefined - } - - const geometry = this.createGeometry( - this.computeIndices(mesh, section), - this.computeVertices(mesh, section), - this.computeColors(mesh, section, transparent ? 4 : 3) - ) - - const material = transparent - ? Materials.getInstance().transparent - : Materials.getInstance().opaque - - const threeMesh = new THREE.InstancedMesh( - geometry, - material.material, - instances.length - ) - - this.setMatricesFromVimx(threeMesh, mesh.scene, instances) - const result = new InstancedMesh(mesh, threeMesh, instances) - return result - } - createFromVim ( g3d: G3d, mesh: number, @@ -91,76 +52,15 @@ export class InstancedMeshFactory { instances?.length ?? g3d.getMeshInstanceCount(mesh) ) - this.setMatricesFromVimx(threeMesh, g3d, instances) + this.setMatrices(threeMesh, g3d, instances) this.setPackedIds(threeMesh, instances ?? g3d.meshInstances[mesh]) const result = new InstancedMesh(g3d, threeMesh, instances) return result } - private createGeometry ( - indices: THREE.Uint32BufferAttribute, - positions: THREE.Float32BufferAttribute, - colors: THREE.Float32BufferAttribute - ) { - const geometry = new THREE.BufferGeometry() - geometry.setIndex(indices) - geometry.setAttribute('position', positions) - geometry.setAttribute('color', colors) - return geometry - } - - private computeIndices (mesh: G3dMesh, section: MeshSection) { - const indexStart = mesh.getIndexStart(section) - const indexCount = mesh.getIndexCount(section) - const vertexOffset = mesh.getVertexStart(section) - const indices = new Uint32Array(indexCount) - for (let i = 0; i < indexCount; i++) { - indices[i] = mesh.chunk.indices[indexStart + i] - vertexOffset - } - return new THREE.Uint32BufferAttribute(indices, 1) - } - - private computeVertices (mesh: G3dMesh, section: MeshSection) { - const vertexStart = mesh.getVertexStart(section) - const vertexEnd = mesh.getVertexEnd(section) - const vertices = mesh.chunk.positions.subarray( - vertexStart * G3d.POSITION_SIZE, - vertexEnd * G3d.POSITION_SIZE - ) - return new THREE.Float32BufferAttribute(vertices, G3d.POSITION_SIZE) - } - - private computeColors ( - mesh: G3dMesh, - section: MeshSection, - colorSize: number - ) { - const colors = new Float32Array(mesh.getVertexCount(section) * colorSize) - - let c = 0 - const submeshStart = mesh.getSubmeshStart(section) - const submeshEnd = mesh.getSubmeshEnd(section) - for (let sub = submeshStart; sub < submeshEnd; sub++) { - const mat = mesh.chunk.submeshMaterial[sub] - const color = this.materials.getMaterialColor(mat) - const subVertexCount = mesh.getSubmeshVertexCount(sub) - - for (let i = 0; i < subVertexCount; i++) { - colors[c] = color[0] - colors[c + 1] = color[1] - colors[c + 2] = color[2] - if (colorSize > 3) { - colors[c + 3] = color[3] - } - c += colorSize - } - } - return new THREE.Float32BufferAttribute(colors, colorSize) - } - - private setMatricesFromVimx ( + private setMatrices ( three: THREE.InstancedMesh, - source: G3dScene | G3d, + source: G3d, instances: number[] ) { const matrix = new THREE.Matrix4() diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts index 6e377323a..6a2400b0b 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -3,7 +3,7 @@ import { Vim } from '../vim' import { Scene } from '../scene' import { ElementMapping } from '../elementMapping' import { VimSubsetBuilder } from './subsetBuilder' -import { VimMeshFactory } from './legacyMeshFactory' +import { VimMeshFactory } from './vimMeshFactory' import { LoadRequest as BaseLoadRequest, ILoadRequest as BaseILoadRequest, LoadError, LoadSuccess } from '../../../shared/loadResult' import { VimSource } from '../..' import { @@ -88,8 +88,7 @@ export class LoadRequest extends BaseLoadRequest { vimIndex, mapping, builder, - bfast.url, - 'vim' + bfast.url ) if (bfast.source instanceof RemoteBuffer) { diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/loadingSynchronizer.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadingSynchronizer.ts deleted file mode 100644 index ca69c027c..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/loadingSynchronizer.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * @module vim-loader - */ - -import { G3dMesh } from 'vim-format' -import { G3dSubset } from './g3dSubset' - -/** - * Makes sure both instanced meshes and merged meshes are requested in the right order - * Also decouples downloads and processing. - */ -export class LoadingSynchronizer { - done = false - uniques: G3dSubset - nonUniques: G3dSubset - getMesh: (mesh: number) => Promise - mergeAction: (mesh: G3dMesh, index: number) => void - instanceAction: (mesh: G3dMesh, index: number) => void - - mergeQueue: (() => void)[] = [] - instanceQueue: (() => void)[] = [] - - constructor ( - uniques: G3dSubset, - nonUniques: G3dSubset, - getMesh: (mesh: number) => Promise, - mergeAction: (mesh: G3dMesh, index: number) => void, - instanceAction: (mesh: G3dMesh, index: number) => void - ) { - this.uniques = uniques - this.nonUniques = nonUniques - this.getMesh = getMesh - this.mergeAction = mergeAction - this.instanceAction = instanceAction - } - - get isDone () { - return this.done - } - - abort () { - this.done = true - this.mergeQueue.length = 0 - this.instanceQueue.length = 0 - } - - // Loads batches until the all meshes are loaded - async loadAll () { - const promises = this.getSortedPromises() - Promise.all(promises).then(() => (this.done = true)) - await this.consumeQueues() - } - - private async consumeQueues () { - while ( - !( - this.done && - this.mergeQueue.length === 0 && - this.instanceQueue.length === 0 - ) - ) { - while (this.mergeQueue.length > 0) { - this.mergeQueue.pop()() - } - while (this.instanceQueue.length > 0) { - this.instanceQueue.pop()() - } - - // Resume on next frame - await new Promise((resolve) => setTimeout(resolve, 0)) - } - } - - private getSortedPromises () { - const promises: Promise[] = [] - - const uniqueCount = this.uniques.getMeshCount() - const nonUniquesCount = this.nonUniques.getMeshCount() - - let uniqueIndex = 0 - let nonUniqueIndex = 0 - let uniqueMesh = 0 - let nonUniqueMesh = 0 - - while (!this.isDone) { - const mergeDone = uniqueIndex >= uniqueCount - const instanceDone = nonUniqueIndex >= nonUniquesCount - if (mergeDone && instanceDone) { - break - } - - if (!mergeDone && (uniqueMesh <= nonUniqueMesh || instanceDone)) { - uniqueMesh = this.uniques.getSourceMesh(uniqueIndex) - promises.push(this.merge(uniqueMesh, uniqueIndex++)) - } - if (!instanceDone && (nonUniqueMesh <= uniqueMesh || mergeDone)) { - nonUniqueMesh = this.nonUniques.getSourceMesh(nonUniqueIndex) - promises.push(this.instance(nonUniqueMesh, nonUniqueIndex++)) - } - } - return promises - } - - async merge (mesh: number, index: number) { - const m = await this.getMesh(mesh) - this.mergeQueue.push(() => this.mergeAction(m, index)) - } - - async instance (mesh: number, index: number) { - const m = await this.getMesh(mesh) - this.instanceQueue.push(() => this.instanceAction(m, index)) - } -} diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts b/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts index a3989bc10..0f5781bd6 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts @@ -2,13 +2,9 @@ * @module vim-loader */ -import { VimMeshFactory } from './legacyMeshFactory' -import { LoadPartialSettings, LoadSettings, SubsetRequest } from './subsetRequest' +import { VimMeshFactory } from './vimMeshFactory' import { G3dSubset } from './g3dSubset' -import { ISignal, ISignalHandler, SignalDispatcher } from 'ste-signals' -import { ISubscribable, SubscriptionChangeEventHandler } from 'ste-core' -import { Vimx } from './vimx' -import { Scene } from '../scene' +import { ISignal, SignalDispatcher } from 'ste-signals' export interface SubsetBuilder { /** Dispatched whenever a subset begins or finishes loading. */ @@ -20,8 +16,8 @@ export interface SubsetBuilder { /** Returns all instances as a subset */ getFullSet(): G3dSubset - /** Loads given subset with given options */ - loadSubset(subset: G3dSubset, settings?: LoadPartialSettings) + /** Loads given subset */ + loadSubset(subset: G3dSubset) /** Stops and clears all loading processes */ clear() @@ -53,8 +49,7 @@ export class VimSubsetBuilder implements SubsetBuilder { return new G3dSubset(this.factory.g3d) } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - loadSubset (subset: G3dSubset, settings?: LoadPartialSettings) { + loadSubset (subset: G3dSubset) { this.factory.add(subset) this._onUpdate.dispatch() } @@ -65,101 +60,3 @@ export class VimSubsetBuilder implements SubsetBuilder { dispose () {} } - -/** - * Loads and builds subsets from a VimX file. - */ -export class VimxSubsetBuilder { - private _localVimx: Vimx - private _scene: Scene - private _vimIndex: number - private _set = new Set() - - private _onUpdate = new SignalDispatcher() - get onUpdate () { - return this._onUpdate.asEvent() - } - - get isLoading () { - return this._set.size > 0 - } - - constructor (localVimx: Vimx, scene: Scene, vimIndex: number = 0) { - this._localVimx = localVimx - this._scene = scene - this._vimIndex = vimIndex - } - - getFullSet () { - return new G3dSubset(this._localVimx.scene) - } - - async loadSubset (subset: G3dSubset, settings?: LoadPartialSettings) { - const request = new SubsetRequest(this._scene, this._localVimx, subset, this._vimIndex) - this._set.add(request) - this._onUpdate.dispatch() - await request.start(settings) - this._set.delete(request) - this._onUpdate.dispatch() - } - - clear () { - this._localVimx.abort() - this._set.forEach((s) => s.dispose()) - this._set.clear() - this._onUpdate.dispatch() - } - - dispose () { - this.clear() - } -} - -export class DummySubsetBuilder implements SubsetBuilder { - get onUpdate () { - return new AlwaysTrueSignal() - } - - get isLoading () { - return false - } - - getFullSet (): G3dSubset { - throw new Error('Method not implemented.') - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - loadSubset (subset: G3dSubset, settings?: Partial) {} - clear () { } - dispose () { } -} - -class AlwaysTrueSignal implements ISignal { - count: number - subscribe (fn: ISignalHandler): () => void { - fn(null) - return () => {} - } - - sub (fn: ISignalHandler): () => void { - fn(null) - return () => {} - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - unsubscribe (fn: ISignalHandler): void {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - unsub (fn: ISignalHandler): void {} - one (fn: ISignalHandler): () => void { - fn(null) - return () => {} - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - has (fn: ISignalHandler): boolean { - return false - } - - clear (): void {} - onSubscriptionChange: ISubscribable -} diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts deleted file mode 100644 index 5f9ed07d2..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * @module vim-loader - */ - -import { InsertableMesh } from './insertableMesh' -import { InstancedMeshFactory } from './instancedMeshFactory' -import { Scene } from '../scene' -import { Vimx } from './vimx' - -import { G3dMesh } from 'vim-format' -import { G3dSubset } from './g3dSubset' -import { InstancedMesh } from './instancedMesh' -import { LoadingSynchronizer } from './loadingSynchronizer' - -export type LoadSettings = { - /** Delay in ms between each rendering list update. @default: 400ms */ - updateDelayMs: number - /** If true, will wait for geometry to be ready before it is added to the renderer. @default: false */ - delayRender: boolean -} - -export type LoadPartialSettings = Partial -function getFullSettings (option: LoadPartialSettings) { - return { - updateDelayMs: option?.updateDelayMs ?? 400, - delayRender: option?.delayRender ?? false - } as LoadSettings -} - -/** - * Manages geometry downloads and loads it into a scene for rendering. - */ -export class SubsetRequest { - private _subset: G3dSubset - - private _uniques: G3dSubset - private _nonUniques: G3dSubset - private _opaqueMesh: InsertableMesh - private _transparentMesh: InsertableMesh - - private _synchronizer: LoadingSynchronizer - private _meshFactory: InstancedMeshFactory - private _meshes: InstancedMesh[] = [] - private _pushedMesh = 0 - - private _disposed: boolean = false - private _started: boolean = false - - private _scene: Scene - - getBoundingBox () { - return this._subset.getBoundingBox() - } - - constructor (scene: Scene, localVimx: Vimx, subset: G3dSubset, vimIndex: number = 0) { - this._subset = subset - this._scene = scene - - this._uniques = this._subset.filterUniqueMeshes() - this._nonUniques = this._subset.filterNonUniqueMeshes() - - const opaqueOffsets = this._uniques.getOffsets('opaque') - this._opaqueMesh = new InsertableMesh( - opaqueOffsets, - localVimx.materials, - false, - undefined, - vimIndex - ) - this._opaqueMesh.mesh.name = 'Opaque_Merged_Mesh' - - const transparentOffsets = this._uniques.getOffsets('transparent') - this._transparentMesh = new InsertableMesh( - transparentOffsets, - localVimx.materials, - true, - undefined, - vimIndex - ) - this._transparentMesh.mesh.name = 'Transparent_Merged_Mesh' - - this._scene.addMesh(this._transparentMesh) - this._scene.addMesh(this._opaqueMesh) - - this._meshFactory = new InstancedMeshFactory(localVimx.materials, undefined, vimIndex) - - this._synchronizer = new LoadingSynchronizer( - this._uniques, - this._nonUniques, - (mesh) => localVimx.getMesh(mesh), - (mesh, index) => this.mergeMesh(mesh, index), - (mesh, index) => - this.instanceMesh(mesh, this._nonUniques.getMeshInstances(index)) - ) - - return this - } - - dispose () { - if (!this._disposed) { - this._disposed = true - this._synchronizer.abort() - } - } - - async start (settings: LoadPartialSettings) { - if (this._started) { - return - } - this._started = true - const fullSettings = getFullSettings(settings) - - // Loading and updates are independants - void this._synchronizer.loadAll() - - // Loop until done or disposed. - let lastUpdate = Date.now() - // eslint-disable-next-line no-constant-condition - while (true) { - await this.nextFrame() - if (this._disposed) { - return - } - if (this._synchronizer.isDone) { - this.updateMeshes() - return - } - if ( - !fullSettings.delayRender && - Date.now() - lastUpdate > fullSettings.updateDelayMs - ) { - this.updateMeshes() - lastUpdate = Date.now() - } - } - } - - private async nextFrame () { - return new Promise((resolve) => setTimeout(resolve, 0)) - } - - private mergeMesh (g3dMesh: G3dMesh, index: number) { - this._transparentMesh.insert(g3dMesh, index) - this._opaqueMesh.insert(g3dMesh, index) - } - - private instanceMesh (g3dMesh: G3dMesh, instances: number[]) { - const opaque = this._meshFactory.createOpaque(g3dMesh, instances) - const transparent = this._meshFactory.createTransparent(g3dMesh, instances) - - if (opaque) { - this._meshes.push(opaque) - } - if (transparent) { - this._meshes.push(transparent) - } - } - - private updateMeshes () { - // Update Instanced meshes - while (this._pushedMesh < this._meshes.length) { - const mesh = this._meshes[this._pushedMesh++] - this._scene.addMesh(mesh) - } - - // Update Merged meshes - this._transparentMesh.update() - this._opaqueMesh.update() - this._scene.setDirty() - } -} diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts similarity index 100% rename from src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts rename to src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimx.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimx.ts deleted file mode 100644 index fc9cb8dd1..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimx.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @module vim-loader - */ - -import { G3dMaterial, RemoteVimx, VimHeader, G3dScene } from 'vim-format' - -/** - * Interface to interact with a vimx - */ -export class Vimx { - private readonly vimx: RemoteVimx - readonly scene: G3dScene - readonly materials: G3dMaterial - readonly header: VimHeader - - static async fromRemote (vimx: RemoteVimx, downloadMeshes: boolean) { - if (downloadMeshes) { - await vimx.bfast.forceDownload() - } - const [header, scene, materials] = await Promise.all([ - await vimx.getHeader(), - await vimx.getScene(), - await vimx.getMaterials() - ]) - - return new Vimx(vimx, header, scene, materials) - } - - private constructor ( - vimx: RemoteVimx, - header: VimHeader, - scene: G3dScene, - material: G3dMaterial - ) { - this.vimx = vimx - this.header = header - this.scene = scene - this.materials = material - } - - getMesh (mesh: number) { - return this.vimx.getMesh(mesh) - } - - abort () { - this.vimx.abort() - } -} diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index 4eb20bf40..31ee788e4 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -9,17 +9,13 @@ import { VimSettings } from './vimSettings' import { Element3D } from './element3d' import { ElementMapping, - ElementMapping2, ElementNoMapping } from './elementMapping' import { ISignal, SignalDispatcher } from 'ste-signals' import { G3dSubset } from './progressive/g3dSubset' import { SubsetBuilder } from './progressive/subsetBuilder' -import { LoadPartialSettings } from './progressive/subsetRequest' import { IVim } from '../../shared/vim' -type VimFormat = 'vim' | 'vimx' - /** * Represents a container for the built three.js meshes and the vim data from which they were constructed. * Facilitates high-level scene manipulation by providing access to objects. @@ -37,11 +33,6 @@ export class Vim implements IVim { */ readonly vimIndex: number - /** - * Indicates whether the vim was opened from a vim or vimx file. - */ - readonly format: VimFormat - /** * Indicates the url this vim came from if applicable. */ @@ -75,7 +66,7 @@ export class Vim implements IVim { /** * The mapping from Bim to Geometry for this vim. */ - readonly map: ElementMapping | ElementNoMapping | ElementMapping2 + readonly map: ElementMapping | ElementNoMapping private readonly _builder: SubsetBuilder private readonly _loadedInstances = new Set() @@ -115,10 +106,9 @@ export class Vim implements IVim { * @param {Scene} scene - The scene containing the vim's geometry. * @param {VimSettings} settings - The settings used to open this vim. * @param {number} vimIndex - The stable ID of this vim (0-255) for GPU picking. - * @param {ElementMapping | ElementNoMapping | ElementMapping2} map - The element mapping. + * @param {ElementMapping | ElementNoMapping} map - The element mapping. * @param {SubsetBuilder} builder - The subset builder for constructing subsets of the Vim object. * @param {string} source - The source of the Vim object. - * @param {VimFormat} format - The format of the Vim object. */ constructor ( header: VimHeader | undefined, @@ -127,10 +117,9 @@ export class Vim implements IVim { scene: Scene, settings: VimSettings, vimIndex: number, - map: ElementMapping | ElementNoMapping | ElementMapping2, + map: ElementMapping | ElementNoMapping, builder: SubsetBuilder, - source: string, - format: VimFormat) { + source: string) { this.header = header this.bim = document this.g3d = g3d @@ -142,7 +131,6 @@ export class Vim implements IVim { this.map = map ?? new ElementNoMapping() this._builder = builder this.source = source - this.format = format } getBoundingBox(): Promise { @@ -243,50 +231,42 @@ export class Vim implements IVim { } /** - * Asynchronously loads all geometry according to the provided settings. - * @param {LoadPartialSettings} [settings] - Optional settings for the loading process. + * Asynchronously loads all geometry. */ - async loadAll (settings?: LoadPartialSettings) { - return this.loadSubset(this.getFullSet(), settings) + async loadAll () { + return this.loadSubset(this.getFullSet()) } /** - * Asynchronously loads geometry for the specified subset according to the provided settings. + * Asynchronously loads geometry for the specified subset. * @param {G3dSubset} subset - The subset to load resources for. - * @param {LoadPartialSettings} [settings] - Optional settings for the loading process. */ - async loadSubset (subset: G3dSubset, settings?: LoadPartialSettings) { + async loadSubset (subset: G3dSubset) { subset = subset.except('instance', this._loadedInstances) const count = subset.getInstanceCount() for (let i = 0; i < count; i++) { this._loadedInstances.add(subset.getVimInstance(i)) } - // Add box to rendering. - const box = subset.getBoundingBox() - this.scene.updateBox(box) - if (subset.getInstanceCount() === 0) { console.log('Empty subset. Ignoring') return } // Launch loading - await this._builder.loadSubset(subset, settings) + await this._builder.loadSubset(subset) } /** * Asynchronously loads geometry based on a specified filter mode and criteria. * @param {FilterMode} filterMode - The mode of filtering to apply. * @param {number[]} filter - The filter criteria. - * @param {LoadPartialSettings} [settings] - Optional settings for the loading process. */ async loadFilter ( filterMode: FilterMode, - filter: number[], - settings?: LoadPartialSettings + filter: number[] ) { const subset = this.getFullSet().filter(filterMode, filter) - await this.loadSubset(subset, settings) + await this.loadSubset(subset) } /** diff --git a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts index a1c6869fb..a8f61a74d 100644 --- a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts +++ b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts @@ -6,9 +6,6 @@ import deepmerge from 'deepmerge' import { Transparency } from './geometry' import * as THREE from 'three' -// Internal only - not exported -type FileType = 'vim' | 'vimx' | undefined - /** * Represents settings for configuring the behavior and rendering of a vim object. */ @@ -46,43 +43,17 @@ export type VimSettings = { verboseHttp: boolean } -/** - * Internal settings type that includes vimx-specific fields. - * Used internally for loading vimx files. - */ -export type VimSettingsFull = VimSettings & { - /** - * Specifies the file type (vim or vimx) if it cannot or should not be inferred from the file extension. - */ - fileType: FileType - - /** - * Set to true to stream geometry to the scene. Only supported with vimx files. - */ - progressive: boolean - - /** - * The time in milliseconds between each scene refresh during progressive loading. - */ - progressiveInterval: number -} - /** * Default configuration settings for a vim object. */ -export function getDefaultVimSettings(): VimSettingsFull { +export function getDefaultVimSettings(): VimSettings { return { position: new THREE.Vector3(), rotation: new THREE.Vector3(), scale: 1, matrix: undefined, transparency: 'all', - verboseHttp: false, - - // progressive (internal) - fileType: undefined, - progressive: false, - progressiveInterval: 1000 + verboseHttp: false } } @@ -94,12 +65,12 @@ export type VimPartialSettings = Partial /** * Wraps Vim options, converting values to related THREE.js types and providing default values. * @param {VimPartialSettings} [options] - Optional partial settings for the Vim object. - * @returns {VimSettingsFull} The complete settings for the Vim object, including defaults. + * @returns {VimSettings} The complete settings for the Vim object, including defaults. */ -export function createVimSettings (options?: VimPartialSettings): VimSettingsFull { +export function createVimSettings (options?: VimPartialSettings): VimSettings { const merge = (options ? deepmerge(getDefaultVimSettings(), options, undefined) - : getDefaultVimSettings()) as VimSettingsFull + : getDefaultVimSettings()) as VimSettings merge.transparency = Transparency.isValid(merge.transparency) ? merge.transparency From 2ddcf00a6af59eca4827e0f506f5320a1f7441a6 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Fri, 6 Feb 2026 14:38:40 -0500 Subject: [PATCH 36/94] removing more vimx --- .../webgl/loader/colorAttribute.ts | 57 +--- .../webgl/loader/materials/materials.ts | 41 +++ src/vim-web/core-viewers/webgl/loader/mesh.ts | 253 +----------------- .../loader/progressive/insertableMesh.ts | 50 +--- .../webgl/loader/progressive/instancedMesh.ts | 50 +--- .../webgl/loader/progressive/subsetBuilder.ts | 23 +- .../core-viewers/webgl/loader/scene.ts | 6 +- src/vim-web/core-viewers/webgl/loader/vim.ts | 8 +- .../core-viewers/webgl/viewer/raycaster.ts | 9 +- 9 files changed, 73 insertions(+), 424 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/colorAttribute.ts b/src/vim-web/core-viewers/webgl/loader/colorAttribute.ts index ff6845a21..2eb7bbbce 100644 --- a/src/vim-web/core-viewers/webgl/loader/colorAttribute.ts +++ b/src/vim-web/core-viewers/webgl/loader/colorAttribute.ts @@ -5,7 +5,6 @@ import * as THREE from 'three' import { MergedSubmesh } from './mesh' import { Vim } from './vim' -import { InsertableSubmesh } from './progressive/insertableSubmesh' import { WebglAttributeTarget } from './webglAttribute' export class WebglColorAttribute { @@ -69,17 +68,15 @@ export class WebglColorAttribute { const indices = sub.three.geometry.index // Save colors to be able to reset. - if (sub instanceof InsertableSubmesh) { - let c = 0 - const previous = new Float32Array((end - start) * 3) - for (let i = start; i < end; i++) { - const v = indices.getX(i) - previous[c++] = colors.getX(v) - previous[c++] = colors.getY(v) - previous[c++] = colors.getZ(v) - } - sub.saveColors(previous) + let c = 0 + const previous = new Float32Array((end - start) * 3) + for (let i = start; i < end; i++) { + const v = indices.getX(i) + previous[c++] = colors.getX(v) + previous[c++] = colors.getY(v) + previous[c++] = colors.getZ(v) } + sub.saveColors(previous) for (let i = start; i < end; i++) { const v = indices.getX(i) @@ -87,47 +84,11 @@ export class WebglColorAttribute { colors.setXYZ(v, color.r, color.g, color.b) } colors.needsUpdate = true - colors.clearUpdateRanges() + colors.clearUpdateRanges() } - /** - * Repopulates the color buffer of the merged mesh from original g3d data. - * @param index index of the merged mesh instance - */ private resetMergedColor (sub: MergedSubmesh) { if (!this.vim) return - if (sub instanceof InsertableSubmesh) { - this.resetMergedInsertableColor(sub) - return - } - - const colors = sub.three.geometry.getAttribute( - 'color' - ) as THREE.BufferAttribute - - const indices = sub.three.geometry.index - let mergedIndex = sub.meshStart - - const g3d = this.vim.g3d - const g3dMesh = g3d.instanceMeshes[sub.instance] - const subStart = g3d.getMeshSubmeshStart(g3dMesh) - const subEnd = g3d.getMeshSubmeshEnd(g3dMesh) - - for (let sub = subStart; sub < subEnd; sub++) { - const start = g3d.getSubmeshIndexStart(sub) - const end = g3d.getSubmeshIndexEnd(sub) - const color = g3d.getSubmeshColor(sub) - for (let i = start; i < end; i++) { - const v = indices.getX(mergedIndex) - colors.setXYZ(v, color[0], color[1], color[2]) - mergedIndex++ - } - } - colors.needsUpdate = true - colors.clearUpdateRanges() - } - - private resetMergedInsertableColor (sub: InsertableSubmesh) { const previous = sub.popColors() if (previous === undefined) return diff --git a/src/vim-web/core-viewers/webgl/loader/materials/materials.ts b/src/vim-web/core-viewers/webgl/loader/materials/materials.ts index d0bbfd2c3..bbc154e50 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/materials.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/materials.ts @@ -15,6 +15,47 @@ import { SkyboxMaterial } from './skyboxMaterial' export type ModelMaterial = THREE.Material | THREE.Material[] | undefined +/** + * Applies a material override to a THREE.Mesh. + * If value is an array, undefined entries are replaced with the base material. + * If value is undefined, resets to the base material. + */ +export function applyMaterial( + mesh: THREE.Mesh, + value: ModelMaterial, + baseMaterial: ModelMaterial, + ignoreSceneMaterial: boolean +) { + if (ignoreSceneMaterial) return + + const base = baseMaterial + let mat: ModelMaterial + + if (Array.isArray(value)) { + const baseArr = Array.isArray(base) ? base : [base] + const result: THREE.Material[] = [] + for (const v of value) { + if (v === undefined) { + result.push(...baseArr) + } else { + result.push(v) + } + } + mat = result + } else { + mat = value ?? base + } + + mesh.material = mat + + mesh.geometry.clearGroups() + if (Array.isArray(mat)) { + mat.forEach((_m, i) => { + mesh.geometry.addGroup(0, Infinity, i) + }) + } +} + /** * Defines the materials to be used by the vim loader and allows for material injection. */ diff --git a/src/vim-web/core-viewers/webgl/loader/mesh.ts b/src/vim-web/core-viewers/webgl/loader/mesh.ts index 667dc5801..8115df30c 100644 --- a/src/vim-web/core-viewers/webgl/loader/mesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/mesh.ts @@ -4,195 +4,9 @@ import * as THREE from 'three' import { InsertableSubmesh } from './progressive/insertableSubmesh' -import { Vim } from './vim' import { InstancedSubmesh } from './progressive/instancedSubmesh' -import { ModelMaterial } from './materials/materials' -/** - * Wrapper around THREE.Mesh - * Keeps track of what VIM instances are part of this mesh. - * Is either merged on instanced. - */ -export class Mesh { - /** - * the wrapped THREE mesh - */ - mesh: THREE.Mesh - - /** - * Vim file from which this mesh was created. - */ - vim: Vim | undefined - - /** - * Whether the mesh is merged or not. - */ - merged: boolean - - /** - * Indices of the g3d instances that went into creating the mesh - */ - instances: number[] - - /** - * startPosition of each submesh on a merged mesh. - */ - submeshes: number[] - /** - * bounding box of each instance - */ - boxes: THREE.Box3[] - - /** - * Set to true to ignore SetMaterial calls. - */ - ignoreSceneMaterial: boolean - - /** - * Total bounding box for this mesh. - */ - boundingBox: THREE.Box3 - - /** - * initial material. - */ - private _material: ModelMaterial - - private constructor ( - mesh: THREE.Mesh, - instance: number[], - boxes: THREE.Box3[] - ) { - this.mesh = mesh - this.mesh.userData.vim = this - this.instances = instance - this.boxes = boxes - this.boundingBox = this.unionAllBox(boxes) - this._material = mesh.material - } - - static createMerged ( - mesh: THREE.Mesh, - instances: number[], - boxes: THREE.Box3[], - submeshes: number[] - ) { - const result = new Mesh(mesh, instances, boxes) - result.merged = true - result.submeshes = submeshes - return result - } - - static createInstanced ( - mesh: THREE.Mesh, - instances: number[], - boxes: THREE.Box3[] - ) { - const result = new Mesh(mesh, instances, boxes) - result.merged = false - return result - } - - /** - * Sets the material for this mesh. - * Set to undefined to reset to original materials. - */ - setMaterial(value: ModelMaterial) { - if (this.ignoreSceneMaterial) return; - - const base = this._material; // always defined - let mat: ModelMaterial; - - if (Array.isArray(value)) { - mat = this._mergeMaterials(value, base); - } else { - mat = value ?? base; - } - - // Apply it - this.mesh.material = mat; - - // Update groups - this.mesh.geometry.clearGroups(); - if (Array.isArray(mat)) { - mat.forEach((_m, i) => { - this.mesh.geometry.addGroup(0, Infinity, i); - }); - } - } - - private _mergeMaterials( - value: THREE.Material[], - base: ModelMaterial - ): THREE.Material[] { - const baseArr = Array.isArray(base) ? base : [base]; - const result: THREE.Material[] = []; - - for (const v of value) { - if (v === undefined) { - result.push(...baseArr); - } else { - result.push(v); - } - } - - return result; - } - - /** - * Returns submesh for given index. - */ - getSubMesh (index: number) { - return new StandardSubmesh(this, index) - } - - /** - * Returns submesh corresponding to given face on a merged mesh. - */ - getSubmeshFromFace (faceIndex: number) { - if (!this.merged) { - throw new Error('Can only be called when mesh.merged = true') - } - const index = this.binarySearch(this.submeshes, faceIndex * 3) - return new StandardSubmesh(this, index) - } - - /** - * - * @returns Returns all submeshes - */ - getSubmeshes () { - return this.instances.map((s, i) => new StandardSubmesh(this, i)) - } - - private binarySearch (array: number[], element: number) { - let m = 0 - let n = array.length - 1 - while (m <= n) { - const k = (n + m) >> 1 - const cmp = element - array[k] - if (cmp > 0) { - m = k + 1 - } else if (cmp < 0) { - n = k - 1 - } else { - return k - } - } - return m - 1 - } - - private unionAllBox (boxes: THREE.Box3[]) { - const box = boxes[0].clone() - for (let i = 1; i < boxes.length; i++) { - box.union(boxes[i]) - } - return box - } -} - -// eslint-disable-next-line no-use-before-define -export type MergedSubmesh = StandardSubmesh | InsertableSubmesh +export type MergedSubmesh = InsertableSubmesh export type Submesh = MergedSubmesh | InstancedSubmesh export class SimpleInstanceSubmesh { @@ -206,68 +20,3 @@ export class SimpleInstanceSubmesh { this.index = index } } - -export class StandardSubmesh { - mesh: Mesh - index: number - - constructor (mesh: Mesh, index: number) { - this.mesh = mesh - this.index = index - } - - equals (other: Submesh) { - return this.mesh === other.mesh && this.index === other.index - } - - /** - * Returns parent three mesh. - */ - get three () { - return this.mesh.mesh - } - - /** - * True if parent mesh is merged. - */ - get merged () { - return this.mesh.merged - } - - /** - * Returns vim instance associated with this submesh. - */ - get instance () { - return this.mesh.instances[this.index] - } - - /** - * Returns bounding box for this submesh. - */ - get boundingBox () { - return this.mesh.boxes[this.index] - } - - /** - * Returns starting position in parent mesh for merged mesh. - */ - get meshStart () { - return this.mesh.submeshes[this.index] - } - - /** - * Returns ending position in parent mesh for merged mesh. - */ - get meshEnd () { - return this.index + 1 < this.mesh.submeshes.length - ? this.mesh.submeshes[this.index + 1] - : this.three.geometry.index.count - } - - /** - * Returns vim object for this submesh. - */ - get object () { - return this.mesh.vim.getElement(this.instance) - } -} diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts index f3024cd8c..7e6177313 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts @@ -8,7 +8,7 @@ import { InsertableGeometry } from './insertableGeometry' import { InsertableSubmesh } from './insertableSubmesh' import { G3dMeshOffsets } from './g3dOffsets' import { Vim } from '../vim' -import { ModelMaterial, Materials } from '../materials/materials' +import { ModelMaterial, Materials, applyMaterial } from '../materials/materials' import { ElementMapping } from '../elementMapping' export class InsertableMesh { @@ -122,49 +122,7 @@ export class InsertableMesh { // } } - /** - * Sets the material for this mesh. - * Set to undefined to reset to original materials. - */ - setMaterial(value: ModelMaterial) { - if (this.ignoreSceneMaterial) return; - - const base = this._material; // always defined - let mat: ModelMaterial; - - if (Array.isArray(value)) { - mat = this._mergeMaterials(value, base); - } else { - mat = value ?? base; - } - - // Apply it - this.mesh.material = mat; - - // Update groups - this.mesh.geometry.clearGroups(); - if (Array.isArray(mat)) { - mat.forEach((_m, i) => { - this.mesh.geometry.addGroup(0, Infinity, i); - }); - } - } - - private _mergeMaterials( - value: THREE.Material[], - base: ModelMaterial - ): THREE.Material[] { - const baseArr = Array.isArray(base) ? base : [base]; - const result: THREE.Material[] = []; - - for (const v of value) { - if (v === undefined) { - result.push(...baseArr); - } else { - result.push(v); - } - } - - return result; - } + setMaterial(value: ModelMaterial) { + applyMaterial(this.mesh, value, this._material, this.ignoreSceneMaterial) + } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts index e049a1dcb..2afefff11 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts @@ -6,7 +6,7 @@ import * as THREE from 'three' import { Vim } from '../vim' import { InstancedSubmesh } from './instancedSubmesh' import { G3d } from 'vim-format' -import { ModelMaterial } from '../materials/materials' +import { ModelMaterial, applyMaterial } from '../materials/materials' export class InstancedMesh { g3dMesh: G3d @@ -64,51 +64,9 @@ export class InstancedMesh { return submeshes } - /** - * Sets the material for this mesh. - * Set to undefined to reset to original materials. - */ - setMaterial(value: ModelMaterial) { - if (this.ignoreSceneMaterial) return; - - const base = this._material; // always defined - let mat: ModelMaterial; - - if (Array.isArray(value)) { - mat = this._mergeMaterials(value, base); - } else { - mat = value ?? base; - } - - // Apply it - this.mesh.material = mat; - - // Update groups - this.mesh.geometry.clearGroups(); - if (Array.isArray(mat)) { - mat.forEach((_m, i) => { - this.mesh.geometry.addGroup(0, Infinity, i); - }); - } - } - - private _mergeMaterials( - value: THREE.Material[], - base: ModelMaterial - ): THREE.Material[] { - const baseArr = Array.isArray(base) ? base : [base]; - const result: THREE.Material[] = []; - - for (const v of value) { - if (v === undefined) { - result.push(...baseArr); - } else { - result.push(v); - } - } - - return result; - } + setMaterial(value: ModelMaterial) { + applyMaterial(this.mesh, value, this._material, this.ignoreSceneMaterial) + } private computeBoundingBoxes () { this.mesh.geometry.computeBoundingBox() diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts b/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts index 0f5781bd6..b11bf8505 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts @@ -6,34 +6,15 @@ import { VimMeshFactory } from './vimMeshFactory' import { G3dSubset } from './g3dSubset' import { ISignal, SignalDispatcher } from 'ste-signals' -export interface SubsetBuilder { - /** Dispatched whenever a subset begins or finishes loading. */ - onUpdate: ISignal - - /** Returns true when some subset is being loaded. */ - isLoading: boolean - - /** Returns all instances as a subset */ - getFullSet(): G3dSubset - - /** Loads given subset */ - loadSubset(subset: G3dSubset) - - /** Stops and clears all loading processes */ - clear() - - dispose() -} - /** * Loads and builds subsets from a Vim file. */ -export class VimSubsetBuilder implements SubsetBuilder { +export class VimSubsetBuilder { factory: VimMeshFactory private _onUpdate = new SignalDispatcher() - get onUpdate () { + get onUpdate (): ISignal { return this._onUpdate.asEvent() } diff --git a/src/vim-web/core-viewers/webgl/loader/scene.ts b/src/vim-web/core-viewers/webgl/loader/scene.ts index 67d496516..9636d773d 100644 --- a/src/vim-web/core-viewers/webgl/loader/scene.ts +++ b/src/vim-web/core-viewers/webgl/loader/scene.ts @@ -3,7 +3,7 @@ */ import * as THREE from 'three' -import { Mesh, Submesh } from './mesh' +import { Submesh } from './mesh' import { Vim } from './vim' import { estimateBytesUsed } from 'three/examples/jsm/utils/BufferGeometryUtils' import { InsertableMesh } from './progressive/insertableMesh' @@ -37,7 +37,7 @@ export class Scene { // State insertables: InsertableMesh[] = [] - meshes: (Mesh | InsertableMesh | InstancedMesh)[] = [] + meshes: (InsertableMesh | InstancedMesh)[] = [] private _boundingBox: THREE.Box3 @@ -161,7 +161,7 @@ export class Scene { * userData.instances = number[] (indices of the g3d instances that went into creating the mesh) * userData.boxes = THREE.Box3[] (bounding box of each instance) */ - addMesh (mesh: Mesh | InsertableMesh | InstancedMesh) { + addMesh (mesh: InsertableMesh | InstancedMesh) { this.renderer?.add(mesh.mesh) mesh.vim = this.vim diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index 31ee788e4..f9fc5fe7c 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -13,7 +13,7 @@ import { } from './elementMapping' import { ISignal, SignalDispatcher } from 'ste-signals' import { G3dSubset } from './progressive/g3dSubset' -import { SubsetBuilder } from './progressive/subsetBuilder' +import { VimSubsetBuilder } from './progressive/subsetBuilder' import { IVim } from '../../shared/vim' /** @@ -68,7 +68,7 @@ export class Vim implements IVim { */ readonly map: ElementMapping | ElementNoMapping - private readonly _builder: SubsetBuilder + private readonly _builder: VimSubsetBuilder private readonly _loadedInstances = new Set() private readonly _elementToObject = new Map() @@ -107,7 +107,7 @@ export class Vim implements IVim { * @param {VimSettings} settings - The settings used to open this vim. * @param {number} vimIndex - The stable ID of this vim (0-255) for GPU picking. * @param {ElementMapping | ElementNoMapping} map - The element mapping. - * @param {SubsetBuilder} builder - The subset builder for constructing subsets of the Vim object. + * @param {VimSubsetBuilder} builder - The subset builder for constructing subsets of the Vim object. * @param {string} source - The source of the Vim object. */ constructor ( @@ -118,7 +118,7 @@ export class Vim implements IVim { settings: VimSettings, vimIndex: number, map: ElementMapping | ElementNoMapping, - builder: SubsetBuilder, + builder: VimSubsetBuilder, source: string) { this.header = header this.bim = document diff --git a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts index ce1d03376..f717b4059 100644 --- a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts +++ b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts @@ -4,7 +4,8 @@ import * as THREE from 'three' import { Element3D } from '../loader/element3d' -import { Mesh } from '../loader/mesh' +import { InsertableMesh } from '../loader/progressive/insertableMesh' +import { InstancedMesh } from '../loader/progressive/instancedMesh' import { RenderScene } from './rendering/renderScene' import { Camera } from './camera/camera' import { Renderer } from './rendering/renderer' @@ -132,11 +133,11 @@ export class Raycaster implements IRaycaster { * Extracts the core model object from a raycast hit. */ private getVimObjectFromHit(hit: THREE.Intersection): Element3D | undefined { - const mesh = hit.object.userData.vim as Mesh + const mesh = hit.object.userData.vim as InsertableMesh | InstancedMesh if (!mesh) return undefined const sub = mesh.merged - ? mesh.getSubmeshFromFace(hit.faceIndex) - : mesh.getSubMesh(hit.instanceId) + ? (mesh as InsertableMesh).getSubmeshFromFace(hit.faceIndex) + : (mesh as InstancedMesh).getSubMesh(hit.instanceId) return sub?.object } From 1dbce2959ec776b5361ca35fc08e109e00d7a059 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Fri, 6 Feb 2026 15:12:46 -0500 Subject: [PATCH 37/94] cleaning up loading --- .../core-viewers/webgl/loader/geometry.ts | 15 ------- .../core-viewers/webgl/loader/index.ts | 1 - .../webgl/loader/progressive/g3dOffsets.ts | 23 ---------- .../webgl/loader/progressive/g3dSubset.ts | 35 +-------------- .../loader/progressive/insertableGeometry.ts | 7 ++- .../webgl/loader/progressive/instancedMesh.ts | 18 +++----- .../progressive/instancedMeshFactory.ts | 8 ++-- .../loader/progressive/instancedSubmesh.ts | 2 +- .../webgl/loader/progressive/loadRequest.ts | 5 +-- .../webgl/loader/progressive/subsetBuilder.ts | 43 ------------------- .../loader/progressive/vimMeshFactory.ts | 2 +- src/vim-web/core-viewers/webgl/loader/vim.ts | 43 +++++-------------- src/vim-web/react-viewers/webgl/loading.ts | 4 +- 13 files changed, 28 insertions(+), 178 deletions(-) delete mode 100644 src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts diff --git a/src/vim-web/core-viewers/webgl/loader/geometry.ts b/src/vim-web/core-viewers/webgl/loader/geometry.ts index 5da1a308e..20464f7db 100644 --- a/src/vim-web/core-viewers/webgl/loader/geometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/geometry.ts @@ -86,21 +86,6 @@ function createVertexColors ( return result } -/** - * Returns a THREE.Matrix4 from the g3d for given instance - * @param instance g3d instance index - * @param target matrix where the data will be copied, a new matrix will be created if none provided. - */ -export function getInstanceMatrix ( - g3d: G3d, - instance: number, - target: THREE.Matrix4 = new THREE.Matrix4() -): THREE.Matrix4 { - const matrixAsArray = g3d.getInstanceMatrix(instance) - target.fromArray(matrixAsArray) - return target -} - /** * Creates a BufferGeometry from given geometry data arrays * @param vertices vertex data with 3 number per vertex (XYZ) diff --git a/src/vim-web/core-viewers/webgl/loader/index.ts b/src/vim-web/core-viewers/webgl/loader/index.ts index 9983feb50..f44534b28 100644 --- a/src/vim-web/core-viewers/webgl/loader/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/index.ts @@ -23,7 +23,6 @@ export type * from './progressive/instancedMesh'; export type * from './progressive/instancedMeshFactory'; export type * from './progressive/instancedSubmesh'; export type * from './progressive/vimMeshFactory'; -export type * from './progressive/subsetBuilder'; // Not exported // export * from './progressive/open'; diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts index 3482548f9..02d89cd8d 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts @@ -73,27 +73,4 @@ export class G3dMeshOffsets { : this.counts.vertices } - /** - * Returns instance counts of given mesh. - * @param mesh subset-based mesh index - */ - getMeshInstanceCount (mesh: number) { - return this.subset.getMeshInstanceCount(mesh) - } - - /** - * Returns source-based instance for given mesh and index. - * @mesh subset-based mesh index - * @index mesh-based instance index - */ - getMeshInstance (mesh: number, index: number) { - return this.subset.getMeshInstance(mesh, index) - } - - /** - * Returns the source-based mesh index at given index - */ - getSourceMesh (index: number) { - return this.subset.getSourceMesh(index) - } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts index 9eaf2a350..123798e3d 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts @@ -87,7 +87,7 @@ export class G3dSubset { return chunks } - /** + /** * Returns total instance count in subset. */ @@ -103,17 +103,6 @@ export class G3dSubset { return this._source.instanceNodes[vimIndex] } - /** - * Returns the vim-based instances (nodes) for current subset. - */ - getVimInstances () { - const results = new Array(this._instances.length) - for (let i = 0; i < results.length; i++) { - results[i] = this.getVimInstance(i) - } - return results - } - /** * Returns source-based mesh index. * @param index subset-based mesh index @@ -181,20 +170,6 @@ export class G3dSubset { return this._meshInstances[mesh][index] } - /** - * Returns a new subset that only contains unique meshes. - */ - filterUniqueMeshes () { - return this.filterByCount((count) => count === 1) - } - - /** - * Returns a new subset that only contains non-unique meshes. - */ - filterNonUniqueMeshes () { - return this.filterByCount((count) => count > 1) - } - filterByCount (predicate: (i: number) => boolean) { const set = new Set() this._meshInstances.forEach((instances, i) => { @@ -232,14 +207,6 @@ export class G3dSubset { return result } - /** - * Returns a new subset where the order of meshes is inverted. - */ - reverse () { - const reverse = [...this._instances].reverse() - return new G3dSubset(this._source, reverse) - } - /** * Returns a new subset with instances not included in given filter. * @param mode Defines which field the filter will be applied to. diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 1cf7efc5b..9569ff618 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -9,7 +9,6 @@ import { G3dMeshOffsets } from './g3dOffsets' import { ElementMapping } from '../elementMapping' import { packPickingId } from '../../viewer/rendering/gpuPicker' -// TODO Merge both submeshes class. export class GeometrySubmesh { instance: number start: number @@ -92,7 +91,7 @@ export class InsertableGeometry { insertFromG3d (g3d: G3d, mesh: number) { const added: number[] = [] - const meshG3dIndex = this.offsets.getSourceMesh(mesh) + const meshG3dIndex = this.offsets.subset.getSourceMesh(mesh) const subStart = g3d.getMeshSubmeshStart(meshG3dIndex, this.offsets.section) const subEnd = g3d.getMeshSubmeshEnd(meshG3dIndex, this.offsets.section) @@ -118,9 +117,9 @@ export class InsertableGeometry { let indexOut = 0 let vertexOut = 0 // Iterate over all included instances for this mesh. - const instanceCount = this.offsets.getMeshInstanceCount(mesh) + const instanceCount = this.offsets.subset.getMeshInstanceCount(mesh) for (let instance = 0; instance < instanceCount; instance++) { - const g3dInstance = this.offsets.getMeshInstance(mesh, instance) + const g3dInstance = this.offsets.subset.getMeshInstance(mesh, instance) matrix.fromArray(g3d.getInstanceMatrix(g3dInstance)) // Get element index for this instance (for GPU picking) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts index 2afefff11..9e625ddf8 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts @@ -5,36 +5,28 @@ import * as THREE from 'three' import { Vim } from '../vim' import { InstancedSubmesh } from './instancedSubmesh' -import { G3d } from 'vim-format' import { ModelMaterial, applyMaterial } from '../materials/materials' export class InstancedMesh { - g3dMesh: G3d vim: Vim mesh: THREE.InstancedMesh - - // instances - bimInstances: ArrayLike - meshInstances: ArrayLike + instances: ArrayLike boundingBox: THREE.Box3 boxes: THREE.Box3[] // State ignoreSceneMaterial: boolean - + private _material: ModelMaterial readonly size: number = 0 constructor ( - g3d: G3d, mesh: THREE.InstancedMesh, instances: Array ) { - this.g3dMesh = g3d this.mesh = mesh this.mesh.userData.vim = this - this.bimInstances = instances - this.meshInstances = instances + this.instances = instances this.boxes = this.computeBoundingBoxes() this.size = this.boxes[0]?.getSize(new THREE.Vector3()).length() ?? 0 @@ -57,8 +49,8 @@ export class InstancedMesh { * Returns all submeshes for given index. */ getSubmeshes () { - const submeshes = new Array(this.bimInstances.length) - for (let i = 0; i < this.bimInstances.length; i++) { + const submeshes = new Array(this.instances.length) + for (let i = 0; i < this.instances.length; i++) { submeshes[i] = new InstancedSubmesh(this, i) } return submeshes diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index b761d58af..797bf4ad9 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -3,7 +3,7 @@ */ import * as THREE from 'three' -import { G3d, G3dMaterial, MeshSection } from 'vim-format' +import { G3d, MeshSection } from 'vim-format' import { InstancedMesh } from './instancedMesh' import { Materials } from '../materials/materials' import * as Geometry from '../geometry' @@ -11,12 +11,10 @@ import { ElementMapping } from '../elementMapping' import { packPickingId } from '../../viewer/rendering/gpuPicker' export class InstancedMeshFactory { - materials: G3dMaterial private _mapping: ElementMapping | undefined private _vimIndex: number - constructor (materials: G3dMaterial, mapping?: ElementMapping, vimIndex: number = 0) { - this.materials = materials + constructor (mapping?: ElementMapping, vimIndex: number = 0) { this._mapping = mapping this._vimIndex = vimIndex } @@ -54,7 +52,7 @@ export class InstancedMeshFactory { this.setMatrices(threeMesh, g3d, instances) this.setPackedIds(threeMesh, instances ?? g3d.meshInstances[mesh]) - const result = new InstancedMesh(g3d, threeMesh, instances) + const result = new InstancedMesh(threeMesh, instances) return result } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedSubmesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedSubmesh.ts index 1f337c4a3..7a45e6f83 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedSubmesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedSubmesh.ts @@ -36,7 +36,7 @@ export class InstancedSubmesh { * Returns vim instance associated with this submesh. */ get instance () { - return this.mesh.bimInstances[this.index] + return this.mesh.instances[this.index] } /** diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts index 6a2400b0b..5c0915669 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -2,7 +2,6 @@ import { createVimSettings, VimPartialSettings } from '../vimSettings' import { Vim } from '../vim' import { Scene } from '../scene' import { ElementMapping } from '../elementMapping' -import { VimSubsetBuilder } from './subsetBuilder' import { VimMeshFactory } from './vimMeshFactory' import { LoadRequest as BaseLoadRequest, ILoadRequest as BaseILoadRequest, LoadError, LoadSuccess } from '../../../shared/loadResult' import { VimSource } from '../..' @@ -77,8 +76,6 @@ export class LoadRequest extends BaseLoadRequest { const header = await requestHeader(bfast) - // Create vim - const builder = new VimSubsetBuilder(factory) const vim = new Vim( header, doc, @@ -87,7 +84,7 @@ export class LoadRequest extends BaseLoadRequest { fullSettings, vimIndex, mapping, - builder, + factory, bfast.url ) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts b/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts deleted file mode 100644 index b11bf8505..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @module vim-loader - */ - -import { VimMeshFactory } from './vimMeshFactory' -import { G3dSubset } from './g3dSubset' -import { ISignal, SignalDispatcher } from 'ste-signals' - -/** - * Loads and builds subsets from a Vim file. - */ -export class VimSubsetBuilder { - factory: VimMeshFactory - - private _onUpdate = new SignalDispatcher() - - get onUpdate (): ISignal { - return this._onUpdate.asEvent() - } - - get isLoading () { - return false - } - - constructor (factory: VimMeshFactory) { - this.factory = factory - } - - getFullSet () { - return new G3dSubset(this.factory.g3d) - } - - loadSubset (subset: G3dSubset) { - this.factory.add(subset) - this._onUpdate.dispatch() - } - - clear () { - this._onUpdate.dispatch() - } - - dispose () {} -} diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts index 48207bd6c..dd91f0cf0 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts @@ -26,7 +26,7 @@ export class VimMeshFactory { this._scene = scene this._mapping = mapping this._vimIndex = vimIndex - this._instancedFactory = new InstancedMeshFactory(materials, mapping, vimIndex) + this._instancedFactory = new InstancedMeshFactory(mapping, vimIndex) } /** diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index f9fc5fe7c..7e70e5983 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -13,7 +13,7 @@ import { } from './elementMapping' import { ISignal, SignalDispatcher } from 'ste-signals' import { G3dSubset } from './progressive/g3dSubset' -import { VimSubsetBuilder } from './progressive/subsetBuilder' +import { VimMeshFactory } from './progressive/vimMeshFactory' import { IVim } from '../../shared/vim' /** @@ -68,24 +68,17 @@ export class Vim implements IVim { */ readonly map: ElementMapping | ElementNoMapping - private readonly _builder: VimSubsetBuilder + private readonly _factory: VimMeshFactory private readonly _loadedInstances = new Set() private readonly _elementToObject = new Map() + private _onUpdate = new SignalDispatcher() /** * Getter for accessing the event dispatched whenever a subset begins or finishes loading. * @returns {ISignal} The event dispatcher for loading updates. */ - get onLoadingUpdate () { - return this._builder.onUpdate - } - - /** - * Indicates whether there are subsets currently being loaded. - * @returns {boolean} True if subsets are being loaded; otherwise, false. - */ - get isLoading () { - return this._builder.isLoading + get onLoadingUpdate (): ISignal { + return this._onUpdate.asEvent() } /** @@ -98,18 +91,6 @@ export class Vim implements IVim { private _onDispose = new SignalDispatcher() - /** - * Constructs a new instance of a Vim object with the provided parameters. - * @param {VimHeader | undefined} header - The Vim header, if available. - * @param {VimDocument} document - The Vim document. - * @param {G3d | undefined} g3d - The G3d object, if available. - * @param {Scene} scene - The scene containing the vim's geometry. - * @param {VimSettings} settings - The settings used to open this vim. - * @param {number} vimIndex - The stable ID of this vim (0-255) for GPU picking. - * @param {ElementMapping | ElementNoMapping} map - The element mapping. - * @param {VimSubsetBuilder} builder - The subset builder for constructing subsets of the Vim object. - * @param {string} source - The source of the Vim object. - */ constructor ( header: VimHeader | undefined, document: VimDocument, @@ -118,7 +99,7 @@ export class Vim implements IVim { settings: VimSettings, vimIndex: number, map: ElementMapping | ElementNoMapping, - builder: VimSubsetBuilder, + factory: VimMeshFactory, source: string) { this.header = header this.bim = document @@ -129,7 +110,7 @@ export class Vim implements IVim { this.vimIndex = vimIndex this.map = map ?? new ElementNoMapping() - this._builder = builder + this._factory = factory this.source = source } @@ -227,7 +208,7 @@ export class Vim implements IVim { * @returns {G3dSubset} A subset containing all instances. */ getFullSet (): G3dSubset { - return this._builder.getFullSet() + return new G3dSubset(this._factory.g3d) } /** @@ -252,8 +233,8 @@ export class Vim implements IVim { console.log('Empty subset. Ignoring') return } - // Launch loading - await this._builder.loadSubset(subset) + this._factory.add(subset) + this._onUpdate.dispatch() } /** @@ -276,15 +257,13 @@ export class Vim implements IVim { this._elementToObject.clear() this._loadedInstances.clear() this.scene.clear() - // Clearing this one last because it dispatches the signal - this._builder.clear() + this._onUpdate.dispatch() } /** * Cleans up and releases resources associated with the vim. */ dispose () { - this._builder.dispose() this._onDispose.dispatch() this._onDispose.clear() this.scene.dispose() diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index 845de2988..04f4e8dbe 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -114,8 +114,8 @@ export class ComponentLoader { private async initVim (vim: Core.Webgl.Vim, settings: AddSettings, loadGeometry: boolean) { this._viewer.add(vim) vim.onLoadingUpdate.subscribe(() => { - this._viewer.gizmos.loading.visible = vim.isLoading - if (settings.autoFrame !== false && !vim.isLoading) { + this._viewer.gizmos.loading.visible = false + if (settings.autoFrame !== false) { this._viewer.camera.snap().frame(vim) this._viewer.camera.save() } From f635a27b4f396d4ecc01c4c0f83758c5be26ef25 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 9 Feb 2026 15:27:21 -0500 Subject: [PATCH 38/94] fixd method call --- src/vim-web/core-viewers/webgl/loader/element3d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vim-web/core-viewers/webgl/loader/element3d.ts b/src/vim-web/core-viewers/webgl/loader/element3d.ts index 1fe741f6a..a5093ddb2 100644 --- a/src/vim-web/core-viewers/webgl/loader/element3d.ts +++ b/src/vim-web/core-viewers/webgl/loader/element3d.ts @@ -79,7 +79,7 @@ export class Element3D implements IVimElement { if (this._outlineAttribute.apply(value)) { this.renderer.notifySceneUpdate() if (value) this.renderer.addOutline() - else this.renderer.removeOutline + else this.renderer.removeOutline() } } From 7d3b8b39176d2a215c64936753ca6acae1a9343a Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 9 Feb 2026 15:27:35 -0500 Subject: [PATCH 39/94] fixed delta time --- .../core-viewers/webgl/viewer/rendering/renderingComposer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts index 15b1a0d90..8c2f21d58 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts @@ -222,17 +222,18 @@ export class RenderingComposer { * First renders the main scene, then processes outlines if enabled */ render () { + var delta = this._clock.getDelta() // Render main scene to scene target this._renderPass.render( this._renderer, undefined, this._sceneTarget, - this._clock.getDelta(), + delta, false ) // Process outline pipeline and final composition - this._composer.render(this._clock.getDelta()) + this._composer.render(delta) } /** From f8fb92b7150dc813eb7cefad91fdd0d122792bc5 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 9 Feb 2026 15:27:48 -0500 Subject: [PATCH 40/94] removed debug for picking --- src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts index 67a09d618..a72f3bbb0 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -123,7 +123,7 @@ export class GpuPicker implements IRaycaster { private _readBuffer: Float32Array // Debug visualization - debug = true + debug = false private _debugSphere: THREE.Mesh | undefined private _debugLine: THREE.Line | undefined From 72c9acfe1ed606dcf936fd4161fadf69c2bae3c3 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 9 Feb 2026 15:28:06 -0500 Subject: [PATCH 41/94] comments --- .../webgl/loader/progressive/g3dOffsets.ts | 7 ++ .../webgl/loader/progressive/g3dSubset.ts | 16 ++++- .../loader/progressive/insertableGeometry.ts | 27 ++++++++ .../progressive/insertableMeshFactory.ts | 64 +++++++++++++++++++ .../progressive/instancedMeshFactory.ts | 14 ++++ .../webgl/loader/progressive/loadRequest.ts | 22 ++++++- .../loader/progressive/vimMeshFactory.ts | 54 ++++++---------- .../core-viewers/webgl/loader/scene.ts | 15 +++-- src/vim-web/core-viewers/webgl/loader/vim.ts | 8 ++- 9 files changed, 183 insertions(+), 44 deletions(-) create mode 100644 src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts index 02d89cd8d..01665c7c8 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts @@ -2,6 +2,13 @@ * @module vim-loader */ +/** + * Cumulative offset arrays enabling O(1) lookup of where each mesh starts + * in the unified index and vertex buffers. Computed once from a G3dSubset + * and used by InsertableGeometry to pre-allocate buffers and place each + * mesh's data at the correct position. + */ + import { MeshSection } from 'vim-format' import { G3dSubset } from './g3dSubset' diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts index 123798e3d..9f2ea35d0 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts @@ -60,6 +60,11 @@ export class G3dSubset { } } + /** + * Splits this subset into smaller subsets by index count threshold. + * Note: the threshold is based on total INDEX count (not vertex count), + * matching the 4M index chunking limit used by VimMeshFactory. + */ chunks(count: number): G3dSubset[] { const chunks: G3dSubset[] = [] let currentSize = 0 @@ -170,6 +175,11 @@ export class G3dSubset { return this._meshInstances[mesh][index] } + /** + * Filters meshes by their instance count. Used to separate: + * - merged meshes (<=5 instances) via filterByCount(c => c <= 5) + * - instanced meshes (>5 instances) via filterByCount(c => c > 5) + */ filterByCount (predicate: (i: number) => boolean) { const set = new Set() this._meshInstances.forEach((instances, i) => { @@ -208,9 +218,11 @@ export class G3dSubset { } /** - * Returns a new subset with instances not included in given filter. + * Returns a new subset with instances NOT matching the filter. + * Used in progressive loading to skip already-loaded instances: + * `subset.except('instance', loadedInstances)` * @param mode Defines which field the filter will be applied to. - * @param filter Array of all values to match for. + * @param filter Array or Set of values to exclude. */ except (mode: FilterMode, filter: number[] | Set): G3dSubset { return this._filter(mode, filter, false) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 9569ff618..c47f59aed 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -2,6 +2,22 @@ * @module vim-loader */ +/** + * Manages the Three.js BufferGeometry for merged (InsertableMesh) meshes. + * + * Buffer layout (all pre-allocated via G3dMeshOffsets): + * - index: Uint32 — triangle indices + * - position: Float32x3 — world-space vertices (transforms baked in) + * - color: Float32x3 (opaque) or Float32x4 (transparent) — per-vertex color + * - packedId: Uint32 — per-vertex (vimIndex << 24 | elementIndex) for GPU picking + * + * Geometry is inserted incrementally via insertFromG3d(), which iterates over + * all instances for a given mesh, bakes the instance matrix into vertex positions, + * and tracks submesh boundaries for Element3D mapping. + * + * The update() method uploads only the dirty buffer ranges to the GPU. + */ + import * as THREE from 'three' import { G3d, G3dMaterial } from 'vim-format' import { Scene } from '../scene' @@ -89,6 +105,12 @@ export class InsertableGeometry { return this._indexAttribute.count / this._indexAttribute.array.length } + /** + * Inserts geometry for a single mesh definition, duplicated for each instance. + * For each instance: bakes the instance matrix into vertex positions, copies indices + * with offset adjustment, sets per-vertex colors and packed picking IDs, + * and creates a GeometrySubmesh tracking the index range and bounding box. + */ insertFromG3d (g3d: G3d, mesh: number) { const added: number[] = [] const meshG3dIndex = this.offsets.subset.getSourceMesh(mesh) @@ -195,6 +217,11 @@ export class InsertableGeometry { this._updateStartMesh = this._updateEndMesh } + /** + * Uploads dirty buffer ranges to the GPU. Uses range-based updates + * (addUpdateRange) to minimize GPU transfer — only the contiguous range + * of newly inserted meshes is uploaded for each attribute. + */ update () { // Update up to the mesh for which all preceding meshes are ready while (this._meshToUpdate.has(this._updateEndMesh)) { diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts new file mode 100644 index 000000000..cf97a5a98 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts @@ -0,0 +1,64 @@ +/** + * @module vim-loader + */ + +/** + * Creates merged (InsertableMesh) meshes where geometry is duplicated per instance + * with baked world-space transforms. This approach supports per-vertex attributes + * (color, packed picking IDs) at the cost of higher memory usage. + * + * Used for meshes with <=5 instances. The flow is: + * 1. Compute G3dMeshOffsets to pre-allocate buffer sizes + * 2. Create InsertableMesh with pre-allocated buffers + * 3. Insert each mesh's geometry (looping over all instances, baking transforms) + * 4. Finalize with update() to upload buffer ranges to GPU + */ + +import { G3d, G3dMaterial, MeshSection } from 'vim-format' +import { InsertableMesh } from './insertableMesh' +import { G3dSubset } from './g3dSubset' +import { ElementMapping } from '../elementMapping' + +export class InsertableMeshFactory { + private _materials: G3dMaterial + private _mapping: ElementMapping | undefined + private _vimIndex: number + + constructor (materials: G3dMaterial, mapping?: ElementMapping, vimIndex: number = 0) { + this._materials = materials + this._mapping = mapping + this._vimIndex = vimIndex + } + + createOpaqueFromVim (g3d: G3d, subset: G3dSubset) { + return this.createFromVim(g3d, subset, 'opaque', false) + } + + createTransparentFromVim (g3d: G3d, subset: G3dSubset) { + return this.createFromVim(g3d, subset, 'transparent', true) + } + + /** + * Creates a merged mesh for the given subset and opacity section. + * 1. Pre-allocate buffers via G3dMeshOffsets (enables O(1) offset lookups) + * 2. Insert each mesh's geometry (duplicating per instance with baked transforms) + * 3. Finalize: upload dirty buffer ranges to GPU + */ + private createFromVim ( + g3d: G3d, + subset: G3dSubset, + section: MeshSection, + transparent: boolean + ) { + const offsets = subset.getOffsets(section) + const mesh = new InsertableMesh(offsets, this._materials, transparent, this._mapping, this._vimIndex) + + const count = subset.getMeshCount() + for (let m = 0; m < count; m++) { + mesh.insertFromVim(g3d, m) + } + + mesh.update() + return mesh + } +} diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 797bf4ad9..7560e38fe 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -2,6 +2,13 @@ * @module vim-loader */ +/** + * Creates GPU-instanced meshes where geometry is shared across all instances. + * Used for meshes with >5 instances. Each unique mesh geometry is stored once, + * and Three.js InstancedMesh renders it at multiple transforms via per-instance + * matrix attributes. Per-instance packed IDs are added for GPU picking. + */ + import * as THREE from 'three' import { G3d, MeshSection } from 'vim-format' import { InstancedMesh } from './instancedMesh' @@ -27,6 +34,10 @@ export class InstancedMeshFactory { return this.createFromVim(g3d, mesh, instances, 'transparent', true) } + /** + * Creates a single GPU-instanced mesh: builds shared geometry once, + * then sets per-instance transforms and packed picking IDs. + */ createFromVim ( g3d: G3d, mesh: number, @@ -71,6 +82,9 @@ export class InstancedMeshFactory { /** * Adds per-instance packed ID attribute for GPU picking. + * Each instance gets a uint32 = (vimIndex << 24) | elementIndex, + * stored as an InstancedBufferAttribute so the picking shader can + * read it per-instance without duplicating geometry. */ private setPackedIds ( three: THREE.InstancedMesh, diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts index 5c0915669..7a3022778 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -1,3 +1,10 @@ +/** + * Core VIM parsing entry point. Loads a VIM file (BFast container) and produces + * a Vim object with G3d geometry, BIM document, element mapping, and mesh factory. + * The Vim is created WITHOUT geometry — call vim.loadAll() or vim.loadSubset() + * separately to build Three.js meshes. + */ + import { createVimSettings, VimPartialSettings } from '../vimSettings' import { Vim } from '../vim' import { Scene } from '../scene' @@ -47,6 +54,14 @@ export class LoadRequest extends BaseLoadRequest { } } + /** + * Parses a VIM file into a Vim object. Steps: + * 1. Parse G3d geometry from the BFast 'geometry' buffer + * 2. Parse BIM document (VimDocument) from the BFast + * 3. Build ElementMapping (instance → element index) needed for GPU picking + * 4. Create Scene and VimMeshFactory (no geometry built yet) + * 5. Return Vim — caller must invoke loadAll()/loadSubset() to build meshes + */ private async loadFromVim ( bfast: BFast, settings: VimPartialSettings, @@ -61,21 +76,22 @@ export class LoadRequest extends BaseLoadRequest { } } - // Fetch g3d data + // Step 1: Parse G3d geometry const geometry = await bfast.getBfast('geometry') const g3d = await G3d.createFromBfast(geometry) const materials = new G3dMaterial(g3d.materialColors) - // Create mapping (needed by factory for element index attributes) + // Step 2-3: Parse BIM document and build instance → element mapping const doc = await VimDocument.createFromBfast(bfast) const mapping = await ElementMapping.fromG3d(g3d, doc) - // Create scene and factory WITH mapping + // Step 4: Create scene and factory (factory needs mapping for GPU picking IDs) const scene = new Scene(fullSettings.matrix) const factory = new VimMeshFactory(g3d, materials, scene, mapping, vimIndex) const header = await requestHeader(bfast) + // Step 5: Create Vim — geometry will be built later via loadAll()/loadSubset() const vim = new Vim( header, doc, diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts index dd91f0cf0..73e2dbbc5 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts @@ -2,42 +2,47 @@ * @module vim-loader */ -import { InsertableMesh } from './insertableMesh' +/** + * Orchestrator that routes G3dSubset instances to the appropriate mesh factory: + * - Meshes with <=5 instances → InsertableMeshFactory (merged, geometry duplicated per instance) + * - Meshes with >5 instances → InstancedMeshFactory (GPU instanced, geometry shared) + * + * Merged meshes are further chunked at 4M indices to keep buffer sizes manageable. + */ + import { Scene } from '../scene' -import { G3dMaterial, G3d, MeshSection } from 'vim-format' +import { G3dMaterial, G3d } from 'vim-format' +import { InsertableMeshFactory } from './insertableMeshFactory' import { InstancedMeshFactory } from './instancedMeshFactory' import { G3dSubset } from './g3dSubset' import { ElementMapping } from '../elementMapping' -/** - * Mesh factory to load a standard vim using the progressive pipeline. - */ export class VimMeshFactory { readonly g3d: G3d - private _materials: G3dMaterial + private _insertableFactory: InsertableMeshFactory private _instancedFactory: InstancedMeshFactory private _scene: Scene - private _mapping: ElementMapping - private _vimIndex: number constructor (g3d: G3d, materials: G3dMaterial, scene: Scene, mapping: ElementMapping, vimIndex: number = 0) { this.g3d = g3d - this._materials = materials this._scene = scene - this._mapping = mapping - this._vimIndex = vimIndex + this._insertableFactory = new InsertableMeshFactory(materials, mapping, vimIndex) this._instancedFactory = new InstancedMeshFactory(mapping, vimIndex) } /** - * Adds all instances from subset to the scene + * Adds all instances from subset to the scene. + * Decision logic: + * - <=5 instances per mesh → merged (geometry duplicated, chunked at 4M indices) + * - >5 instances per mesh → GPU instanced (geometry shared, one mesh per unique geometry) */ public add (subset: G3dSubset) { const uniques = subset.filterByCount((count) => count <= 5) const nonUniques = subset.filterByCount((count) => count > 5) - // Create and add meshes to scene + // Instanced meshes first (one Three.js InstancedMesh per unique geometry) this.addInstancedMeshes(this._scene, nonUniques) + // Merged meshes chunked at 4M indices to keep buffer sizes manageable const chunks = uniques.chunks(4_000_000) for(const chunk of chunks) { this.addMergedMesh(this._scene, chunk) @@ -45,27 +50,8 @@ export class VimMeshFactory { } private addMergedMesh (scene: Scene, subset: G3dSubset) { - const opaque = this.createMergedMesh(subset, 'opaque', false) - const transparents = this.createMergedMesh(subset, 'transparent', true) - scene.addMesh(opaque) - scene.addMesh(transparents) - } - - private createMergedMesh ( - subset: G3dSubset, - section: MeshSection, - transparent: boolean - ) { - const offsets = subset.getOffsets(section) - const opaque = new InsertableMesh(offsets, this._materials, transparent, this._mapping, this._vimIndex) - - const count = subset.getMeshCount() - for (let m = 0; m < count; m++) { - opaque.insertFromVim(this.g3d, m) - } - - opaque.update() - return opaque + scene.addMesh(this._insertableFactory.createOpaqueFromVim(this.g3d, subset)) + scene.addMesh(this._insertableFactory.createTransparentFromVim(this.g3d, subset)) } private addInstancedMeshes (scene: Scene, subset: G3dSubset) { diff --git a/src/vim-web/core-viewers/webgl/loader/scene.ts b/src/vim-web/core-viewers/webgl/loader/scene.ts index 9636d773d..4207cbda0 100644 --- a/src/vim-web/core-viewers/webgl/loader/scene.ts +++ b/src/vim-web/core-viewers/webgl/loader/scene.ts @@ -144,6 +144,11 @@ export class Scene { this.meshes.forEach((m) => (m.vim = value)) } + /** + * Registers a submesh in the instance → submesh map. + * If a Vim is attached, also wires the submesh to its Element3D + * so that visibility/color/outline changes propagate to the right geometry. + */ addSubmesh (submesh: Submesh) { const meshes = this._instanceToMeshes.get(submesh.instance) ?? [] meshes.push(submesh) @@ -156,10 +161,12 @@ export class Scene { } /** - * Add an instanced mesh to the Scene and recomputes fields as needed. - * @param mesh Is expected to have: - * userData.instances = number[] (indices of the g3d instances that went into creating the mesh) - * userData.boxes = THREE.Box3[] (bounding box of each instance) + * Adds a mesh to the scene. Wiring sequence: + * 1. Add Three.js mesh to renderer + * 2. Apply scene transform matrix (position/rotation/scale from VimSettings) + * 3. Expand scene bounding box + * 4. Register all submeshes (maps instance → submesh, wires to Element3D) + * 5. Apply current material override if any */ addMesh (mesh: InsertableMesh | InstancedMesh) { this.renderer?.add(mesh.mesh) diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index 7e70e5983..25921cc9e 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -219,10 +219,15 @@ export class Vim implements IVim { } /** - * Asynchronously loads geometry for the specified subset. + * Core progressive loading method. Steps: + * 1. Exclude already-loaded instances via subset.except() to avoid duplicates + * 2. Record new instances in _loadedInstances set + * 3. Delegate to VimMeshFactory.add() which splits into merged/instanced + * 4. Dispatch onUpdate signal (consumed by UI for loading progress) * @param {G3dSubset} subset - The subset to load resources for. */ async loadSubset (subset: G3dSubset) { + // Exclude instances that have already been loaded subset = subset.except('instance', this._loadedInstances) const count = subset.getInstanceCount() for (let i = 0; i < count; i++) { @@ -233,6 +238,7 @@ export class Vim implements IVim { console.log('Empty subset. Ignoring') return } + // Build meshes and add to scene this._factory.add(subset) this._onUpdate.dispatch() } From 6c09fdf50a5ab5e45e53d0091886f3b10e0664a1 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 9 Feb 2026 15:29:07 -0500 Subject: [PATCH 42/94] documentation --- CLAUDE.md | 88 +++++++++++++++++++++++++---- IMPROVEMENTS.md | 147 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 133 +++++++++++++++++++++++++++---------------- 3 files changed, 310 insertions(+), 58 deletions(-) create mode 100644 IMPROVEMENTS.md diff --git a/CLAUDE.md b/CLAUDE.md index 0d4063d27..097264f7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,8 +37,23 @@ React-based 3D viewers for VIM files with BIM (Building Information Modeling) su | Picking Material | `src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts` | | InsertableGeometry | `src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts` | | InstancedMeshFactory | `src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts` | -| VimMeshFactory | `src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts` | +| VimMeshFactory | `src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts` | | VimSettings | `src/vim-web/core-viewers/webgl/loader/vimSettings.ts` | +| Mesh types (Submesh) | `src/vim-web/core-viewers/webgl/loader/mesh.ts` | +| Scene | `src/vim-web/core-viewers/webgl/loader/scene.ts` | +| Raycaster (CPU) | `src/vim-web/core-viewers/webgl/viewer/raycaster.ts` | +| SubsetBuilder | `src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts` | +| InsertableMesh | `src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts` | +| InstancedMesh | `src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts` | +| InsertableMeshFactory | `src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts` | +| ComponentLoader | `src/vim-web/react-viewers/webgl/loading.ts` | +| Core LoadRequest | `src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts` | +| G3dSubset | `src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts` | +| G3dMeshOffsets | `src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts` | +| ElementMapping | `src/vim-web/core-viewers/webgl/loader/elementMapping.ts` | +| Vim | `src/vim-web/core-viewers/webgl/loader/vim.ts` | +| RenderingComposer | `src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts` | +| Renderer | `src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts` | ### Import Pattern @@ -76,6 +91,10 @@ VIM.THREE // Three.js re-export src/vim-web/ ├── core-viewers/ # Framework-agnostic (no React) │ ├── webgl/ # Local Three.js rendering +│ │ ├── loader/ # Mesh building, scene, VIM data model +│ │ │ ├── progressive/ # Geometry loading & mesh construction +│ │ │ └── materials/ # Shader materials, applyMaterial() helper +│ │ └── viewer/ # Camera, raycaster, rendering, gizmos │ ├── ultra/ # RPC client for streaming server │ └── shared/ # Common interfaces (IVim, Selection, Input) └── react-viewers/ # React UI layer @@ -489,20 +508,65 @@ npm run documentation # TypeDoc ## Architecture Details +### Loading Pipeline (WebGL) + +Full call chain from `viewer.load()` to rendered scene: + +``` +viewer.load(url) + → ComponentLoader.load() — allocates vimIndex (0-255) + → Core LoadRequest — parses BFast → G3d + VimDocument + ElementMapping + → Creates Vim (no geometry yet) + → initVim() — viewer.add(vim), vim.loadAll() + → Vim.loadSubset(fullSet) + → VimMeshFactory.add(subset) — splits merged vs instanced + → Scene.addMesh() → addSubmesh() → Element3D._addMesh() +``` + +**Key steps:** +1. `ComponentLoader` allocates a `vimIndex` (0-255) and creates a `LoadRequest` +2. `LoadRequest.loadFromVim()` parses the BFast container: fetches G3d geometry, creates `G3dMaterial`, parses `VimDocument` (BIM data), builds `ElementMapping` (instance-to-element map), creates `Scene` and `VimMeshFactory` +3. `Vim` is constructed with the factory but **no geometry yet** — geometry is loaded separately via `loadAll()`/`loadSubset()` +4. `Vim.loadAll()` creates a `G3dSubset` of all instances and delegates to `loadSubset()` +5. `VimMeshFactory.add()` splits the subset: ≤5 instances → `InsertableMeshFactory` (merged, chunked), >5 → `InstancedMeshFactory` (GPU instanced) +6. `Scene.addMesh()` adds the Three.js mesh to the renderer, applies the scene transform matrix, iterates submeshes, and wires each to its `Element3D` via `addSubmesh()` + +**`load()` vs `open()`:** Both parse the VIM file, but only `load()` (via `ComponentLoader`) triggers `vim.loadAll()` to build geometry. Direct `LoadRequest` usage creates a Vim with no meshes until `loadSubset()` is called. + +### Progressive Loading + +- `G3dSubset`: A filtered view of G3d instances, grouped by mesh. Supports further filtering by instance count (`filterByCount`), exclusion (`except`), and chunking (`chunks`) +- `Vim.loadSubset()`: The core progressive loading method — tracks `_loadedInstances` Set, calls `subset.except('instance', loaded)` to skip already-loaded instances, then delegates to `VimMeshFactory.add()` and dispatches `onUpdate` +- `Vim.loadFilter()`: Convenience method that creates a filtered subset and calls `loadSubset()` +- `G3dSubset.chunks(count)`: Splits a subset into smaller subsets by **index count** threshold (not vertex count) + ### Rendering Pipeline (WebGL) +Multi-pass compositor: ``` -Main Scene (MSAA) → Selection Mask → Outline Pass → FXAA → Merge → Screen +Scene (MSAA) → Selection Mask (mask material) → Outline Pass (depth edge detection) → FXAA → Merge → Screen ``` -- On-demand rendering: `renderer.needsUpdate = true` +- On-demand rendering: `renderer.needsUpdate` flag is set by camera movements, selection changes, or visibility changes, and cleared after each frame - Key files: `rendering/renderer.ts`, `renderingComposer.ts` ### Mesh Building (WebGL) -- **≤5 instances**: Merged into `InsertableMesh` (chunks at 4M vertices) -- **>5 instances**: GPU instanced via `InstancedMesh` -- Key file: `loader/progressive/legacyMeshFactory.ts` +- **≤5 instances**: Merged into `InsertableMesh` via `InsertableMeshFactory` (chunks at 4M **indices**) +- **>5 instances**: GPU instanced via `InstancedMesh` via `InstancedMeshFactory` +- Key file: `loader/progressive/vimMeshFactory.ts` + +**Mesh Type Hierarchy:** +- `Scene.meshes: (InsertableMesh | InstancedMesh)[]` — no abstract `Mesh` wrapper +- `Submesh = InsertableSubmesh | InstancedSubmesh` — polymorphic submesh used by Element3D +- `MergedSubmesh = InsertableSubmesh` — type alias (no `StandardSubmesh`) +- `SimpleInstanceSubmesh` — used only by gizmo markers +- Both `InsertableMesh` and `InstancedMesh` delegate material application to the shared `applyMaterial()` helper in `materials/materials.ts` + +**Subset Loading:** +- `VimSubsetBuilder` (in `progressive/subsetBuilder.ts`) — concrete class, no interface +- Owns a `VimMeshFactory`, dispatches `onUpdate` signal consumed by `Vim.onLoadingUpdate` +- `Vim` depends directly on `VimSubsetBuilder` ### GPU Picking (WebGL) @@ -563,7 +627,6 @@ To add a new attribute to the GPU picker output: 4. **Propagate through factory chain:** - `VimSettings` → `open.ts` → `VimMeshFactory` → `InsertableMesh`/`InstancedMeshFactory` - - For vimx: `VimSettings` → `open.ts` → `VimxSubsetBuilder` → `SubsetRequest` 5. **Update `gpuPicker.ts`** - Read new channel: ```typescript @@ -573,13 +636,18 @@ To add a new attribute to the GPU picker output: **Vim Index Flow:** ``` VimSettings.vimIndex (set by loader based on viewer.vims.length) - → open.ts (loadFromVim / loadFromVimX) - → VimMeshFactory / VimxSubsetBuilder - → InsertableMesh / InstancedMeshFactory / SubsetRequest + → loadRequest.ts (loadFromVim) + → VimMeshFactory + → InsertableMesh / InstancedMeshFactory → InsertableGeometry (per-vertex) / InstancedBufferAttribute (per-instance) → pickingMaterial shader → gpuPicker.pick() ``` +**CPU Raycaster** (`viewer/raycaster.ts`): +- Fallback raycaster using Three.js intersection tests +- Reads `hit.object.userData.vim` as `InsertableMesh | InstancedMesh` +- Discriminates via `mesh.merged` to call `getSubmeshFromFace()` or `getSubMesh()` + ### Ultra RPC Stack ``` diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 000000000..981b1898b --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,147 @@ +# WebGL Viewer Improvement Suggestions + +## Context + +After a thorough exploration of the codebase, this document captures concrete improvement suggestions for the WebGL viewer across two axes: **API quality** (internal and external) and **performance**. Each item references specific files and line numbers. + +--- + +## API IMPROVEMENTS + +### External API + +#### 4. Camera getters expose mutable internal state +**File:** `camera.ts:220-221` +```typescript +get position () { return this.camPerspective.camera.position } +``` +Consumers can accidentally corrupt camera state by mutating the returned `Vector3`. Same for `target` and `matrix`. Should return clones or readonly views. + +#### 5. Element3D.visible setter has unconditional side effect +**File:** `element3d.ts:106-117` +The setter always iterates meshes to set `mesh.visible = true` even when the attribute didn't change. The `if(value)` block runs outside the change-detection `if`. Should be inside it. + +#### 6. onLoadingUpdate carries no payload +**File:** `vim.ts:236-237` +```typescript +this._factory.add(subset) +this._onUpdate.dispatch() // no progress info, no "started"/"finished" distinction +``` +Consumers can't track loading progress or know what was loaded. Consider dispatching `{ loaded: number, total: number }` or at minimum the subset that was just loaded. + +#### 7. Selection.enabled has no change event +**File:** `selection.ts:27` +```typescript +public enabled = true; // plain boolean, no signal +``` +Unlike all other state which uses signals, toggling selection enabled/disabled is invisible to listeners. Should be a `StateRef` or at minimum fire an event. + +#### 8. Missing batch element queries on Vim +**File:** `vim.ts:135-184` +Only single-element lookups exist (`getElement`, `getElementFromIndex`). No `getElementsFromIndices(indices[])` for batch lookups. Common pattern is looping `getElementFromIndex` hundreds of times. + +#### 9. Missing Selection utility methods +**File:** `selection.ts` +No `first()`, `filter(predicate)`, or `forEach(callback)`. Every consumer does `selection.getAll().filter(...)` which allocates a new array. + +### Internal API + +#### 10. VimMeshFactory creates empty transparent meshes unconditionally +**File:** `vimMeshFactory.ts:52-55` +```typescript +scene.addMesh(this._insertableFactory.createOpaqueFromVim(this.g3d, subset)) +scene.addMesh(this._insertableFactory.createTransparentFromVim(this.g3d, subset)) +``` +Both are always created even if the subset has zero transparent geometry. Creates empty GPU buffers and draw calls. + +#### 11. G3dSubset constructor rebuilds Maps on every creation +**File:** `g3dSubset.ts:44-61` +Every `new G3dSubset()` builds a `Map` and iterates all instances. This constructor runs multiple times during loading: once for the full set, then for `filterByCount` (x2), then for `chunks` (xN), then for `except`. Each time rebuilds the map. + +#### 12. InstancedMesh eagerly computes ALL per-instance bounding boxes +**File:** `instancedMesh.ts:31-33` +```typescript +this.boxes = this.computeBoundingBoxes() // N Box3.clone().applyMatrix4() +``` +For a mesh with 1000 instances, this allocates 1000 `Box3` objects upfront. Many are never needed (e.g., if only a few are selected). + +#### 13. Scene uses Map for instance->submesh lookup +**File:** `scene.ts:46` +```typescript +private _instanceToMeshes: Map = new Map() +``` +If instance indices are dense (0..N), a flat array would give O(1) lookups vs Map's hash overhead. Worth profiling on large models. + +--- + +## PERFORMANCE IMPROVEMENTS + +### Loading / Mesh Building + +#### 14. G3dSubset.chunks() uses spread operator in loop +**File:** `g3dSubset.ts:78` +```typescript +currentInstances.push(...instances) // spread creates temp array each iteration +``` +For 100 meshes with 10 instances each, allocates 100 temporary arrays. Use a regular loop or `Array.prototype.push.apply`. + +#### 15. InsertableGeometry recomputes full bounding box on every update +**File:** `insertableGeometry.ts:263-267` +```typescript +this.geometry.computeBoundingBox() // iterates ALL vertices +this.geometry.computeBoundingSphere() // iterates ALL vertices again +``` +During progressive loading, each update iterates the entire 4M-vertex buffer even though only a subset was added. Should use incremental bounds (expand existing box with new submesh boxes). + +### GPU / Rendering + +#### 16. GPU picker creates DataView on every pick +**File:** `gpuPicker.ts:247` +```typescript +const dataView = new DataView(this._readBuffer.buffer) +``` +Should be pre-allocated as an instance field since `_readBuffer` never changes. + +#### 17. GPU picker allocates Vector3s in reconstructWorldPosition +**File:** `gpuPicker.ts:312-334` +```typescript +const rayEnd = new THREE.Vector3(ndcX, ndcY, 1).unproject(camera) +const cameraDir = new THREE.Vector3() +const worldPos = camera.position.clone().add(rayDir.clone().multiplyScalar(t)) +``` +Multiple temporary Vector3 allocations per pick. Should use pre-allocated scratch vectors. + +#### 18. Element3D._addMesh uses findIndex for duplicate check +**File:** `element3d.ts:253` +```typescript +if (this._meshes.findIndex((m) => m.equals(mesh)) < 0) +``` +O(n) linear scan. For elements with many submeshes this adds up. A Set or early-exit would be cheaper. + +--- + +## SUGGESTED PRIORITY ORDER + +**Immediate (bugs):** +1. Fix `removeOutline` missing parentheses (element3d.ts:82) +2. Set `debug = false` in gpuPicker (gpuPicker.ts:126) +3. Fix double `getDelta()` in renderingComposer (renderingComposer.ts:230,235) + +**High (tangible user/developer impact):** +4. Camera getters return clones +5. Skip empty transparent mesh creation in VimMeshFactory +6. Lazy bounding boxes in InstancedMesh +7. Incremental bounding box in InsertableGeometry.update() +8. Add payload to onLoadingUpdate signal + +**Medium (API polish):** +9. Fix visible setter side effect scoping +10. Add batch element queries to Vim +11. Add Selection utility methods (first, filter) +12. Selection.enabled change events + +**Lower (micro-optimizations, profile first):** +13. Pre-allocate DataView and scratch Vector3s in gpuPicker +14. Replace spread operator in G3dSubset.chunks() +15. Reduce G3dSubset constructor overhead +16. Array-based instance->submesh lookup in Scene diff --git a/README.md b/README.md index 83a0ff346..3e3fb2d11 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,112 @@ # VIM Web -## Live Demo +React-based 3D viewers for VIM files with BIM (Building Information Modeling) support. VIM files are optimized 3D building models that contain both geometry and rich BIM metadata (elements, properties, categories, etc.). -Visit our [Demo page](https://vimaec.github.io/vim-web-demo) +## Getting Started -### Documentation +```bash +npm install +npm run dev # Dev server at localhost:5173 +npm run build # Production build +npm run eslint # Lint +npm run documentation # TypeDoc generation +``` -Explore the full [API Documentation](https://vimaec.github.io/vim-web). +## Architecture Overview -### Package -https://www.npmjs.com/package/vim-web +### Dual Viewer System +| Viewer | Use Case | Rendering | +|--------|----------|-----------| +| **WebGL** | Small-medium models | Local Three.js rendering | +| **Ultra** | Large models | Server-side streaming via WebSocket RPC | -## Overview +### Layer Separation -The **VIM-Web** repository consists of four primary components, divided into two layers: +``` +src/vim-web/ +├── core-viewers/ # Framework-agnostic (no React) +│ ├── webgl/ # Local Three.js rendering +│ │ ├── loader/ # VIM parsing, mesh building, scene, data model +│ │ │ ├── progressive/ # Geometry loading & mesh construction +│ │ │ └── materials/ # Shader materials +│ │ └── viewer/ # Camera, raycaster, rendering, gizmos +│ ├── ultra/ # RPC client for streaming server +│ └── shared/ # Common interfaces (IVim, Selection, Input) +└── react-viewers/ # React UI layer + ├── webgl/ # Full UI (BIM tree, context menu, gizmos) + ├── ultra/ # Minimal UI + └── helpers/ # StateRef, hooks, utilities +``` -### Core Viewers -- **WebGL Core Viewer:** A WebGL-based viewer for the VIM format. Includes features like outlines, ghosting, and sectioning, but without UI components. -- **Ultra Core Viewer:** A high-performance viewer for the VIM Ultra Render Server, optimized for scale and speed. +## Loading Pipeline -### React Viewers -- **WebGL Viewer:** A React-based wrapper for the WebGL viewer, providing interactive UI elements and a BIM explorer. -- **Ultra Viewer:** A React-based wrapper for the Ultra viewer, featuring a UI for real-time interactions. +High-level call chain from URL to rendered scene: -## VIM Format +``` +viewer.load(url) + → ComponentLoader.load() — allocates vimIndex (0-255) + → Core LoadRequest — parses BFast → G3d + VimDocument + ElementMapping + → Creates Vim (no geometry yet) + → initVim() — viewer.add(vim), vim.loadAll() + → Vim.loadSubset(fullSet) + → VimMeshFactory.add(subset) — splits merged vs instanced + → Scene.addMesh() → addSubmesh() → Element3D._addMesh() +``` -The **VIM** file format is a high-performance 3D scene format that supports rich BIM data. It can also be extended to accommodate other relational and non-relational datasets. Unlike **IFC**, the VIM format is pre-tessellated, allowing for rapid loading and rendering. +1. **ComponentLoader** (`react-viewers/webgl/loading.ts`) allocates a `vimIndex` (0-255) and creates a `LoadRequest` +2. **LoadRequest** (`progressive/loadRequest.ts`) parses the VIM file (BFast container) into G3d geometry, VimDocument (BIM data), and ElementMapping +3. **Vim** is constructed with a `VimMeshFactory` but no geometry yet +4. **Vim.loadAll()** creates a full G3dSubset and calls `loadSubset()` +5. **VimMeshFactory** routes subsets: meshes with <=5 instances go to `InsertableMeshFactory` (merged), >5 go to `InstancedMeshFactory` (GPU instanced) +6. **Scene.addMesh()** adds Three.js meshes to the renderer, applies transforms, and wires submeshes to Element3D objects -Learn more about the VIM format here: [VIM Format Repository](https://github.com/vimaec/vim-format) -). +## Rendering Pipeline -### Built With -- [VIM WebGL Viewer](https://github.com/vimaec/vim-webgl-viewer) -- [React.js](https://reactjs.org/) +Multi-pass compositor (WebGL): -## Getting Started +``` +Scene (MSAA) → Selection Mask → Outline Pass (edge detection) → FXAA → Merge → Screen +``` + +Rendering is on-demand: the `needsUpdate` flag is set by camera movements, selection changes, or visibility changes, and cleared after each frame. Key files: `rendering/renderer.ts`, `renderingComposer.ts`. + +## GPU Picking + +Clicks resolve to BIM elements via a custom shader that renders to a Float32 render target: + +- **R** = packed ID (`vimIndex << 24 | elementIndex`) — supports 256 vims x 16M elements +- **G** = depth along camera direction (0 = miss) +- **B/A** = surface normal (x, y); z is reconstructed -Follow these steps to get started with the project: +IDs are pre-packed during mesh building as per-vertex attributes (merged meshes) or per-instance attributes (instanced meshes). See `gpuPicker.ts` and `pickingMaterial.ts`. -1. Clone the repository. -2. Open the project in **VS Code**. -3. Install the dependencies: `npm install`. -4. Start the development server: `npm run dev`. +## Mesh Building Strategy -> **Note:** Ensure you have a recent version of **Node.js** installed, as required by Vite. +Two strategies based on instance count per unique mesh: -## Repository Organization +| Strategy | Condition | Implementation | Chunking | +|----------|-----------|----------------|----------| +| **Merged** | <=5 instances | `InsertableMeshFactory` → `InsertableMesh` | Chunks at 4M indices | +| **Instanced** | >5 instances | `InstancedMeshFactory` → `InstancedMesh` | One mesh per unique geometry | -- **`./docs`:** Root folder for GitHub Pages, built using the `build:website` script. -- **`./dist`:** Contains the built package for npm, created with the `build:libs` script. -- **`./src/pages`:** Source code for the demo pages published on GitHub Pages. -- **`./src/vim-web`:** Source code for building and publishing the vim-web npm package. -- **`./src/core-viewers/webgl`:** Source code for the WebGL core viewer. Based on [vim-webgl-viewer](https://github.com/vimaec/vim-webgl-viewer). -- **`./src/core-viewers/ultra`:** Source code for the Ultra core viewer. -- **`./src/react-viewers/webgl`:** Source code for the WebGL React component. Based on [vim-webgl-component](https://github.com/vimaec/vim-webgl-component). -- **`./src/react-viewers/ultra`:** Source code for the Ultra React component. +**Merged meshes** duplicate geometry per instance with baked transforms, enabling per-vertex attributes for GPU picking. **Instanced meshes** share geometry across instances using Three.js `InstancedMesh` with per-instance attributes. -## License +Progressive loading is supported via `Vim.loadSubset()` which tracks loaded instances and avoids duplicates using `G3dSubset.except()`. -Distributed under the **MIT License**. See `LICENSE.txt` for more details. +## Key Concepts -## Contact +- **Element3D**: Primary object representing a BIM element. Controls visibility, color, outline, and provides access to BIM metadata. +- **Selection**: Observable selection state with multi-select, events, and bounding box queries. +- **Camera**: Fluent API with `snap()` (instant) and `lerp(duration)` (animated) for framing, orbiting, and zooming. +- **Gizmos**: Section box, measurement tool, and markers. +- **StateRef/ActionRef**: Observable state and action system used in the React layer for customization. -- **Simon Roberge** - [simon.roberge@vimaec.com](mailto:simon.roberge@vimaec.com) -- **Martin Ashton** - [martin.ashton@vimaec.com](mailto:martin.ashton@vimaec.com) +## Customization -## Acknowledgments +The React viewer exposes customization points for: +- **Control bar**: Add/replace toolbar buttons +- **Context menu**: Add custom menu items +- **BIM info panel**: Modify displayed data or add custom sections -Special thanks to these packages and more: -- [react-complex-tree](https://github.com/lukasbach/react-complex-tree) -- [re-resizable](https://github.com/bokuweb/re-resizable) -- [react-tooltip](https://github.com/ReactTooltip/react-tooltip) -- [Strongly Typed Events](https://github.com/KeesCBakker/Strongly-Typed-Events-for-TypeScript#readme) +See [CLAUDE.md](./CLAUDE.md) for detailed API examples and implementation reference. From bdf75fbb063ad5a987a0423266e532aeefc7fdab Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Mon, 9 Feb 2026 16:45:53 -0500 Subject: [PATCH 43/94] kinda works --- .../webgl/viewer/camera/camera.ts | 13 ++ .../webgl/viewer/camera/cameraInterface.ts | 6 + .../webgl/viewer/camera/cameraMovement.ts | 3 +- .../webgl/viewer/camera/cameraMovementLerp.ts | 8 +- .../webgl/viewer/camera/cameraMovementSnap.ts | 149 ++++++++++++------ .../core-viewers/webgl/viewer/inputAdapter.ts | 2 +- 6 files changed, 129 insertions(+), 52 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index 8776ce6dd..b8765a7f5 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -34,6 +34,7 @@ export class Camera implements ICamera { // orbit private _orthographic: boolean = false private _target = new THREE.Vector3() + private _screenTarget = new THREE.Vector2(0.5, 0.5) // updates private _lastPosition = new THREE.Vector3() @@ -271,6 +272,18 @@ export class Camera implements ICamera { return this._target } + /** + * The screen position where the orbit target appears. + * (0,0) is top-left, (1,1) is bottom-right, (0.5, 0.5) is center. + */ + get screenTarget () { + return this._screenTarget + } + + set screenTarget (value: THREE.Vector2) { + this._screenTarget.copy(value) + } + private applySettings (settings: ViewerSettings) { // Camera diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts index fca3c5aff..1e265466a 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts @@ -103,6 +103,12 @@ export interface ICamera { */ get target(): THREE.Vector3; + /** + * The screen position where the orbit target appears. + * (0,0) is top-left, (1,1) is bottom-right, (0.5, 0.5) is center. + */ + screenTarget: THREE.Vector2; + /** * The distance from the camera to the target. */ diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 4651400bc..45b8532eb 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -78,7 +78,7 @@ export abstract class CameraMovement { * @param {number} amount - The zoom factor (e.g., 0.5 to move closer, 2 to move farther). * @param {THREE.Vector3} worldPoint - The world position to zoom toward. */ - abstract zoomTowards(amount: number, worldPoint: THREE.Vector3): void + abstract zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void /** * Sets the distance between the camera and its target to the specified value. @@ -196,6 +196,7 @@ export abstract class CameraMovement { const pos = direction.multiplyScalar(-safeDist).add(sphere.center) + this._camera.screenTarget.set(0.5, 0.5) this.set(pos, sphere.center) } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 6b3c11a53..a50f0ae4e 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -113,7 +113,7 @@ export class CameraLerp extends CameraMovement { } } - zoomTowards(amount: number, worldPoint: THREE.Vector3): void { + zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void { const startPos = this._camera.position.clone() // Direction from world point to camera @@ -127,6 +127,11 @@ export class CameraLerp extends CameraMovement { // Set orbit target immediately (not animated) this._camera.target.copy(worldPoint) + // Update screen target so orbit pivot stays at cursor position + if (screenPoint) { + this._camera.screenTarget.copy(screenPoint) + } + this.onProgress = (progress) => { // Only lerp position, orientation stays unchanged this._camera.position.copy(startPos).lerp(endPos, progress) @@ -182,6 +187,7 @@ export class CameraLerp extends CameraMovement { const lookTarget = this._camera.position.clone().add(currentForward) this._camera.camPerspective.camera.up.set(0, 0, 1) this._camera.camPerspective.camera.lookAt(lookTarget) + this._movement.applyScreenTargetOffset() } } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index a3a6b6fb6..3faddf52f 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -49,48 +49,40 @@ export class CameraMovementSnap extends CameraMovement { orbit (angle: THREE.Vector2): void { const locked = angle.clone().multiply(this._camera.allowedRotation) - const worldUp = new THREE.Vector3(0, 0, 1) - const forward = this._camera.forward.clone() - - // Get horizontal right axis (perpendicular to world up and view direction) - let right = new THREE.Vector3().crossVectors(worldUp, forward) - if (right.lengthSq() < 0.001) { - // Looking straight up/down - use camera's right projected to horizontal - right.set(1, 0, 0).applyQuaternion(this._camera.quaternion) - right.z = 0 - } - right.normalize() - - // Azimuth: rotate around world Z (no roll) - const azimuthQuat = new THREE.Quaternion().setFromAxisAngle( - worldUp, - (locked.y * Math.PI) / 180 - ) - - // Elevation: rotate around horizontal right axis (no roll) - const elevationQuat = new THREE.Quaternion().setFromAxisAngle( - right, - (-locked.x * Math.PI) / 180 - ) - - // Combined rotation (apply azimuth first, then elevation) - const orbitQuat = new THREE.Quaternion().multiplyQuaternions(elevationQuat, azimuthQuat) - - // Rotate position offset around target + // Convert current position to spherical coordinates const offset = this._camera.position.clone().sub(this._camera.target) - offset.applyQuaternion(orbitQuat) - const newPos = this._camera.target.clone().add(offset) - - // Rotate forward direction by same amount - const newForward = forward.applyQuaternion(orbitQuat) + const radius = offset.length() + + // Current spherical angles + let theta = Math.atan2(offset.y, offset.x) // azimuth around Z + let phi = Math.acos(THREE.MathUtils.clamp(offset.z / radius, -1, 1)) // angle from up (0 to PI) + + // Apply rotation deltas + theta += (locked.y * Math.PI) / 180 + phi += (locked.x * Math.PI) / 180 + + // Clamp phi to prevent gimbal lock + const minAngle = THREE.MathUtils.degToRad(0.5) + const maxAngle = THREE.MathUtils.degToRad(179.5) + phi = THREE.MathUtils.clamp(phi, minAngle, maxAngle) + + // Convert spherical back to Cartesian + const sinPhi = Math.sin(phi) + const newOffset = new THREE.Vector3( + radius * sinPhi * Math.cos(theta), + radius * sinPhi * Math.sin(theta), + radius * Math.cos(phi) + ) + const newPos = this._camera.target.clone().add(newOffset) - // Set position (with clamping and locking) - this.set(newPos, this._camera.target, false) + // Apply position with axis locking + const lockedPos = this.lockVector(newPos, this._camera.position) + this._camera.position.copy(lockedPos) - // Orient camera along new forward with Z up (removes any accumulated roll) - const lookTarget = this._camera.position.clone().add(newForward) + // Orient camera to look at target this._camera.camPerspective.camera.up.set(0, 0, 1) - this._camera.camPerspective.camera.lookAt(lookTarget) + this._camera.camPerspective.camera.lookAt(this._camera.target) + this.applyScreenTargetOffset() } override move3 (vector: THREE.Vector3): void { @@ -121,17 +113,39 @@ export class CameraMovementSnap extends CameraMovement { const maxAngle = THREE.MathUtils.degToRad(179.5); if (angle < minAngle) { - // direction is too close to straight up - // rotate 'direction' so angle becomes exactly minAngle - const axis = new THREE.Vector3().crossVectors(up, direction).normalize(); - const delta = minAngle - angle; // positive => rotate away from up - direction.applyQuaternion(new THREE.Quaternion().setFromAxisAngle(axis, delta)); + // direction is too close to straight up - clamp to minAngle + // Preserve horizontal direction (XY) while adjusting Z + const horizontalLength = Math.sqrt(direction.x * direction.x + direction.y * direction.y); + + if (horizontalLength > 0.001) { + // We have a valid horizontal direction - preserve it + const newHorizontalLength = dist * Math.sin(minAngle); + const scale = newHorizontalLength / horizontalLength; + direction.x *= scale; + direction.y *= scale; + direction.z = dist * Math.cos(minAngle); + } else { + // No horizontal direction (looking straight up) - pick arbitrary direction + direction.set(1, 0, 0).multiplyScalar(dist * Math.sin(minAngle)); + direction.z = dist * Math.cos(minAngle); + } } else if (angle > maxAngle) { - // direction is too close to straight down - // rotate 'direction' so angle becomes exactly maxAngle - const axis = new THREE.Vector3().crossVectors(up, direction).normalize(); - const delta = maxAngle - angle; // negative => rotate back toward up - direction.applyQuaternion(new THREE.Quaternion().setFromAxisAngle(axis, delta)); + // direction is too close to straight down - clamp to maxAngle + // Preserve horizontal direction (XY) while adjusting Z + const horizontalLength = Math.sqrt(direction.x * direction.x + direction.y * direction.y); + + if (horizontalLength > 0.001) { + // We have a valid horizontal direction - preserve it + const newHorizontalLength = dist * Math.sin(maxAngle); + const scale = newHorizontalLength / horizontalLength; + direction.x *= scale; + direction.y *= scale; + direction.z = dist * Math.cos(maxAngle); + } else { + // No horizontal direction (looking straight down) - pick arbitrary direction + direction.set(1, 0, 0).multiplyScalar(dist * Math.sin(maxAngle)); + direction.z = dist * Math.cos(maxAngle); + } } // 'direction' now has the same length but is clamped in angle @@ -150,10 +164,11 @@ export class CameraMovementSnap extends CameraMovement { if (lookAt) { this._camera.camPerspective.camera.up.set(0, 0, 1); this._camera.camPerspective.camera.lookAt(target); + this.applyScreenTargetOffset(); } } - zoomTowards(amount: number, worldPoint: THREE.Vector3): void { + zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void { // Direction from world point to camera const direction = this._camera.position.clone().sub(worldPoint).normalize() @@ -164,11 +179,47 @@ export class CameraMovementSnap extends CameraMovement { // New camera position const newPos = worldPoint.clone().add(direction.multiplyScalar(newDist)) + // Update screen target so orbit pivot stays at cursor position + if (screenPoint) { + this._camera.screenTarget.copy(screenPoint) + } + // Set position and update orbit target without changing orientation this.set(newPos, worldPoint, false) } + /** + * Rotates the camera so the target appears at screenTarget instead of screen center. + * Must be called after lookAt(target). + */ + applyScreenTargetOffset () { + const st = this._camera.screenTarget + if (st.x === 0.5 && st.y === 0.5) return + + const cam = this._camera.camPerspective.camera + const vFov = cam.fov * Math.PI / 180 + const tanHalfV = Math.tan(vFov / 2) + const tanHalfH = tanHalfV * cam.aspect + + // NDC position where target should appear + const nx = 2 * st.x - 1 + const ny = 1 - 2 * st.y + + // Camera-space direction that projects to (nx, ny) + const targetDir = new THREE.Vector3( + nx * tanHalfH, + ny * tanHalfV, + -1 + ).normalize() + + // Rotation from targetDir to forward (0,0,-1) + // This makes the target appear at (nx, ny) on screen + const forward = new THREE.Vector3(0, 0, -1) + const R = new THREE.Quaternion().setFromUnitVectors(targetDir, forward) + this._camera.quaternion.multiply(R) + } + private lockVector (position: THREE.Vector3, fallback: THREE.Vector3) { const x = this._camera.allowedMovement.x === 0 ? fallback.x : position.x const y = this._camera.allowedMovement.y === 0 ? fallback.y : position.y diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index 284244bb7..dc374dd66 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -74,7 +74,7 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { if (screenPos) { const result = await viewer.raycaster.raycastFromScreen(screenPos) if (result?.worldPosition) { - viewer.camera.lerp(0.75).zoomTowards(value, result.worldPosition) + viewer.camera.lerp(0.75).zoomTowards(value, result.worldPosition, screenPos) return } } From 2b2d0f7ac58cc566794198ad2a579fd072bc9366 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 10 Feb 2026 15:36:16 -0500 Subject: [PATCH 44/94] working --- .../webgl/viewer/camera/cameraMovementSnap.ts | 45 +++++++++++-------- .../core-viewers/webgl/viewer/inputAdapter.ts | 2 +- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index 3faddf52f..25b3f9aa4 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -50,7 +50,9 @@ export class CameraMovementSnap extends CameraMovement { const locked = angle.clone().multiply(this._camera.allowedRotation) // Convert current position to spherical coordinates - const offset = this._camera.position.clone().sub(this._camera.target) + const scaledForward = this._camera.forward.multiplyScalar(this._camera.orbitDistance) + const offCenter = this._camera.position.clone().add(scaledForward) + const offset = this._camera.position.clone().sub(offCenter) const radius = offset.length() // Current spherical angles @@ -79,7 +81,6 @@ export class CameraMovementSnap extends CameraMovement { const lockedPos = this.lockVector(newPos, this._camera.position) this._camera.position.copy(lockedPos) - // Orient camera to look at target this._camera.camPerspective.camera.up.set(0, 0, 1) this._camera.camPerspective.camera.lookAt(this._camera.target) this.applyScreenTargetOffset() @@ -190,7 +191,8 @@ export class CameraMovementSnap extends CameraMovement { /** - * Rotates the camera so the target appears at screenTarget instead of screen center. + * Slides the camera position on the orbit sphere so the target appears + * at screenTarget instead of screen center. Orientation is unchanged. * Must be called after lookAt(target). */ applyScreenTargetOffset () { @@ -202,22 +204,27 @@ export class CameraMovementSnap extends CameraMovement { const tanHalfV = Math.tan(vFov / 2) const tanHalfH = tanHalfV * cam.aspect - // NDC position where target should appear - const nx = 2 * st.x - 1 - const ny = 1 - 2 * st.y - - // Camera-space direction that projects to (nx, ny) - const targetDir = new THREE.Vector3( - nx * tanHalfH, - ny * tanHalfV, - -1 - ).normalize() - - // Rotation from targetDir to forward (0,0,-1) - // This makes the target appear at (nx, ny) on screen - const forward = new THREE.Vector3(0, 0, -1) - const R = new THREE.Quaternion().setFromUnitVectors(targetDir, forward) - this._camera.quaternion.multiply(R) + // Screen offset in tangent space + const sx = (2 * st.x - 1) * tanHalfH + const sy = (1 - 2 * st.y) * tanHalfV + + // Camera's local axes from the lookAt orientation + const right = new THREE.Vector3(1, 0, 0).applyQuaternion(cam.quaternion) + const up = new THREE.Vector3(0, 1, 0).applyQuaternion(cam.quaternion) + + // Offset from target to camera (on the orbit sphere) + const offset = this._camera.position.clone().sub(this._camera.target) + + // Pitch: rotate offset around right axis (up-forward plane) + const pitchQuat = new THREE.Quaternion().setFromAxisAngle(right, Math.atan(sy)) + offset.applyQuaternion(pitchQuat) + + // Yaw: rotate offset around up axis (forward-left plane) + const yawQuat = new THREE.Quaternion().setFromAxisAngle(up, -Math.atan(sx)) + offset.applyQuaternion(yawQuat) + + // Update position only — orientation stays as-is + this._camera.position.copy(this._camera.target).add(offset) } private lockVector (position: THREE.Vector3, fallback: THREE.Vector3) { diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index dc374dd66..a33874501 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -74,7 +74,7 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { if (screenPos) { const result = await viewer.raycaster.raycastFromScreen(screenPos) if (result?.worldPosition) { - viewer.camera.lerp(0.75).zoomTowards(value, result.worldPosition, screenPos) + viewer.camera.lerp(0.25).zoomTowards(value, result.worldPosition, screenPos) return } } From 4a1a59b29792c5317c3d73a2348c38e1f90c9c07 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 10 Feb 2026 15:59:02 -0500 Subject: [PATCH 45/94] working orbit gizmo --- .../webgl/viewer/camera/cameraMovementLerp.ts | 70 ++++++++----------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index a50f0ae4e..317645094 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -139,54 +139,42 @@ export class CameraLerp extends CameraMovement { } orbit (angle: THREE.Vector2): void { - const startPos = this._camera.position.clone() - const startForward = this._camera.forward.clone() const locked = angle.clone().multiply(this._camera.allowedRotation) - - const worldUp = new THREE.Vector3(0, 0, 1) - - // Get horizontal right axis - let right = new THREE.Vector3().crossVectors(worldUp, startForward) - if (right.lengthSq() < 0.001) { - right.set(1, 0, 0).applyQuaternion(this._camera.quaternion) - right.z = 0 - } - right.normalize() - - // Azimuth: rotate around world Z - const azimuthQuat = new THREE.Quaternion().setFromAxisAngle( - worldUp, - (locked.y * Math.PI) / 180 - ) - - // Elevation: rotate around horizontal right axis - const elevationQuat = new THREE.Quaternion().setFromAxisAngle( - right, - (-locked.x * Math.PI) / 180 + const radius = this._camera.orbitDistance + + // Compute offset from target using forward direction (same as snap orbit) + const startOffset = this._camera.forward.clone().negate().multiplyScalar(radius) + + // Current spherical angles + const theta0 = Math.atan2(startOffset.y, startOffset.x) + const phi0 = Math.acos(THREE.MathUtils.clamp(startOffset.z / radius, -1, 1)) + + // Apply rotation deltas + const theta1 = theta0 + (locked.y * Math.PI) / 180 + let phi1 = phi0 + (locked.x * Math.PI) / 180 + + // Clamp phi to prevent gimbal lock + const minAngle = THREE.MathUtils.degToRad(0.5) + const maxAngle = THREE.MathUtils.degToRad(179.5) + phi1 = THREE.MathUtils.clamp(phi1, minAngle, maxAngle) + + // End offset in Cartesian + const sinPhi = Math.sin(phi1) + const endOffset = new THREE.Vector3( + radius * sinPhi * Math.cos(theta1), + radius * sinPhi * Math.sin(theta1), + radius * Math.cos(phi1) ) - // Combined rotation - const orbitQuat = new THREE.Quaternion().multiplyQuaternions(elevationQuat, azimuthQuat) - - // Calculate end position - const offset = startPos.clone().sub(this._camera.target) - offset.applyQuaternion(orbitQuat) - const endPos = this._camera.target.clone().add(offset) - - // Calculate end forward direction - const endForward = startForward.clone().applyQuaternion(orbitQuat) - this.onProgress = (progress) => { - // Lerp position - this._camera.position.copy(startPos).lerp(endPos, progress) + // Interpolate offset direction on sphere + const currentOffset = startOffset.clone().lerp(endOffset, progress) + currentOffset.normalize().multiplyScalar(radius) - // Slerp forward direction - const currentForward = startForward.clone().lerp(endForward, progress).normalize() + this._camera.position.copy(this._camera.target).add(currentOffset) - // Orient camera along current forward with Z up (no roll) - const lookTarget = this._camera.position.clone().add(currentForward) this._camera.camPerspective.camera.up.set(0, 0, 1) - this._camera.camPerspective.camera.lookAt(lookTarget) + this._camera.camPerspective.camera.lookAt(this._camera.target) this._movement.applyScreenTargetOffset() } } From 046471b33edf8d0b18a98566546dbb2164291444 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 10 Feb 2026 16:20:56 -0500 Subject: [PATCH 46/94] refactored extracted sphere coord --- .../webgl/viewer/camera/cameraMovementLerp.ts | 33 +--- .../webgl/viewer/camera/cameraMovementSnap.ts | 144 +++++------------- .../webgl/viewer/camera/sphereCoord.ts | 51 +++++++ 3 files changed, 97 insertions(+), 131 deletions(-) create mode 100644 src/vim-web/core-viewers/webgl/viewer/camera/sphereCoord.ts diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 317645094..7006b2d89 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -8,6 +8,7 @@ import { Element3D } from '../../loader/element3d' import { CameraMovementSnap } from './cameraMovementSnap' import { CameraMovement } from './cameraMovement' import { CameraSaveState } from './cameraInterface' +import { SphereCoord } from './sphereCoord' @@ -62,13 +63,10 @@ export class CameraLerp extends CameraMovement { v.applyQuaternion(this._camera.quaternion) const startPos = this._camera.position.clone() const endPos = this._camera.position.clone().add(v) - const startTarget = this._camera.target.clone() - const endTarget = this._camera.target.clone().add(v) this.onProgress = (progress) => { const pos = startPos.clone().lerp(endPos, progress) - const target = startTarget.clone().lerp(endTarget, progress) - this._movement.set(pos, target, false) + this._movement.set(pos, undefined, false) } } @@ -142,32 +140,11 @@ export class CameraLerp extends CameraMovement { const locked = angle.clone().multiply(this._camera.allowedRotation) const radius = this._camera.orbitDistance - // Compute offset from target using forward direction (same as snap orbit) - const startOffset = this._camera.forward.clone().negate().multiplyScalar(radius) - - // Current spherical angles - const theta0 = Math.atan2(startOffset.y, startOffset.x) - const phi0 = Math.acos(THREE.MathUtils.clamp(startOffset.z / radius, -1, 1)) - - // Apply rotation deltas - const theta1 = theta0 + (locked.y * Math.PI) / 180 - let phi1 = phi0 + (locked.x * Math.PI) / 180 - - // Clamp phi to prevent gimbal lock - const minAngle = THREE.MathUtils.degToRad(0.5) - const maxAngle = THREE.MathUtils.degToRad(179.5) - phi1 = THREE.MathUtils.clamp(phi1, minAngle, maxAngle) - - // End offset in Cartesian - const sinPhi = Math.sin(phi1) - const endOffset = new THREE.Vector3( - radius * sinPhi * Math.cos(theta1), - radius * sinPhi * Math.sin(theta1), - radius * Math.cos(phi1) - ) + const start = SphereCoord.fromForward(this._camera.forward, radius) + const startOffset = start.toVector3() + const endOffset = start.rotate(locked.y, locked.x).toVector3() this.onProgress = (progress) => { - // Interpolate offset direction on sphere const currentOffset = startOffset.clone().lerp(endOffset, progress) currentOffset.normalize().multiplyScalar(radius) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index 25b3f9aa4..f19cc3974 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -4,6 +4,7 @@ import { CameraMovement } from './cameraMovement' import { Element3D } from '../../loader/element3d' +import { SphereCoord } from './sphereCoord' import * as THREE from 'three' @@ -49,35 +50,10 @@ export class CameraMovementSnap extends CameraMovement { orbit (angle: THREE.Vector2): void { const locked = angle.clone().multiply(this._camera.allowedRotation) - // Convert current position to spherical coordinates - const scaledForward = this._camera.forward.multiplyScalar(this._camera.orbitDistance) - const offCenter = this._camera.position.clone().add(scaledForward) - const offset = this._camera.position.clone().sub(offCenter) - const radius = offset.length() - - // Current spherical angles - let theta = Math.atan2(offset.y, offset.x) // azimuth around Z - let phi = Math.acos(THREE.MathUtils.clamp(offset.z / radius, -1, 1)) // angle from up (0 to PI) - - // Apply rotation deltas - theta += (locked.y * Math.PI) / 180 - phi += (locked.x * Math.PI) / 180 - - // Clamp phi to prevent gimbal lock - const minAngle = THREE.MathUtils.degToRad(0.5) - const maxAngle = THREE.MathUtils.degToRad(179.5) - phi = THREE.MathUtils.clamp(phi, minAngle, maxAngle) - - // Convert spherical back to Cartesian - const sinPhi = Math.sin(phi) - const newOffset = new THREE.Vector3( - radius * sinPhi * Math.cos(theta), - radius * sinPhi * Math.sin(theta), - radius * Math.cos(phi) - ) - const newPos = this._camera.target.clone().add(newOffset) + const start = SphereCoord.fromForward(this._camera.forward, this._camera.orbitDistance) + const end = start.rotate(locked.y, locked.x) + const newPos = this._camera.target.clone().add(end.toVector3()) - // Apply position with axis locking const lockedPos = this.lockVector(newPos, this._camera.position) this._camera.position.copy(lockedPos) @@ -91,82 +67,54 @@ export class CameraMovementSnap extends CameraMovement { v.applyQuaternion(this._camera.quaternion) const locked = this.lockVector(v, new THREE.Vector3()) const pos = this._camera.position.clone().add(locked) - const target = this._camera.target.clone().add(locked) - this.set(pos, target, false) + this.set(pos, undefined, false) } set(position: THREE.Vector3, target?: THREE.Vector3, lookAt: boolean = true) { - // Use the existing camera's target if none is provided target = target ?? this._camera.target; - // direction = (desired camera position) - (fixed target) const direction = new THREE.Vector3().subVectors(position, target); const dist = direction.length(); - // If camera and target coincide, skip angle clamping + // Clamp elevation to avoid gimbal lock at poles if (dist > 1e-6) { - // Angle between direction and "up" (0,0,1) in [0..PI] - const up = new THREE.Vector3(0, 0, 1); - const angle = direction.angleTo(up); - - // We'll clamp angle to the range [0.5°, 179.5°] - very close to straight up/down - const minAngle = THREE.MathUtils.degToRad(0.5); - const maxAngle = THREE.MathUtils.degToRad(179.5); - - if (angle < minAngle) { - // direction is too close to straight up - clamp to minAngle - // Preserve horizontal direction (XY) while adjusting Z - const horizontalLength = Math.sqrt(direction.x * direction.x + direction.y * direction.y); - - if (horizontalLength > 0.001) { - // We have a valid horizontal direction - preserve it - const newHorizontalLength = dist * Math.sin(minAngle); - const scale = newHorizontalLength / horizontalLength; - direction.x *= scale; - direction.y *= scale; - direction.z = dist * Math.cos(minAngle); - } else { - // No horizontal direction (looking straight up) - pick arbitrary direction - direction.set(1, 0, 0).multiplyScalar(dist * Math.sin(minAngle)); - direction.z = dist * Math.cos(minAngle); - } - } else if (angle > maxAngle) { - // direction is too close to straight down - clamp to maxAngle - // Preserve horizontal direction (XY) while adjusting Z - const horizontalLength = Math.sqrt(direction.x * direction.x + direction.y * direction.y); - - if (horizontalLength > 0.001) { - // We have a valid horizontal direction - preserve it - const newHorizontalLength = dist * Math.sin(maxAngle); - const scale = newHorizontalLength / horizontalLength; - direction.x *= scale; - direction.y *= scale; - direction.z = dist * Math.cos(maxAngle); - } else { - // No horizontal direction (looking straight down) - pick arbitrary direction - direction.set(1, 0, 0).multiplyScalar(dist * Math.sin(maxAngle)); - direction.z = dist * Math.cos(maxAngle); - } - } - - // 'direction' now has the same length but is clamped in angle - // Recompute the actual camera position - position.copy(target).add(direction); + const clamped = SphereCoord.fromVector(direction) + direction.copy(clamped.toVector3()) + position.copy(target).add(direction) } - // 2) Pass the adjusted position through your locking logic const lockedPos = this.lockVector(position, this._camera.position); this._camera.position.copy(lockedPos); - - // 3) The target remains exactly as given this._camera.target.copy(target); - // 4) Orient the camera to look at the target, with Z as up (only if lookAt is true) if (lookAt) { this._camera.camPerspective.camera.up.set(0, 0, 1); this._camera.camPerspective.camera.lookAt(target); this.applyScreenTargetOffset(); + } else { + this.updateScreenTarget(); + } + } + + /** + * Projects the orbit target onto the screen and stores the result + * in screenTarget. Called when camera moves without re-orienting + * so the next orbit reflects the target's actual screen position. + */ + private updateScreenTarget () { + const cam = this._camera.camPerspective.camera + cam.updateMatrixWorld(true) + const projected = this._camera.target.clone().project(cam) + + if (projected.z > 1) { + this._camera.screenTarget.set(0.5, 0.5) + return } + + this._camera.screenTarget.set( + THREE.MathUtils.clamp((projected.x + 1) / 2, 0, 1), + THREE.MathUtils.clamp((1 - projected.y) / 2, 0, 1) + ) } zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void { @@ -180,13 +128,13 @@ export class CameraMovementSnap extends CameraMovement { // New camera position const newPos = worldPoint.clone().add(direction.multiplyScalar(newDist)) - // Update screen target so orbit pivot stays at cursor position + // Set position and update orbit target without changing orientation + this.set(newPos, worldPoint, false) + + // Override projected screen target with exact cursor position if (screenPoint) { this._camera.screenTarget.copy(screenPoint) } - - // Set position and update orbit target without changing orientation - this.set(newPos, worldPoint, false) } @@ -208,22 +156,12 @@ export class CameraMovementSnap extends CameraMovement { const sx = (2 * st.x - 1) * tanHalfH const sy = (1 - 2 * st.y) * tanHalfV - // Camera's local axes from the lookAt orientation - const right = new THREE.Vector3(1, 0, 0).applyQuaternion(cam.quaternion) - const up = new THREE.Vector3(0, 1, 0).applyQuaternion(cam.quaternion) - - // Offset from target to camera (on the orbit sphere) - const offset = this._camera.position.clone().sub(this._camera.target) - - // Pitch: rotate offset around right axis (up-forward plane) - const pitchQuat = new THREE.Quaternion().setFromAxisAngle(right, Math.atan(sy)) - offset.applyQuaternion(pitchQuat) - - // Yaw: rotate offset around up axis (forward-left plane) - const yawQuat = new THREE.Quaternion().setFromAxisAngle(up, -Math.atan(sx)) - offset.applyQuaternion(yawQuat) + // Exact offset: in camera local space the direction from target to + // camera that places the target at (sx, sy) on screen is (-sx, -sy, 1). + const dist = this._camera.position.distanceTo(this._camera.target) + const offset = new THREE.Vector3(-sx, -sy, 1).normalize().multiplyScalar(dist) + offset.applyQuaternion(cam.quaternion) - // Update position only — orientation stays as-is this._camera.position.copy(this._camera.target).add(offset) } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/sphereCoord.ts b/src/vim-web/core-viewers/webgl/viewer/camera/sphereCoord.ts new file mode 100644 index 000000000..63bf03fa5 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/viewer/camera/sphereCoord.ts @@ -0,0 +1,51 @@ +import * as THREE from 'three' + +const MIN_PHI = THREE.MathUtils.degToRad(0.5) +const MAX_PHI = THREE.MathUtils.degToRad(179.5) + +/** + * Z-up spherical coordinate. Phi is always clamped to [0.5°, 179.5°]. + * - theta: azimuth around Z axis (radians) + * - phi: angle from +Z (0 = up, PI = down) + * - radius: distance from center + */ +export class SphereCoord { + readonly theta: number + readonly phi: number + readonly radius: number + + constructor (theta: number, phi: number, radius: number) { + this.theta = theta + this.phi = THREE.MathUtils.clamp(phi, MIN_PHI, MAX_PHI) + this.radius = radius + } + + static fromVector (v: THREE.Vector3): SphereCoord { + const radius = v.length() + if (radius < 1e-10) return new SphereCoord(0, Math.PI / 2, 0) + const theta = Math.atan2(v.y, v.x) + const phi = Math.acos(THREE.MathUtils.clamp(v.z / radius, -1, 1)) + return new SphereCoord(theta, phi, radius) + } + + static fromForward (forward: THREE.Vector3, radius: number): SphereCoord { + return SphereCoord.fromVector(forward.clone().negate().multiplyScalar(radius)) + } + + rotate (dThetaDeg: number, dPhiDeg: number): SphereCoord { + return new SphereCoord( + this.theta + (dThetaDeg * Math.PI) / 180, + this.phi + (dPhiDeg * Math.PI) / 180, + this.radius + ) + } + + toVector3 (): THREE.Vector3 { + const sinPhi = Math.sin(this.phi) + return new THREE.Vector3( + this.radius * sinPhi * Math.cos(this.theta), + this.radius * sinPhi * Math.sin(this.theta), + this.radius * Math.cos(this.phi) + ) + } +} From 9218a36eb11c78c4660dd2dd1a12578f3f2b2f75 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Tue, 10 Feb 2026 20:54:06 -0500 Subject: [PATCH 47/94] rotation --- .../webgl/viewer/camera/cameraMovement.ts | 1 + .../webgl/viewer/camera/cameraMovementSnap.ts | 24 ++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 45b8532eb..c6d689aaf 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -143,6 +143,7 @@ export abstract class CameraMovement { * Resets the camera to its last saved position and orientation. */ reset () { + this._camera.screenTarget.set(0.5, 0.5) this.set(this._savedState.position, this._savedState.target) } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index f19cc3974..debddbea0 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -34,16 +34,13 @@ export class CameraMovementSnap extends CameraMovement { applyRotation (quaternion: THREE.Quaternion) { this._camera.quaternion.copy(quaternion) - const target = this._camera.forward - .multiplyScalar(this._camera.orbitDistance) - .add(this._camera.position) - - this.set(this._camera.position, target) + this.updateScreenTarget() } async target (target: Element3D | THREE.Vector3) { const pos = target instanceof Element3D ? (await target.getCenter()) : target if (!pos) return + this._camera.screenTarget.set(0.5, 0.5) this.set(this._camera.position, pos) } @@ -174,12 +171,17 @@ export class CameraMovementSnap extends CameraMovement { } predictRotate(angle: THREE.Vector2) { - // Create quaternions for rotation around X and Z axes - const xQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), (angle.x * Math.PI) / 180) - const zQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), (angle.y * Math.PI) / 180) - const rotation = this._camera.quaternion.clone(); - rotation.multiply(xQuat).multiply(zQuat); - return rotation; + const euler = new THREE.Euler(0, 0, 0, 'ZXY') + euler.setFromQuaternion(this._camera.quaternion) + + euler.x += (angle.x * Math.PI) / 180 + euler.z += (angle.y * Math.PI) / 180 + euler.y = 0 + + const max = Math.PI * 0.48 + euler.x = Math.max(-max, Math.min(max, euler.x)) + + return new THREE.Quaternion().setFromEuler(euler) } } From 6ab0472dfd70da85cddb5a469dee635b574a2df6 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 09:30:46 -0500 Subject: [PATCH 48/94] camera api cleanup --- .../core-viewers/shared/inputHandler.ts | 2 +- .../core-viewers/shared/touchHandler.ts | 4 +- .../core-viewers/ultra/inputAdapter.ts | 2 +- .../webgl/viewer/camera/camera.ts | 2 +- .../webgl/viewer/camera/cameraInterface.ts | 6 -- .../webgl/viewer/camera/cameraMovement.ts | 69 +++++++++++++++--- .../webgl/viewer/camera/cameraMovementLerp.ts | 31 ++++---- .../webgl/viewer/camera/cameraMovementSnap.ts | 70 +++---------------- .../core-viewers/webgl/viewer/inputAdapter.ts | 4 +- 9 files changed, 92 insertions(+), 98 deletions(-) diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index 4c747ddef..138394df8 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -136,7 +136,7 @@ export class InputHandler extends BaseInputHandler { } getZoomValue (value: number) { - return Math.pow(this.scrollSpeed, value) + return Math.pow(this.scrollSpeed, -value) } init(){ diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index e6e428d1b..b0b7decd3 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -94,7 +94,7 @@ export class TouchHandler extends BaseInputHandler { /* private onDoubleDrag = (delta: THREE.Vector2) => { const move = delta.clone().multiplyScalar(this.MOVE_SPEED) - this.camera.snap().move2(move, 'XY') + this.camera.snap().move2D(move, 'XY') } */ @@ -103,7 +103,7 @@ export class TouchHandler extends BaseInputHandler { if (this._viewer.inputs.pointerActive === 'orbit') { this.camera.snap().zoom(1 + delta * this.ZOOM_SPEED) } else { - this.camera.snap().move1(delta * this.ZOOM_SPEED, 'Z') + this.camera.snap().move1D(delta * this.ZOOM_SPEED, 'Z') } } */ diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index c2a276d77..9ef25071e 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -96,7 +96,7 @@ function createAdapter(viewer: Viewer): IInputAdapter { }, zoom: (value: number, screenPos?: THREE.Vector2) => { // Ultra handles zoom server-side, screenPos not used - viewer.rpc.RPCMouseScrollEvent(value >= 1 ? 1 : -1); + viewer.rpc.RPCMouseScrollEvent(value >= 1 ? -1 : 1); }, moveCamera: (value: THREE.Vector3) => { // handled server side diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index b8765a7f5..28d1f9b9f 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -378,7 +378,7 @@ export class Camera implements ICamera { // Apply velocity to move the camera this._tmp1.copy(this._velocity) .multiplyScalar(deltaTime * this.getVelocityMultiplier()) - this.snap().move3(this._tmp1) + this.snap().move3D(this._tmp1) return true } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts index 1e265466a..fca3c5aff 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts @@ -103,12 +103,6 @@ export interface ICamera { */ get target(): THREE.Vector3; - /** - * The screen position where the orbit target appears. - * (0,0) is top-left, (1,1) is bottom-right, (0.5, 0.5) is center. - */ - screenTarget: THREE.Vector2; - /** * The distance from the camera to the target. */ diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index c6d689aaf..4d7d3d1d4 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -27,14 +27,14 @@ export abstract class CameraMovement { * Moves the camera by the specified 3D vector. * @param {THREE.Vector3} vector - The 3D vector representing the direction and distance of movement. */ - abstract move3(vector: THREE.Vector3): void + abstract move3D(vector: THREE.Vector3): void /** * Moves the camera in a specified 2D direction within a plane defined by the given axes. * @param {THREE.Vector2} vector - The 2D vector representing the direction of movement. * @param {'XY' | 'XZ'} axes - The axes defining the plane of movement ('XY' or 'XZ'). */ - move2 (vector: THREE.Vector2, axes: 'XY' | 'XZ'): void { + move2D (vector: THREE.Vector2, axes: 'XY' | 'XZ'): void { const direction = axes === 'XY' ? new THREE.Vector3(-vector.x, 0, vector.y) @@ -42,7 +42,7 @@ export abstract class CameraMovement { ? new THREE.Vector3(-vector.x, vector.y, 0) : undefined - if (direction) this.move3(direction) + if (direction) this.move3D(direction) } /** @@ -50,14 +50,14 @@ export abstract class CameraMovement { * @param {number} amount - The amount to move the camera. * @param {'X' | 'Y' | 'Z'} axis - The axis along which to move the camera ('X', 'Y', or 'Z'). */ - move1 (amount: number, axis: 'X' | 'Y' | 'Z'): void { + move1D (amount: number, axis: 'X' | 'Y' | 'Z'): void { const direction = new THREE.Vector3( axis === 'X' ? -amount : 0, axis === 'Z' ? amount : 0, axis === 'Y' ? amount : 0, ) - this.move3(direction) + this.move3D(direction) } /** @@ -68,14 +68,14 @@ export abstract class CameraMovement { /** * Changes the distance between the camera and its target by a specified factor. - * @param {number} amount - The factor by which to change the distance (e.g., 0.5 for halving the distance, 2 for doubling the distance). + * @param {number} amount - The zoom factor (e.g., 2 to zoom in / halve the distance, 0.5 to zoom out / double the distance). */ abstract zoom(amount: number): void /** * Zooms the camera toward a specific world point while preserving camera orientation. * The orbit target is updated to the world point for future orbit operations. - * @param {number} amount - The zoom factor (e.g., 0.5 to move closer, 2 to move farther). + * @param {number} amount - The zoom factor (e.g., 2 to zoom in / move closer, 0.5 to zoom out / move farther). * @param {THREE.Vector3} worldPoint - The world position to zoom toward. */ abstract zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void @@ -137,7 +137,7 @@ export abstract class CameraMovement { * Rotates the camera without moving so that it looks at the specified target. * @param {Element3D | THREE.Vector3} target - The target object or position to look at. */ - abstract target(target: Element3D | THREE.Vector3): void + abstract lookAt(target: Element3D | THREE.Vector3): void /** * Resets the camera to its last saved position and orientation. @@ -201,6 +201,59 @@ export abstract class CameraMovement { this.set(pos, sphere.center) } + protected applyRotation (quaternion: THREE.Quaternion) { + this._camera.quaternion.copy(quaternion) + this.updateScreenTarget() + } + + /** + * Slides the camera position on the orbit sphere so the target appears + * at screenTarget instead of screen center. Orientation is unchanged. + * Must be called after lookAt(target). + */ + protected applyScreenTargetOffset () { + const st = this._camera.screenTarget + if (st.x === 0.5 && st.y === 0.5) return + + const cam = this._camera.camPerspective.camera + const vFov = cam.fov * Math.PI / 180 + const tanHalfV = Math.tan(vFov / 2) + const tanHalfH = tanHalfV * cam.aspect + + // Screen offset in tangent space + const sx = (2 * st.x - 1) * tanHalfH + const sy = (1 - 2 * st.y) * tanHalfV + + // Exact offset: in camera local space the direction from target to + // camera that places the target at (sx, sy) on screen is (-sx, -sy, 1). + const dist = this._camera.position.distanceTo(this._camera.target) + const offset = new THREE.Vector3(-sx, -sy, 1).normalize().multiplyScalar(dist) + offset.applyQuaternion(cam.quaternion) + + this._camera.position.copy(this._camera.target).add(offset) + } + + /** + * Projects the orbit target onto the screen and stores the result + * in screenTarget. Called when camera moves without re-orienting + * so the next orbit reflects the target's actual screen position. + */ + protected updateScreenTarget () { + const cam = this._camera.camPerspective.camera + cam.updateMatrixWorld(true) + const projected = this._camera.target.clone().project(cam) + + if (projected.z > 1) { + this._camera.screenTarget.set(0.5, 0.5) + return + } + + this._camera.screenTarget.set( + THREE.MathUtils.clamp((projected.x + 1) / 2, 0, 1), + THREE.MathUtils.clamp((1 - projected.y) / 2, 0, 1) + ) + } + private getNormalizedDirection (forward?: THREE.Vector3) { if (!forward) { return this._camera.forward diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 7006b2d89..97be886b6 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -58,7 +58,7 @@ export class CameraLerp extends CameraMovement { this.onProgress?.(t) } - override move3 (vector: THREE.Vector3): void { + override move3D (vector: THREE.Vector3): void { const v = vector.clone() v.applyQuaternion(this._camera.quaternion) const startPos = this._camera.position.clone() @@ -71,16 +71,14 @@ export class CameraLerp extends CameraMovement { } rotate (angle: THREE.Vector2): void { - const euler = new THREE.Euler(0, 0, 0, 'YXZ') + const euler = new THREE.Euler(0, 0, 0, 'ZXY') euler.setFromQuaternion(this._camera.quaternion) - // When moving the mouse one full sreen - // Orbit will rotate 180 degree around the scene - euler.x += angle.x - euler.y += angle.y - euler.z = 0 + euler.x += (angle.x * Math.PI) / 180 + euler.z += (angle.y * Math.PI) / 180 + euler.y = 0 - // Clamp X rotation to prevent performing a loop. + // Clamp pitch to prevent performing a loop. const max = Math.PI * 0.48 euler.x = Math.max(-max, Math.min(max, euler.x)) @@ -90,12 +88,12 @@ export class CameraLerp extends CameraMovement { this.onProgress = (progress) => { rot.copy(start) rot.slerp(end, progress) - this._movement.applyRotation(rot) + this.applyRotation(rot) } } zoom (amount: number): void { - const dist = this._camera.orbitDistance * amount + const dist = this._camera.orbitDistance / amount this.setDistance(dist) } @@ -106,8 +104,8 @@ export class CameraLerp extends CameraMovement { .lerp(start, dist / this._camera.orbitDistance) this.onProgress = (progress) => { - this._camera.position.copy(start) - this._camera.position.lerp(end, progress) + const pos = start.clone().lerp(end, progress) + this._movement.set(pos, undefined, false) } } @@ -119,7 +117,7 @@ export class CameraLerp extends CameraMovement { // Calculate end position const currentDist = startPos.distanceTo(worldPoint) - const newDist = currentDist * amount + const newDist = currentDist / amount const endPos = worldPoint.clone().add(direction.multiplyScalar(newDist)) // Set orbit target immediately (not animated) @@ -152,12 +150,13 @@ export class CameraLerp extends CameraMovement { this._camera.camPerspective.camera.up.set(0, 0, 1) this._camera.camPerspective.camera.lookAt(this._camera.target) - this._movement.applyScreenTargetOffset() + this.applyScreenTargetOffset() } } - async target (target: Element3D | THREE.Vector3) { + async lookAt (target: Element3D | THREE.Vector3) { const pos = target instanceof Element3D ? (await target.getCenter()) : target + this._camera.screenTarget.set(0.5, 0.5) const next = pos.clone().sub(this._camera.position) const start = this._camera.quaternion.clone() const rot = new THREE.Quaternion().setFromUnitVectors( @@ -166,7 +165,7 @@ export class CameraLerp extends CameraMovement { ) this.onProgress = (progress) => { const r = start.clone().slerp(rot, progress) - this._movement.applyRotation(r) + this.applyRotation(r) } } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index debddbea0..9d8026612 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -14,7 +14,7 @@ export class CameraMovementSnap extends CameraMovement { * @param amount movement size. */ zoom (amount: number): void { - const dist = this._camera.orbitDistance * amount + const dist = this._camera.orbitDistance / amount this.setDistance(dist) } @@ -28,16 +28,11 @@ export class CameraMovementSnap extends CameraMovement { rotate (angle: THREE.Vector2): void { const locked = angle.clone().multiply(this._camera.allowedRotation) - const rotation = this.predictRotate(locked) + const rotation = this.computeRotation(locked) this.applyRotation(rotation) } - applyRotation (quaternion: THREE.Quaternion) { - this._camera.quaternion.copy(quaternion) - this.updateScreenTarget() - } - - async target (target: Element3D | THREE.Vector3) { + async lookAt (target: Element3D | THREE.Vector3) { const pos = target instanceof Element3D ? (await target.getCenter()) : target if (!pos) return this._camera.screenTarget.set(0.5, 0.5) @@ -59,7 +54,7 @@ export class CameraMovementSnap extends CameraMovement { this.applyScreenTargetOffset() } - override move3 (vector: THREE.Vector3): void { + override move3D (vector: THREE.Vector3): void { const v = vector.clone() v.applyQuaternion(this._camera.quaternion) const locked = this.lockVector(v, new THREE.Vector3()) @@ -74,13 +69,14 @@ export class CameraMovementSnap extends CameraMovement { const dist = direction.length(); // Clamp elevation to avoid gimbal lock at poles + let finalPos = position; if (dist > 1e-6) { const clamped = SphereCoord.fromVector(direction) direction.copy(clamped.toVector3()) - position.copy(target).add(direction) + finalPos = target.clone().add(direction) } - const lockedPos = this.lockVector(position, this._camera.position); + const lockedPos = this.lockVector(finalPos, this._camera.position); this._camera.position.copy(lockedPos); this._camera.target.copy(target); @@ -93,34 +89,13 @@ export class CameraMovementSnap extends CameraMovement { } } - /** - * Projects the orbit target onto the screen and stores the result - * in screenTarget. Called when camera moves without re-orienting - * so the next orbit reflects the target's actual screen position. - */ - private updateScreenTarget () { - const cam = this._camera.camPerspective.camera - cam.updateMatrixWorld(true) - const projected = this._camera.target.clone().project(cam) - - if (projected.z > 1) { - this._camera.screenTarget.set(0.5, 0.5) - return - } - - this._camera.screenTarget.set( - THREE.MathUtils.clamp((projected.x + 1) / 2, 0, 1), - THREE.MathUtils.clamp((1 - projected.y) / 2, 0, 1) - ) - } - zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void { // Direction from world point to camera const direction = this._camera.position.clone().sub(worldPoint).normalize() // Calculate new distance const currentDist = this._camera.position.distanceTo(worldPoint) - const newDist = currentDist * amount + const newDist = currentDist / amount // New camera position const newPos = worldPoint.clone().add(direction.multiplyScalar(newDist)) @@ -135,33 +110,6 @@ export class CameraMovementSnap extends CameraMovement { } - /** - * Slides the camera position on the orbit sphere so the target appears - * at screenTarget instead of screen center. Orientation is unchanged. - * Must be called after lookAt(target). - */ - applyScreenTargetOffset () { - const st = this._camera.screenTarget - if (st.x === 0.5 && st.y === 0.5) return - - const cam = this._camera.camPerspective.camera - const vFov = cam.fov * Math.PI / 180 - const tanHalfV = Math.tan(vFov / 2) - const tanHalfH = tanHalfV * cam.aspect - - // Screen offset in tangent space - const sx = (2 * st.x - 1) * tanHalfH - const sy = (1 - 2 * st.y) * tanHalfV - - // Exact offset: in camera local space the direction from target to - // camera that places the target at (sx, sy) on screen is (-sx, -sy, 1). - const dist = this._camera.position.distanceTo(this._camera.target) - const offset = new THREE.Vector3(-sx, -sy, 1).normalize().multiplyScalar(dist) - offset.applyQuaternion(cam.quaternion) - - this._camera.position.copy(this._camera.target).add(offset) - } - private lockVector (position: THREE.Vector3, fallback: THREE.Vector3) { const x = this._camera.allowedMovement.x === 0 ? fallback.x : position.x const y = this._camera.allowedMovement.y === 0 ? fallback.y : position.y @@ -170,7 +118,7 @@ export class CameraMovementSnap extends CameraMovement { return new THREE.Vector3(x, y, z) } - predictRotate(angle: THREE.Vector2) { + private computeRotation(angle: THREE.Vector2) { const euler = new THREE.Euler(0, 0, 0, 'ZXY') euler.setFromQuaternion(this._camera.quaternion) diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index a33874501..3be25665b 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -23,11 +23,11 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { panCamera: (value: THREE.Vector2) => { const size = viewer.camera.frustrumSizeAt(viewer.camera.target) size.multiply(value) - viewer.camera.snap().move2(size, 'XZ') + viewer.camera.snap().move2D(size, 'XZ') }, dollyCamera: (value: THREE.Vector2) => { const dist = viewer.camera.orbitDistance * value.y - viewer.camera.snap().move1(dist, 'Y') + viewer.camera.snap().move1D(dist, 'Y') }, toggleOrthographic: () => { From 67ab06bc18f8e33854b918b0549669c7decba928 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 09:41:05 -0500 Subject: [PATCH 49/94] typo frustrum -> frustum --- .../webgl/viewer/camera/camera.ts | 20 +++++--------- .../webgl/viewer/camera/cameraInterface.ts | 2 +- .../webgl/viewer/camera/cameraMovement.ts | 10 +++---- .../webgl/viewer/camera/cameraMovementLerp.ts | 27 +++++++++++-------- .../webgl/viewer/camera/cameraOrthographic.ts | 2 +- .../webgl/viewer/camera/cameraPerspective.ts | 2 +- .../webgl/viewer/environment/skybox.ts | 2 +- .../webgl/viewer/gizmos/gizmoOrbit.ts | 4 +-- .../viewer/gizmos/measure/measureGizmo.ts | 4 +-- .../gizmos/sectionBox/sectionBoxHandle.ts | 2 +- .../core-viewers/webgl/viewer/inputAdapter.ts | 2 +- 11 files changed, 37 insertions(+), 40 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index 28d1f9b9f..f3dd9b448 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -19,8 +19,8 @@ import { PerspectiveCamera } from './cameraPerspective' * Manages viewer camera movement and position */ export class Camera implements ICamera { - camPerspective: PerspectiveCamera - camOrthographic: OrthographicCamera + readonly camPerspective: PerspectiveCamera + readonly camOrthographic: OrthographicCamera private _viewport: Viewport private _scene: RenderScene // make private again @@ -195,8 +195,8 @@ export class Camera implements ICamera { * @param {THREE.Vector3} point - The point in the scene to calculate the frustum size at. * @returns {number} The frustum size at the specified point. */ - frustrumSizeAt (point: THREE.Vector3) { - return this.orthographic ? this.camOrthographic.frustrumSizeAt(point) : this.camPerspective.frustrumSizeAt(point) + frustumSizeAt (point: THREE.Vector3) { + return this.orthographic ? this.camOrthographic.frustumSizeAt(point) : this.camPerspective.frustumSizeAt(point) } /** @@ -284,12 +284,6 @@ export class Camera implements ICamera { this._screenTarget.copy(value) } - private applySettings (settings: ViewerSettings) { - // Camera - - - } - /** * The distance from the camera to the target. */ @@ -342,7 +336,7 @@ export class Camera implements ICamera { private updateOrthographic () { const aspect = this._viewport.getAspectRatio() - const size = this.camPerspective.frustrumSizeAt(this.target) + const size = this.camPerspective.frustumSizeAt(this.target) this.camOrthographic.updateProjection(size, aspect) this.camOrthographic.camera.position.copy(this.position) @@ -385,8 +379,8 @@ export class Camera implements ICamera { private getVelocityMultiplier () { const rotated = !this._lastQuaternion.equals(this.quaternion) const mod = rotated ? 1 : 1.66 - const frustrum = this.frustrumSizeAt(this.target).length() - return mod * frustrum + const frustum = this.frustumSizeAt(this.target).length() + return mod * frustum } private checkForMovement () { diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts index fca3c5aff..a2327160d 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts @@ -59,7 +59,7 @@ export interface ICamera { * @param {THREE.Vector3} point - The point in the scene to calculate the frustum size at. * @returns {number} The frustum size at the specified point. */ - frustrumSizeAt(point: THREE.Vector3): THREE.Vector2; + frustumSizeAt(point: THREE.Vector3): THREE.Vector2; /** * The current THREE Camera diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 4d7d3d1d4..f5aa27da3 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -170,7 +170,6 @@ export abstract class CameraMovement { target = target.scene.getAverageBoundingBox() } if (target === 'all') { - console.log('frame all') target = this._getBoundingBox() } if (target instanceof THREE.Box3) { @@ -183,14 +182,13 @@ export abstract class CameraMovement { protected frameSphere (sphere: THREE.Sphere, forward?: THREE.Vector3) { const direction = this.getNormalizedDirection(forward) - // Compute best distance to frame sphere - const frustrum = this._camera.frustrumSizeAt(sphere.center) // lets use this in our calculation instead, mr - const vFov = (this._camera.camPerspective.camera.fov * Math.PI) / 180 + const cam = this._camera.camPerspective.camera + const vFov = (cam.fov * Math.PI) / 180 const vDist = (sphere.radius * 1.2) / Math.tan(vFov / 2) - const hFov = vFov * this._camera.camPerspective.camera.aspect - const hDist = (sphere.radius * 1.2) / Math.tan(hFov / 2) + const hHalfFov = Math.atan(Math.tan(vFov / 2) * cam.aspect) + const hDist = (sphere.radius * 1.2) / Math.tan(hHalfFov) const dist = Math.max(vDist, hDist) const safeDist = Math.max(dist, this._camera.camPerspective.camera.near * 2) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 97be886b6..e7ccf70e6 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -13,13 +13,13 @@ import { SphereCoord } from './sphereCoord' export class CameraLerp extends CameraMovement { - _movement: CameraMovementSnap - _clock = new THREE.Clock() + private _movement: CameraMovementSnap + private _clock = new THREE.Clock() // position - onProgress: ((progress: number) => void) | undefined + private onProgress: ((progress: number) => void) | undefined - _duration = 1 + private _duration = 1 constructor (camera: Camera, movement: CameraMovementSnap, savedState: CameraSaveState, getBoundingBox:() => THREE.Box3) { super(camera, savedState, getBoundingBox) @@ -41,7 +41,7 @@ export class CameraLerp extends CameraMovement { this.onProgress = undefined } - easeOutCubic (x: number): number { + private easeOutCubic (x: number): number { return 1 - Math.pow(1 - x, 3) } @@ -156,15 +156,20 @@ export class CameraLerp extends CameraMovement { async lookAt (target: Element3D | THREE.Vector3) { const pos = target instanceof Element3D ? (await target.getCenter()) : target + if (!pos) return this._camera.screenTarget.set(0.5, 0.5) - const next = pos.clone().sub(this._camera.position) + const start = this._camera.quaternion.clone() - const rot = new THREE.Quaternion().setFromUnitVectors( - new THREE.Vector3(0, 0, -1), - next.normalize() - ) + + // Compute end orientation using Three.js lookAt (respects Z-up) + const savedQuat = this._camera.quaternion.clone() + this._camera.camPerspective.camera.up.set(0, 0, 1) + this._camera.camPerspective.camera.lookAt(pos) + const end = this._camera.quaternion.clone() + this._camera.quaternion.copy(savedQuat) + this.onProgress = (progress) => { - const r = start.clone().slerp(rot, progress) + const r = start.clone().slerp(end, progress) this.applyRotation(r) } } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraOrthographic.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraOrthographic.ts index eb0781956..6bf7a9220 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraOrthographic.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraOrthographic.ts @@ -19,7 +19,7 @@ export class OrthographicCamera { this.camera.updateProjectionMatrix() } - frustrumSizeAt (point: THREE.Vector3) { + frustumSizeAt (point: THREE.Vector3) { return new THREE.Vector2( this.camera.right - this.camera.left, this.camera.top - this.camera.bottom diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraPerspective.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraPerspective.ts index a41306722..3055a78f3 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraPerspective.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraPerspective.ts @@ -25,7 +25,7 @@ export class PerspectiveCamera { this.camera.updateProjectionMatrix() } - frustrumSizeAt (point: THREE.Vector3) { + frustumSizeAt (point: THREE.Vector3) { const dist = this.camera.position.distanceTo(point) const size = 2 * dist * Math.tan((this.camera.fov / 2) * (Math.PI / 180)) return new THREE.Vector2(size * this.camera.aspect, size) diff --git a/src/vim-web/core-viewers/webgl/viewer/environment/skybox.ts b/src/vim-web/core-viewers/webgl/viewer/environment/skybox.ts index 92ea843ab..3b76bd35c 100644 --- a/src/vim-web/core-viewers/webgl/viewer/environment/skybox.ts +++ b/src/vim-web/core-viewers/webgl/viewer/environment/skybox.ts @@ -84,7 +84,7 @@ export class Skybox { camera.onMoved.subscribe(() => { this.mesh.position.copy(camera.position).add(camera.forward) this.mesh.quaternion.copy(camera.quaternion) - const size = camera.frustrumSizeAt(this.mesh.position) + const size = camera.frustumSizeAt(this.mesh.position) this.mesh.scale.set(size.x, size.y, 1) }) } diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts index 8a2034281..805439115 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts @@ -178,9 +178,9 @@ export class GizmoOrbit { private updateScale () { if (!this._gizmos) return - const frustrum = this._camera.frustrumSizeAt(this._gizmos.position) + const frustum = this._camera.frustumSizeAt(this._gizmos.position) // Size is fraction of screen (0-1), use smaller dimension - const screenSize = Math.min(frustrum.x, frustrum.y) + const screenSize = Math.min(frustum.x, frustum.y) const h = screenSize * this._size this._gizmos.scale.set(h, h, h) } diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measureGizmo.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measureGizmo.ts index d0c9052b7..9f3aa9ce9 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measureGizmo.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measureGizmo.ts @@ -124,7 +124,7 @@ class MeasureMarker { updateScale () { const scale = - this._camera.frustrumSizeAt(this.mesh.position).y / 2 * this.MARKER_SIZE + this._camera.frustumSizeAt(this.mesh.position).y / 2 * this.MARKER_SIZE this.mesh.scale.set(scale, scale, scale) this.mesh.updateMatrix() } @@ -230,7 +230,7 @@ export class MeasureGizmo { ) { if (!first || !second) return const length = first.distanceTo(second) - const ratio = length / (this._viewer.camera.frustrumSizeAt(first).y / 2) + const ratio = length / (this._viewer.camera.frustumSizeAt(first).y / 2) return ratio } diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxHandle.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxHandle.ts index 4194c28e0..6160ace50 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxHandle.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxHandle.ts @@ -60,7 +60,7 @@ export class SectionBoxHandle extends THREE.Mesh { update(){ if(!this._camera) return; - const size = this._camera.frustrumSizeAt(this.position); + const size = this._camera.frustumSizeAt(this.position); this.scale.set(size.x * 0.003, size.x * 0.003, size.x * 0.003); } diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index 3be25665b..f856395ed 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -21,7 +21,7 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { viewer.camera.snap().rotate(value) }, panCamera: (value: THREE.Vector2) => { - const size = viewer.camera.frustrumSizeAt(viewer.camera.target) + const size = viewer.camera.frustumSizeAt(viewer.camera.target) size.multiply(value) viewer.camera.snap().move2D(size, 'XZ') }, From e210c8637325a16245fc86b3d98f1f0e6278b619 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 10:26:15 -0500 Subject: [PATCH 50/94] unified move --- .../core-viewers/shared/touchHandler.ts | 4 +- .../webgl/viewer/camera/camera.ts | 4 +- .../webgl/viewer/camera/cameraMovement.ts | 74 ++++++++++++------- .../webgl/viewer/camera/cameraMovementLerp.ts | 6 +- .../webgl/viewer/camera/cameraMovementSnap.ts | 6 +- .../core-viewers/webgl/viewer/inputAdapter.ts | 4 +- 6 files changed, 57 insertions(+), 41 deletions(-) diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index b0b7decd3..37089f7d5 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -94,7 +94,7 @@ export class TouchHandler extends BaseInputHandler { /* private onDoubleDrag = (delta: THREE.Vector2) => { const move = delta.clone().multiplyScalar(this.MOVE_SPEED) - this.camera.snap().move2D(move, 'XY') + this.camera.snap().move('XY', move, 'local') } */ @@ -103,7 +103,7 @@ export class TouchHandler extends BaseInputHandler { if (this._viewer.inputs.pointerActive === 'orbit') { this.camera.snap().zoom(1 + delta * this.ZOOM_SPEED) } else { - this.camera.snap().move1D(delta * this.ZOOM_SPEED, 'Z') + this.camera.snap().move('Z', delta * this.ZOOM_SPEED, 'local') } } */ diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index f3dd9b448..650df39f4 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -372,7 +372,9 @@ export class Camera implements ICamera { // Apply velocity to move the camera this._tmp1.copy(this._velocity) .multiplyScalar(deltaTime * this.getVelocityMultiplier()) - this.snap().move3D(this._tmp1) + // Convert Three.js camera-local (x,y,z) → Z-up local (x, -z, y) + this._tmp2.set(this._tmp1.x, -this._tmp1.z, this._tmp1.y) + this.snap().move('XYZ', this._tmp2, 'local') return true } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index f5aa27da3..a45d00019 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -24,40 +24,58 @@ export abstract class CameraMovement { } /** - * Moves the camera by the specified 3D vector. - * @param {THREE.Vector3} vector - The 3D vector representing the direction and distance of movement. + * Moves the camera along a single axis. + * @param axis The Z-up axis to move along ('X' = right, 'Y' = forward, 'Z' = up). + * @param amount The distance to move. + * @param space 'local' to move relative to camera orientation, 'world' for absolute axes. */ - abstract move3D(vector: THREE.Vector3): void - + move(axis: 'X' | 'Y' | 'Z', amount: number, space: 'local' | 'world'): void /** - * Moves the camera in a specified 2D direction within a plane defined by the given axes. - * @param {THREE.Vector2} vector - The 2D vector representing the direction of movement. - * @param {'XY' | 'XZ'} axes - The axes defining the plane of movement ('XY' or 'XZ'). + * Moves the camera along two axes. + * @param axes The Z-up plane to move in (e.g. 'XY' = ground, 'XZ' = vertical). + * @param vector The 2D displacement, components mapped to the specified axes. + * @param space 'local' to move relative to camera orientation, 'world' for absolute axes. */ - move2D (vector: THREE.Vector2, axes: 'XY' | 'XZ'): void { - const direction = - axes === 'XY' - ? new THREE.Vector3(-vector.x, 0, vector.y) - : axes === 'XZ' - ? new THREE.Vector3(-vector.x, vector.y, 0) - : undefined - - if (direction) this.move3D(direction) - } - + move(axes: 'XY' | 'XZ' | 'YZ', vector: THREE.Vector2, space: 'local' | 'world'): void /** - * Moves the camera along a specified axis by a given amount. - * @param {number} amount - The amount to move the camera. - * @param {'X' | 'Y' | 'Z'} axis - The axis along which to move the camera ('X', 'Y', or 'Z'). + * Moves the camera along all three axes. + * @param axes Must be 'XYZ'. + * @param vector The 3D displacement in Z-up convention (X = right, Y = forward, Z = up). + * @param space 'local' to move relative to camera orientation, 'world' for absolute axes. */ - move1D (amount: number, axis: 'X' | 'Y' | 'Z'): void { - const direction = new THREE.Vector3( - axis === 'X' ? -amount : 0, - axis === 'Z' ? amount : 0, - axis === 'Y' ? amount : 0, - ) + move(axes: 'XYZ', vector: THREE.Vector3, space: 'local' | 'world'): void + move ( + axes: 'X' | 'Y' | 'Z' | 'XY' | 'XZ' | 'YZ' | 'XYZ', + value: number | THREE.Vector2 | THREE.Vector3, + space: 'local' | 'world' + ): void { + // Build Z-up direction vector from axes and value + const direction = new THREE.Vector3() + if (value instanceof THREE.Vector3) { + direction.copy(value) + } else if (value instanceof THREE.Vector2) { + this.setComponent(direction, axes[0], value.x) + this.setComponent(direction, axes[1], value.y) + } else { + this.setComponent(direction, axes, value) + } + + if (space === 'local') { + // Remap Z-up (x,y,z) → Three.js camera-local (x, z, -y), then to world + const local = new THREE.Vector3(direction.x, direction.z, -direction.y) + local.applyQuaternion(this._camera.quaternion) + this.applyMove(local) + } else { + this.applyMove(direction) + } + } + + protected abstract applyMove(worldVector: THREE.Vector3): void - this.move3D(direction) + private setComponent (v: THREE.Vector3, axis: string, value: number) { + if (axis === 'X') v.x = value + else if (axis === 'Y') v.y = value + else v.z = value } /** diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index e7ccf70e6..f7e3050b7 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -58,11 +58,9 @@ export class CameraLerp extends CameraMovement { this.onProgress?.(t) } - override move3D (vector: THREE.Vector3): void { - const v = vector.clone() - v.applyQuaternion(this._camera.quaternion) + protected applyMove (worldVector: THREE.Vector3): void { const startPos = this._camera.position.clone() - const endPos = this._camera.position.clone().add(v) + const endPos = this._camera.position.clone().add(worldVector) this.onProgress = (progress) => { const pos = startPos.clone().lerp(endPos, progress) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index 9d8026612..052df01bd 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -54,10 +54,8 @@ export class CameraMovementSnap extends CameraMovement { this.applyScreenTargetOffset() } - override move3D (vector: THREE.Vector3): void { - const v = vector.clone() - v.applyQuaternion(this._camera.quaternion) - const locked = this.lockVector(v, new THREE.Vector3()) + protected applyMove (worldVector: THREE.Vector3): void { + const locked = this.lockVector(worldVector, new THREE.Vector3()) const pos = this._camera.position.clone().add(locked) this.set(pos, undefined, false) } diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index f856395ed..74a428e69 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -23,11 +23,11 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { panCamera: (value: THREE.Vector2) => { const size = viewer.camera.frustumSizeAt(viewer.camera.target) size.multiply(value) - viewer.camera.snap().move2D(size, 'XZ') + viewer.camera.snap().move('XZ', new THREE.Vector2(-size.x, size.y), 'local') }, dollyCamera: (value: THREE.Vector2) => { const dist = viewer.camera.orbitDistance * value.y - viewer.camera.snap().move1D(dist, 'Y') + viewer.camera.snap().move('Y', dist, 'local') }, toggleOrthographic: () => { From 625ab452319377fc972ae69e94e665eebc6fb6b1 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 10:45:45 -0500 Subject: [PATCH 51/94] Ste distanc private --- src/vim-web/core-viewers/webgl/viewer/camera/camera.ts | 4 +++- .../core-viewers/webgl/viewer/camera/cameraMovement.ts | 6 +----- .../core-viewers/webgl/viewer/camera/cameraMovementLerp.ts | 2 +- .../core-viewers/webgl/viewer/camera/cameraMovementSnap.ts | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index 650df39f4..3cceda71e 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -159,7 +159,9 @@ export class Camera implements ICamera { // Values this._onValueChanged.dispatch() - this.snap(true).setDistance(-1000) + // Place camera far from target before orienting it + const initPos = this._target.clone().add(this.forward.multiplyScalar(1000)) + this.snap(true).set(initPos, this._target) this.snap(true).orbitTowards(this._defaultForward) this.updateProjection() } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index a45d00019..8ad2d301b 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -98,11 +98,7 @@ export abstract class CameraMovement { */ abstract zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void - /** - * Sets the distance between the camera and its target to the specified value. - * @param {number} dist - The new distance between the camera and its target. - */ - abstract setDistance(dist: number): void + protected abstract setDistance(dist: number): void /** * Orbits the camera around its target by the given angle while maintaining the distance. diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index f7e3050b7..a12b412ab 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -95,7 +95,7 @@ export class CameraLerp extends CameraMovement { this.setDistance(dist) } - setDistance (dist: number): void { + protected setDistance (dist: number): void { const start = this._camera.position.clone() const end = this._camera.target .clone() diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index 052df01bd..7fc3f6bf2 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -18,7 +18,7 @@ export class CameraMovementSnap extends CameraMovement { this.setDistance(dist) } - setDistance (dist: number): void { + protected setDistance (dist: number): void { const pos = this._camera.target .clone() .sub(this._camera.forward.multiplyScalar(dist)) From 159844951f44d5d87bbc2f1e6f74a1fbc0e8ad13 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 10:50:50 -0500 Subject: [PATCH 52/94] rmoved lookat parametr --- .../webgl/viewer/camera/cameraMovementLerp.ts | 4 +- .../webgl/viewer/camera/cameraMovementSnap.ts | 41 ++++++++++--------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index a12b412ab..07574938b 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -64,7 +64,7 @@ export class CameraLerp extends CameraMovement { this.onProgress = (progress) => { const pos = startPos.clone().lerp(endPos, progress) - this._movement.set(pos, undefined, false) + this._movement.reposition(pos) } } @@ -103,7 +103,7 @@ export class CameraLerp extends CameraMovement { this.onProgress = (progress) => { const pos = start.clone().lerp(end, progress) - this._movement.set(pos, undefined, false) + this._movement.reposition(pos) } } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index 7fc3f6bf2..a128d7540 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -23,7 +23,7 @@ export class CameraMovementSnap extends CameraMovement { .clone() .sub(this._camera.forward.multiplyScalar(dist)) - this.set(pos, this._camera.target, false) + this.reposition(pos) } rotate (angle: THREE.Vector2): void { @@ -57,34 +57,37 @@ export class CameraMovementSnap extends CameraMovement { protected applyMove (worldVector: THREE.Vector3): void { const locked = this.lockVector(worldVector, new THREE.Vector3()) const pos = this._camera.position.clone().add(locked) - this.set(pos, undefined, false) + this.reposition(pos) } - set(position: THREE.Vector3, target?: THREE.Vector3, lookAt: boolean = true) { - target = target ?? this._camera.target; + set (position: THREE.Vector3, target?: THREE.Vector3) { + target = target ?? this._camera.target - const direction = new THREE.Vector3().subVectors(position, target); - const dist = direction.length(); + const direction = new THREE.Vector3().subVectors(position, target) + const dist = direction.length() // Clamp elevation to avoid gimbal lock at poles - let finalPos = position; + let finalPos = position if (dist > 1e-6) { const clamped = SphereCoord.fromVector(direction) direction.copy(clamped.toVector3()) finalPos = target.clone().add(direction) } - const lockedPos = this.lockVector(finalPos, this._camera.position); - this._camera.position.copy(lockedPos); - this._camera.target.copy(target); + const lockedPos = this.lockVector(finalPos, this._camera.position) + this._camera.position.copy(lockedPos) + this._camera.target.copy(target) - if (lookAt) { - this._camera.camPerspective.camera.up.set(0, 0, 1); - this._camera.camPerspective.camera.lookAt(target); - this.applyScreenTargetOffset(); - } else { - this.updateScreenTarget(); - } + this._camera.camPerspective.camera.up.set(0, 0, 1) + this._camera.camPerspective.camera.lookAt(target) + this.applyScreenTargetOffset() + } + + reposition (position: THREE.Vector3, target?: THREE.Vector3) { + const lockedPos = this.lockVector(position, this._camera.position) + this._camera.position.copy(lockedPos) + if (target) this._camera.target.copy(target) + this.updateScreenTarget() } zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void { @@ -98,8 +101,8 @@ export class CameraMovementSnap extends CameraMovement { // New camera position const newPos = worldPoint.clone().add(direction.multiplyScalar(newDist)) - // Set position and update orbit target without changing orientation - this.set(newPos, worldPoint, false) + // Reposition camera and update orbit target without changing orientation + this.reposition(newPos, worldPoint) // Override projected screen target with exact cursor position if (screenPoint) { From f7002255d1975a473ce0dafa432cfef76f5595d6 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 11:20:55 -0500 Subject: [PATCH 53/94] reuse veectors --- .../webgl/viewer/camera/camera.ts | 7 +- .../webgl/viewer/camera/cameraMovement.ts | 38 ++++++----- .../webgl/viewer/camera/cameraMovementLerp.ts | 29 +++++---- .../webgl/viewer/camera/cameraMovementSnap.ts | 65 +++++++++---------- 4 files changed, 74 insertions(+), 65 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index 3cceda71e..e6842ae55 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -22,6 +22,9 @@ export class Camera implements ICamera { readonly camPerspective: PerspectiveCamera readonly camOrthographic: OrthographicCamera + private static readonly _ALL_MOVEMENT = new THREE.Vector3(1, 1, 1) + private static readonly _ALL_ROTATION = new THREE.Vector2(1, 1) + private _viewport: Viewport private _scene: RenderScene // make private again private _lerp: CameraLerp @@ -88,7 +91,7 @@ export class Camera implements ICamera { */ private _allowedMovement = new THREE.Vector3(1, 1, 1) get allowedMovement () { - return this._force ? new THREE.Vector3(1, 1, 1) : this._allowedMovement + return this._force ? Camera._ALL_MOVEMENT : this._allowedMovement } set allowedMovement (axes: THREE.Vector3) { @@ -103,7 +106,7 @@ export class Camera implements ICamera { * Each component of the Vector2 should be either 0 or 1 to enable/disable rotation around the corresponding axis. */ get allowedRotation () { - return this._force ? new THREE.Vector2(1, 1) : this._allowedRotation + return this._force ? Camera._ALL_ROTATION : this._allowedRotation } set allowedRotation (axes: THREE.Vector2) { diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 8ad2d301b..1bf6d8a95 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -17,6 +17,12 @@ export abstract class CameraMovement { private _savedState: CameraSaveState private _getBoundingBox: () => THREE.Box3 + // Reusable tmp vectors to avoid per-frame allocations + private _mvDir = new THREE.Vector3() + private _mvLocal = new THREE.Vector3() + private _mvProjected = new THREE.Vector3() + private _mvOffset = new THREE.Vector3() + constructor (camera: Camera, savedState: CameraSaveState, getBoundingBox: () => THREE.Box3) { this._camera = camera this._savedState = savedState @@ -50,23 +56,23 @@ export abstract class CameraMovement { space: 'local' | 'world' ): void { // Build Z-up direction vector from axes and value - const direction = new THREE.Vector3() + this._mvDir.set(0, 0, 0) if (value instanceof THREE.Vector3) { - direction.copy(value) + this._mvDir.copy(value) } else if (value instanceof THREE.Vector2) { - this.setComponent(direction, axes[0], value.x) - this.setComponent(direction, axes[1], value.y) + this.setComponent(this._mvDir, axes[0], value.x) + this.setComponent(this._mvDir, axes[1], value.y) } else { - this.setComponent(direction, axes, value) + this.setComponent(this._mvDir, axes, value) } if (space === 'local') { // Remap Z-up (x,y,z) → Three.js camera-local (x, z, -y), then to world - const local = new THREE.Vector3(direction.x, direction.z, -direction.y) - local.applyQuaternion(this._camera.quaternion) - this.applyMove(local) + this._mvLocal.set(this._mvDir.x, this._mvDir.z, -this._mvDir.y) + this._mvLocal.applyQuaternion(this._camera.quaternion) + this.applyMove(this._mvLocal) } else { - this.applyMove(direction) + this.applyMove(this._mvDir) } } @@ -239,10 +245,10 @@ export abstract class CameraMovement { // Exact offset: in camera local space the direction from target to // camera that places the target at (sx, sy) on screen is (-sx, -sy, 1). const dist = this._camera.position.distanceTo(this._camera.target) - const offset = new THREE.Vector3(-sx, -sy, 1).normalize().multiplyScalar(dist) - offset.applyQuaternion(cam.quaternion) + this._mvOffset.set(-sx, -sy, 1).normalize().multiplyScalar(dist) + this._mvOffset.applyQuaternion(cam.quaternion) - this._camera.position.copy(this._camera.target).add(offset) + this._camera.position.copy(this._camera.target).add(this._mvOffset) } /** @@ -253,16 +259,16 @@ export abstract class CameraMovement { protected updateScreenTarget () { const cam = this._camera.camPerspective.camera cam.updateMatrixWorld(true) - const projected = this._camera.target.clone().project(cam) + this._mvProjected.copy(this._camera.target).project(cam) - if (projected.z > 1) { + if (this._mvProjected.z > 1) { this._camera.screenTarget.set(0.5, 0.5) return } this._camera.screenTarget.set( - THREE.MathUtils.clamp((projected.x + 1) / 2, 0, 1), - THREE.MathUtils.clamp((1 - projected.y) / 2, 0, 1) + THREE.MathUtils.clamp((this._mvProjected.x + 1) / 2, 0, 1), + THREE.MathUtils.clamp((1 - this._mvProjected.y) / 2, 0, 1) ) } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 07574938b..1cf4ab4fe 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -21,6 +21,10 @@ export class CameraLerp extends CameraMovement { private _duration = 1 + private _lrTmp = new THREE.Vector3() + private _lrTmp2 = new THREE.Vector3() + private _lrQuat = new THREE.Quaternion() + constructor (camera: Camera, movement: CameraMovementSnap, savedState: CameraSaveState, getBoundingBox:() => THREE.Box3) { super(camera, savedState, getBoundingBox) this._movement = movement @@ -63,8 +67,8 @@ export class CameraLerp extends CameraMovement { const endPos = this._camera.position.clone().add(worldVector) this.onProgress = (progress) => { - const pos = startPos.clone().lerp(endPos, progress) - this._movement.reposition(pos) + this._lrTmp.copy(startPos).lerp(endPos, progress) + this._movement.reposition(this._lrTmp) } } @@ -102,8 +106,8 @@ export class CameraLerp extends CameraMovement { .lerp(start, dist / this._camera.orbitDistance) this.onProgress = (progress) => { - const pos = start.clone().lerp(end, progress) - this._movement.reposition(pos) + this._lrTmp.copy(start).lerp(end, progress) + this._movement.reposition(this._lrTmp) } } @@ -141,10 +145,10 @@ export class CameraLerp extends CameraMovement { const endOffset = start.rotate(locked.y, locked.x).toVector3() this.onProgress = (progress) => { - const currentOffset = startOffset.clone().lerp(endOffset, progress) - currentOffset.normalize().multiplyScalar(radius) + this._lrTmp.copy(startOffset).lerp(endOffset, progress) + this._lrTmp.normalize().multiplyScalar(radius) - this._camera.position.copy(this._camera.target).add(currentOffset) + this._camera.position.copy(this._camera.target).add(this._lrTmp) this._camera.camPerspective.camera.up.set(0, 0, 1) this._camera.camPerspective.camera.lookAt(this._camera.target) @@ -167,8 +171,8 @@ export class CameraLerp extends CameraMovement { this._camera.quaternion.copy(savedQuat) this.onProgress = (progress) => { - const r = start.clone().slerp(end, progress) - this.applyRotation(r) + this._lrQuat.copy(start).slerp(end, progress) + this.applyRotation(this._lrQuat) } } @@ -177,10 +181,9 @@ export class CameraLerp extends CameraMovement { const startPos = this._camera.position.clone() const startTarget = this._camera.target.clone() this.onProgress = (progress) => { - this._movement.set( - startPos.clone().lerp(position, progress), - startTarget.clone().lerp(endTarget, progress) - ) + this._lrTmp.copy(startPos).lerp(position, progress) + this._lrTmp2.copy(startTarget).lerp(endTarget, progress) + this._movement.set(this._lrTmp, this._lrTmp2) } } } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index a128d7540..c098cdd9e 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -9,6 +9,10 @@ import * as THREE from 'three' export class CameraMovementSnap extends CameraMovement { + private static readonly _ZERO = new THREE.Vector3() + private _snTmp1 = new THREE.Vector3() + private _snTmp2 = new THREE.Vector3() + /** * Moves the camera closer or farther away from orbit target. * @param amount movement size. @@ -19,11 +23,8 @@ export class CameraMovementSnap extends CameraMovement { } protected setDistance (dist: number): void { - const pos = this._camera.target - .clone() - .sub(this._camera.forward.multiplyScalar(dist)) - - this.reposition(pos) + this._snTmp1.copy(this._camera.target).sub(this._camera.forward.multiplyScalar(dist)) + this.reposition(this._snTmp1) } rotate (angle: THREE.Vector2): void { @@ -44,10 +45,10 @@ export class CameraMovementSnap extends CameraMovement { const start = SphereCoord.fromForward(this._camera.forward, this._camera.orbitDistance) const end = start.rotate(locked.y, locked.x) - const newPos = this._camera.target.clone().add(end.toVector3()) + this._snTmp1.copy(this._camera.target).add(end.toVector3()) - const lockedPos = this.lockVector(newPos, this._camera.position) - this._camera.position.copy(lockedPos) + this.lockVector(this._snTmp1, this._camera.position, this._snTmp2) + this._camera.position.copy(this._snTmp2) this._camera.camPerspective.camera.up.set(0, 0, 1) this._camera.camPerspective.camera.lookAt(this._camera.target) @@ -55,27 +56,27 @@ export class CameraMovementSnap extends CameraMovement { } protected applyMove (worldVector: THREE.Vector3): void { - const locked = this.lockVector(worldVector, new THREE.Vector3()) - const pos = this._camera.position.clone().add(locked) - this.reposition(pos) + this.lockVector(worldVector, CameraMovementSnap._ZERO, this._snTmp1) + this._snTmp2.copy(this._camera.position).add(this._snTmp1) + this.reposition(this._snTmp2) } set (position: THREE.Vector3, target?: THREE.Vector3) { target = target ?? this._camera.target - const direction = new THREE.Vector3().subVectors(position, target) - const dist = direction.length() + this._snTmp1.subVectors(position, target) + const dist = this._snTmp1.length() // Clamp elevation to avoid gimbal lock at poles let finalPos = position if (dist > 1e-6) { - const clamped = SphereCoord.fromVector(direction) - direction.copy(clamped.toVector3()) - finalPos = target.clone().add(direction) + const clamped = SphereCoord.fromVector(this._snTmp1) + this._snTmp1.copy(clamped.toVector3()) + finalPos = this._snTmp2.copy(target).add(this._snTmp1) } - const lockedPos = this.lockVector(finalPos, this._camera.position) - this._camera.position.copy(lockedPos) + this.lockVector(finalPos, this._camera.position, this._snTmp1) + this._camera.position.copy(this._snTmp1) this._camera.target.copy(target) this._camera.camPerspective.camera.up.set(0, 0, 1) @@ -84,39 +85,35 @@ export class CameraMovementSnap extends CameraMovement { } reposition (position: THREE.Vector3, target?: THREE.Vector3) { - const lockedPos = this.lockVector(position, this._camera.position) - this._camera.position.copy(lockedPos) + this.lockVector(position, this._camera.position, this._snTmp1) + this._camera.position.copy(this._snTmp1) if (target) this._camera.target.copy(target) this.updateScreenTarget() } zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void { - // Direction from world point to camera - const direction = this._camera.position.clone().sub(worldPoint).normalize() + this._snTmp1.copy(this._camera.position).sub(worldPoint).normalize() - // Calculate new distance const currentDist = this._camera.position.distanceTo(worldPoint) const newDist = currentDist / amount - // New camera position - const newPos = worldPoint.clone().add(direction.multiplyScalar(newDist)) + this._snTmp2.copy(worldPoint).add(this._snTmp1.multiplyScalar(newDist)) - // Reposition camera and update orbit target without changing orientation - this.reposition(newPos, worldPoint) + this.reposition(this._snTmp2, worldPoint) - // Override projected screen target with exact cursor position if (screenPoint) { this._camera.screenTarget.copy(screenPoint) } } - private lockVector (position: THREE.Vector3, fallback: THREE.Vector3) { - const x = this._camera.allowedMovement.x === 0 ? fallback.x : position.x - const y = this._camera.allowedMovement.y === 0 ? fallback.y : position.y - const z = this._camera.allowedMovement.z === 0 ? fallback.z : position.z - - return new THREE.Vector3(x, y, z) + private lockVector (position: THREE.Vector3, fallback: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 { + const allowed = this._camera.allowedMovement + return out.set( + allowed.x === 0 ? fallback.x : position.x, + allowed.y === 0 ? fallback.y : position.y, + allowed.z === 0 ? fallback.z : position.z + ) } private computeRotation(angle: THREE.Vector2) { From ff5a30b9875811d39629b05281f2b11fafc71e99 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 11:30:53 -0500 Subject: [PATCH 54/94] docs --- .../webgl/viewer/camera/camera.ts | 24 +++++++------- .../webgl/viewer/camera/cameraMovement.ts | 32 ++++++++++--------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index e6842ae55..f5fda145c 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -26,7 +26,7 @@ export class Camera implements ICamera { private static readonly _ALL_ROTATION = new THREE.Vector2(1, 1) private _viewport: Viewport - private _scene: RenderScene // make private again + private _scene: RenderScene private _lerp: CameraLerp private _movement: CameraMovementSnap @@ -44,7 +44,7 @@ export class Camera implements ICamera { private _lastQuaternion = new THREE.Quaternion() private _lastTarget = new THREE.Vector3() - // Reuseable vectors for calculations + // Reusable vectors for calculations private _tmp1 = new THREE.Vector3() private _tmp2 = new THREE.Vector3() @@ -86,8 +86,8 @@ export class Camera implements ICamera { private _force: boolean = false /** - * Represents allowed movement along each axis using a Vector3 object. - * Each component of the Vector3 should be either 0 or 1 to enable/disable movement along the corresponding axis. + * Allowed movement axes in Z-up space (X = right, Y = forward, Z = up). + * Each component should be 0 (locked) or 1 (free). */ private _allowedMovement = new THREE.Vector3(1, 1, 1) get allowedMovement () { @@ -102,8 +102,8 @@ export class Camera implements ICamera { } /** - * Represents allowed rotation using a Vector2 object. - * Each component of the Vector2 should be either 0 or 1 to enable/disable rotation around the corresponding axis. + * Allowed rotation axes. x = pitch (up/down), y = yaw (around Z). + * Each component should be 0 (locked) or 1 (free). */ get allowedRotation () { return this._force ? Camera._ALL_ROTATION : this._allowedRotation @@ -182,7 +182,7 @@ export class Camera implements ICamera { /** * Interface for smoothly moving the camera over time. - * @param {number} [duration=1] - The duration of the camera movement animation. + * @param {number} [duration=1] - The duration of the camera movement in seconds. * @param {boolean} [force=false] - Set to true to ignore locked axis and rotation. * @returns {CameraMovement} The camera movement api. */ @@ -198,7 +198,7 @@ export class Camera implements ICamera { /** * Calculates the frustum size at a given point in the scene. * @param {THREE.Vector3} point - The point in the scene to calculate the frustum size at. - * @returns {number} The frustum size at the specified point. + * @returns {THREE.Vector2} The frustum size (width, height) at the specified point. */ frustumSizeAt (point: THREE.Vector3) { return this.orthographic ? this.camOrthographic.frustumSizeAt(point) : this.camPerspective.frustumSizeAt(point) @@ -214,7 +214,7 @@ export class Camera implements ICamera { } /** - * The quaternion representing the orientation of the object. + * The quaternion representing the camera's orientation. */ get quaternion () { return this.camPerspective.camera.quaternion @@ -243,7 +243,7 @@ export class Camera implements ICamera { } /** - * The current or target velocity of the camera. + * The current velocity in camera-local Z-up space (X = right, Y = forward, Z = up). */ get localVelocity () { const result = this._velocity.clone() @@ -253,7 +253,7 @@ export class Camera implements ICamera { } /** - * The current or target velocity of the camera. + * Sets the desired velocity in camera-local Z-up space (X = right, Y = forward, Z = up). */ set localVelocity (vector: THREE.Vector3) { this._lerp.cancel() @@ -271,7 +271,7 @@ export class Camera implements ICamera { } /** - * The target at which the camera is looking at and around which it rotates. + * The point the camera looks at and orbits around. */ get target () { return this._target diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 1bf6d8a95..b2c161f46 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -85,8 +85,8 @@ export abstract class CameraMovement { } /** - * Rotates the camera by the specified angles. - * @param {THREE.Vector2} angle - The 2D vector representing the rotation angles around the X and Y axes. + * Rotates the camera in place by the given angles. + * @param angle - x: pitch (up/down), y: yaw (around Z), in degrees. */ abstract rotate(angle: THREE.Vector2): void @@ -99,18 +99,19 @@ export abstract class CameraMovement { /** * Zooms the camera toward a specific world point while preserving camera orientation. * The orbit target is updated to the world point for future orbit operations. - * @param {number} amount - The zoom factor (e.g., 2 to zoom in / move closer, 0.5 to zoom out / move farther). - * @param {THREE.Vector3} worldPoint - The world position to zoom toward. + * @param amount - The zoom factor (e.g., 2 to zoom in / move closer, 0.5 to zoom out / move farther). + * @param worldPoint - The world position to zoom toward. + * @param [screenPoint] - Screen position of the world point, used to keep the target pinned under the cursor. */ abstract zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void protected abstract setDistance(dist: number): void /** - * Orbits the camera around its target by the given angle while maintaining the distance. - * @param {THREE.Vector2} vector - The 2D vector representing the orbit angles around the X and Y axes. + * Orbits the camera around its target while maintaining the distance. + * @param angle - x: elevation change, y: azimuth change, in degrees. */ - abstract orbit(vector: THREE.Vector2): void + abstract orbit(angle: THREE.Vector2): void /** * Orbits the camera around its target to align with the given direction. @@ -154,8 +155,8 @@ export abstract class CameraMovement { /** - * Rotates the camera without moving so that it looks at the specified target. - * @param {Element3D | THREE.Vector3} target - The target object or position to look at. + * Orients the camera to look at the given point. The orbit target is updated. + * @param target - The target element or world position to look at. */ abstract lookAt(target: Element3D | THREE.Vector3): void @@ -168,19 +169,20 @@ export abstract class CameraMovement { } /** - * Moves both the camera and its target to the given positions. - * @param {THREE.Vector3} position - The new position of the camera. - * @param {THREE.Vector3 | undefined} [target] - The new position of the target (optional). + * Sets the camera position and target, orienting the camera to look at the target. + * Elevation is clamped to avoid gimbal lock at poles. + * @param position - The new camera position. + * @param [target] - The new orbit target. Defaults to the current target. */ abstract set(position: THREE.Vector3, target?: THREE.Vector3) /** * Sets the camera's orientation and position to focus on the specified target. - * @param {IObject | Vim | THREE.Sphere | THREE.Box3 | 'all' | undefined} target - The target object, or 'all' to frame all objects. - * @param {THREE.Vector3} [forward] - Optional forward direction after framing. + * @param target - The target to frame, or 'all' to frame everything. + * @param [forward] - Optional forward direction after framing. */ async frame ( - target: Selectable | Vim | THREE.Sphere | THREE.Box3 | 'all' | undefined, + target: Selectable | Vim | THREE.Sphere | THREE.Box3 | 'all', forward?: THREE.Vector3 ) { if ((target instanceof Marker) || (target instanceof Element3D)) { From 6643b29ed6cf8f9aae2047884cea6d354e18afc1 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 12:14:29 -0500 Subject: [PATCH 55/94] made rotation x horizontal, y vertical --- src/vim-web/core-viewers/shared/inputHandler.ts | 6 +----- src/vim-web/core-viewers/shared/touchHandler.ts | 6 +----- src/vim-web/core-viewers/webgl/viewer/camera/camera.ts | 2 +- .../core-viewers/webgl/viewer/camera/cameraMovement.ts | 6 +++--- .../core-viewers/webgl/viewer/camera/cameraMovementLerp.ts | 6 +++--- .../core-viewers/webgl/viewer/camera/cameraMovementSnap.ts | 6 +++--- 6 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index 138394df8..11d393e77 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -260,9 +260,5 @@ export class InputHandler extends BaseInputHandler { } function toRotation (delta: THREE.Vector2, speed: number) { - const rot = delta.clone() - rot.x = -delta.y - rot.y = -delta.x - rot.multiplyScalar(180 * speed) - return rot + return delta.clone().negate().multiplyScalar(180 * speed) } \ No newline at end of file diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index 37089f7d5..398adf68b 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -74,11 +74,7 @@ export class TouchHandler extends BaseInputHandler { } private toRotation (delta: THREE.Vector2, speed: number) { - const rotation = new THREE.Vector2() - rotation.x = delta.y - rotation.y = delta.x - rotation.multiplyScalar(-180 * speed) - return rotation + return delta.clone().multiplyScalar(-180 * speed) } /* diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index f5fda145c..67a128b20 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -102,7 +102,7 @@ export class Camera implements ICamera { } /** - * Allowed rotation axes. x = pitch (up/down), y = yaw (around Z). + * Allowed rotation axes. x = yaw (around Z), y = pitch (up/down). * Each component should be 0 (locked) or 1 (free). */ get allowedRotation () { diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index b2c161f46..d914ac173 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -86,7 +86,7 @@ export abstract class CameraMovement { /** * Rotates the camera in place by the given angles. - * @param angle - x: pitch (up/down), y: yaw (around Z), in degrees. + * @param angle - x: yaw (around Z), y: pitch (up/down), in degrees. */ abstract rotate(angle: THREE.Vector2): void @@ -109,7 +109,7 @@ export abstract class CameraMovement { /** * Orbits the camera around its target while maintaining the distance. - * @param angle - x: elevation change, y: azimuth change, in degrees. + * @param angle - x: azimuth change, y: elevation change, in degrees. */ abstract orbit(angle: THREE.Vector2): void @@ -146,7 +146,7 @@ export abstract class CameraMovement { const declination = angleForward - angleDirection; // Convert to degrees. - const angle = new THREE.Vector2(-declination, azimuth); + const angle = new THREE.Vector2(azimuth, -declination); angle.multiplyScalar(180 / Math.PI); // Apply rotation. diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 1cf4ab4fe..9a1be6c0b 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -76,8 +76,8 @@ export class CameraLerp extends CameraMovement { const euler = new THREE.Euler(0, 0, 0, 'ZXY') euler.setFromQuaternion(this._camera.quaternion) - euler.x += (angle.x * Math.PI) / 180 - euler.z += (angle.y * Math.PI) / 180 + euler.x += (angle.y * Math.PI) / 180 + euler.z += (angle.x * Math.PI) / 180 euler.y = 0 // Clamp pitch to prevent performing a loop. @@ -142,7 +142,7 @@ export class CameraLerp extends CameraMovement { const start = SphereCoord.fromForward(this._camera.forward, radius) const startOffset = start.toVector3() - const endOffset = start.rotate(locked.y, locked.x).toVector3() + const endOffset = start.rotate(locked.x, locked.y).toVector3() this.onProgress = (progress) => { this._lrTmp.copy(startOffset).lerp(endOffset, progress) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index c098cdd9e..741d2ba22 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -44,7 +44,7 @@ export class CameraMovementSnap extends CameraMovement { const locked = angle.clone().multiply(this._camera.allowedRotation) const start = SphereCoord.fromForward(this._camera.forward, this._camera.orbitDistance) - const end = start.rotate(locked.y, locked.x) + const end = start.rotate(locked.x, locked.y) this._snTmp1.copy(this._camera.target).add(end.toVector3()) this.lockVector(this._snTmp1, this._camera.position, this._snTmp2) @@ -120,8 +120,8 @@ export class CameraMovementSnap extends CameraMovement { const euler = new THREE.Euler(0, 0, 0, 'ZXY') euler.setFromQuaternion(this._camera.quaternion) - euler.x += (angle.x * Math.PI) / 180 - euler.z += (angle.y * Math.PI) / 180 + euler.x += (angle.y * Math.PI) / 180 + euler.z += (angle.x * Math.PI) / 180 euler.y = 0 const max = Math.PI * 0.48 From 862a6cc19b6347bc1fe8222949dbfc5c512b16a6 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 14:10:10 -0500 Subject: [PATCH 56/94] refactor --- .../webgl/viewer/camera/cameraInterface.ts | 4 +-- .../webgl/viewer/camera/cameraMovement.ts | 32 +++++++++++++++++-- .../webgl/viewer/camera/cameraMovementLerp.ts | 28 +++------------- .../webgl/viewer/camera/cameraMovementSnap.ts | 31 ++---------------- 4 files changed, 38 insertions(+), 57 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts index a2327160d..17eb78cd5 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts @@ -57,7 +57,7 @@ export interface ICamera { /** * Calculates the frustum size at a given point in the scene. * @param {THREE.Vector3} point - The point in the scene to calculate the frustum size at. - * @returns {number} The frustum size at the specified point. + * @returns {THREE.Vector2} The frustum size (width, height) at the specified point. */ frustumSizeAt(point: THREE.Vector3): THREE.Vector2; @@ -67,7 +67,7 @@ export interface ICamera { get three(): THREE.Camera; /** - * The quaternion representing the orientation of the object. + * The quaternion representing the camera's orientation. */ get quaternion(): THREE.Quaternion; diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index d914ac173..054fdb09a 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -13,6 +13,8 @@ import { CameraSaveState } from './cameraInterface' export abstract class CameraMovement { + protected static readonly MAX_PITCH = Math.PI * 0.48 + protected _camera: Camera private _savedState: CameraSaveState private _getBoundingBox: () => THREE.Box3 @@ -94,7 +96,9 @@ export abstract class CameraMovement { * Changes the distance between the camera and its target by a specified factor. * @param {number} amount - The zoom factor (e.g., 2 to zoom in / halve the distance, 0.5 to zoom out / double the distance). */ - abstract zoom(amount: number): void + zoom (amount: number): void { + this.setDistance(this._camera.orbitDistance / amount) + } /** * Zooms the camera toward a specific world point while preserving camera orientation. @@ -158,7 +162,14 @@ export abstract class CameraMovement { * Orients the camera to look at the given point. The orbit target is updated. * @param target - The target element or world position to look at. */ - abstract lookAt(target: Element3D | THREE.Vector3): void + async lookAt (target: Element3D | THREE.Vector3) { + const pos = target instanceof Element3D ? (await target.getCenter()) : target + if (!pos) return + this._camera.screenTarget.set(0.5, 0.5) + this.lookAtPoint(pos) + } + + protected abstract lookAtPoint(point: THREE.Vector3): void /** * Resets the camera to its last saved position and orientation. @@ -226,6 +237,23 @@ export abstract class CameraMovement { this.updateScreenTarget() } + /** + * Computes a clamped rotation quaternion from the current orientation plus the given angles. + * @param angle - x: yaw (around Z), y: pitch (up/down), in degrees. + */ + protected computeRotation (angle: THREE.Vector2): THREE.Quaternion { + const euler = new THREE.Euler(0, 0, 0, 'ZXY') + euler.setFromQuaternion(this._camera.quaternion) + + euler.x += (angle.y * Math.PI) / 180 + euler.z += (angle.x * Math.PI) / 180 + euler.y = 0 + + euler.x = Math.max(-CameraMovement.MAX_PITCH, Math.min(CameraMovement.MAX_PITCH, euler.x)) + + return new THREE.Quaternion().setFromEuler(euler) + } + /** * Slides the camera position on the orbit sphere so the target appears * at screenTarget instead of screen center. Orientation is unchanged. diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 9a1be6c0b..45aaf8f02 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -4,7 +4,6 @@ import * as THREE from 'three' import { Camera } from './camera' -import { Element3D } from '../../loader/element3d' import { CameraMovementSnap } from './cameraMovementSnap' import { CameraMovement } from './cameraMovement' import { CameraSaveState } from './cameraInterface' @@ -73,19 +72,9 @@ export class CameraLerp extends CameraMovement { } rotate (angle: THREE.Vector2): void { - const euler = new THREE.Euler(0, 0, 0, 'ZXY') - euler.setFromQuaternion(this._camera.quaternion) - - euler.x += (angle.y * Math.PI) / 180 - euler.z += (angle.x * Math.PI) / 180 - euler.y = 0 - - // Clamp pitch to prevent performing a loop. - const max = Math.PI * 0.48 - euler.x = Math.max(-max, Math.min(max, euler.x)) - + const locked = angle.clone().multiply(this._camera.allowedRotation) const start = this._camera.quaternion.clone() - const end = new THREE.Quaternion().setFromEuler(euler) + const end = this.computeRotation(locked) const rot = new THREE.Quaternion() this.onProgress = (progress) => { rot.copy(start) @@ -94,11 +83,6 @@ export class CameraLerp extends CameraMovement { } } - zoom (amount: number): void { - const dist = this._camera.orbitDistance / amount - this.setDistance(dist) - } - protected setDistance (dist: number): void { const start = this._camera.position.clone() const end = this._camera.target @@ -156,17 +140,13 @@ export class CameraLerp extends CameraMovement { } } - async lookAt (target: Element3D | THREE.Vector3) { - const pos = target instanceof Element3D ? (await target.getCenter()) : target - if (!pos) return - this._camera.screenTarget.set(0.5, 0.5) - + protected lookAtPoint (point: THREE.Vector3) { const start = this._camera.quaternion.clone() // Compute end orientation using Three.js lookAt (respects Z-up) const savedQuat = this._camera.quaternion.clone() this._camera.camPerspective.camera.up.set(0, 0, 1) - this._camera.camPerspective.camera.lookAt(pos) + this._camera.camPerspective.camera.lookAt(point) const end = this._camera.quaternion.clone() this._camera.quaternion.copy(savedQuat) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index 741d2ba22..f3253a714 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -3,7 +3,6 @@ */ import { CameraMovement } from './cameraMovement' -import { Element3D } from '../../loader/element3d' import { SphereCoord } from './sphereCoord' import * as THREE from 'three' @@ -13,15 +12,6 @@ export class CameraMovementSnap extends CameraMovement { private _snTmp1 = new THREE.Vector3() private _snTmp2 = new THREE.Vector3() - /** - * Moves the camera closer or farther away from orbit target. - * @param amount movement size. - */ - zoom (amount: number): void { - const dist = this._camera.orbitDistance / amount - this.setDistance(dist) - } - protected setDistance (dist: number): void { this._snTmp1.copy(this._camera.target).sub(this._camera.forward.multiplyScalar(dist)) this.reposition(this._snTmp1) @@ -33,11 +23,8 @@ export class CameraMovementSnap extends CameraMovement { this.applyRotation(rotation) } - async lookAt (target: Element3D | THREE.Vector3) { - const pos = target instanceof Element3D ? (await target.getCenter()) : target - if (!pos) return - this._camera.screenTarget.set(0.5, 0.5) - this.set(this._camera.position, pos) + protected lookAtPoint (point: THREE.Vector3) { + this.set(this._camera.position, point) } orbit (angle: THREE.Vector2): void { @@ -116,18 +103,4 @@ export class CameraMovementSnap extends CameraMovement { ) } - private computeRotation(angle: THREE.Vector2) { - const euler = new THREE.Euler(0, 0, 0, 'ZXY') - euler.setFromQuaternion(this._camera.quaternion) - - euler.x += (angle.y * Math.PI) / 180 - euler.z += (angle.x * Math.PI) / 180 - euler.y = 0 - - const max = Math.PI * 0.48 - euler.x = Math.max(-max, Math.min(max, euler.x)) - - return new THREE.Quaternion().setFromEuler(euler) - } - } From 3146516453d2d0fbea6714039687726096d8c313 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 14:14:19 -0500 Subject: [PATCH 57/94] missing lock --- .../webgl/viewer/camera/cameraMovement.ts | 9 +++++++++ .../webgl/viewer/camera/cameraMovementLerp.ts | 9 ++++++--- .../webgl/viewer/camera/cameraMovementSnap.ts | 11 ----------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 054fdb09a..97b6cdd19 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -302,6 +302,15 @@ export abstract class CameraMovement { ) } + protected lockVector (position: THREE.Vector3, fallback: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 { + const allowed = this._camera.allowedMovement + return out.set( + allowed.x === 0 ? fallback.x : position.x, + allowed.y === 0 ? fallback.y : position.y, + allowed.z === 0 ? fallback.z : position.z + ) + } + private getNormalizedDirection (forward?: THREE.Vector3) { if (!forward) { return this._camera.forward diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 45aaf8f02..cfb68caba 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -115,8 +115,9 @@ export class CameraLerp extends CameraMovement { } this.onProgress = (progress) => { - // Only lerp position, orientation stays unchanged - this._camera.position.copy(startPos).lerp(endPos, progress) + this._lrTmp.copy(startPos).lerp(endPos, progress) + this.lockVector(this._lrTmp, this._camera.position, this._lrTmp2) + this._camera.position.copy(this._lrTmp2) } } @@ -131,8 +132,10 @@ export class CameraLerp extends CameraMovement { this.onProgress = (progress) => { this._lrTmp.copy(startOffset).lerp(endOffset, progress) this._lrTmp.normalize().multiplyScalar(radius) + this._lrTmp.add(this._camera.target) - this._camera.position.copy(this._camera.target).add(this._lrTmp) + this.lockVector(this._lrTmp, this._camera.position, this._lrTmp2) + this._camera.position.copy(this._lrTmp2) this._camera.camPerspective.camera.up.set(0, 0, 1) this._camera.camPerspective.camera.lookAt(this._camera.target) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index f3253a714..06fbd3a14 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -92,15 +92,4 @@ export class CameraMovementSnap extends CameraMovement { this._camera.screenTarget.copy(screenPoint) } } - - - private lockVector (position: THREE.Vector3, fallback: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 { - const allowed = this._camera.allowedMovement - return out.set( - allowed.x === 0 ? fallback.x : position.x, - allowed.y === 0 ? fallback.y : position.y, - allowed.z === 0 ? fallback.z : position.z - ) - } - } From 1e1aa1e21ff827c5087e8692a425bbc51c00abec Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 14:51:46 -0500 Subject: [PATCH 58/94] reset orbit outside screen --- .../webgl/viewer/camera/cameraMovement.ts | 32 +++++++++++++++++-- .../webgl/viewer/camera/cameraMovementLerp.ts | 2 +- .../webgl/viewer/camera/cameraMovementSnap.ts | 2 +- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 97b6cdd19..316a9038e 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -113,9 +113,21 @@ export abstract class CameraMovement { /** * Orbits the camera around its target while maintaining the distance. + * If the target has drifted off-screen, it is reset to 10 units + * in front of the camera so the orbit feels natural. * @param angle - x: azimuth change, y: elevation change, in degrees. */ - abstract orbit(angle: THREE.Vector2): void + orbit (angle: THREE.Vector2): void { + const st = this._camera.screenTarget + if (st.x < 0 || st.x > 1 || st.y < 0 || st.y > 1) { + this._camera.target.copy(this._camera.position) + .add(this._camera.forward.multiplyScalar(10)) + this._camera.screenTarget.set(0.5, 0.5) + } + this.applyOrbit(angle) + } + + protected abstract applyOrbit(angle: THREE.Vector2): void /** * Orbits the camera around its target to align with the given direction. @@ -171,6 +183,17 @@ export abstract class CameraMovement { protected abstract lookAtPoint(point: THREE.Vector3): void + /** + * Moves the orbit target without moving the camera or changing orientation. + * @param target - The new orbit target (element or world position). + */ + async setTarget (target: Element3D | THREE.Vector3) { + const pos = target instanceof Element3D ? (await target.getCenter()) : target + if (!pos) return + this._camera.target.copy(pos) + this.updateScreenTarget() + } + /** * Resets the camera to its last saved position and orientation. */ @@ -296,9 +319,12 @@ export abstract class CameraMovement { return } + // No clamping: values outside [0,1] signal that the target has + // drifted off-screen (e.g. after WASD movement). orbit() uses + // this to detect and reset a stale target. this._camera.screenTarget.set( - THREE.MathUtils.clamp((this._mvProjected.x + 1) / 2, 0, 1), - THREE.MathUtils.clamp((1 - this._mvProjected.y) / 2, 0, 1) + (this._mvProjected.x + 1) / 2, + (1 - this._mvProjected.y) / 2 ) } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index cfb68caba..5f6b4edaa 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -121,7 +121,7 @@ export class CameraLerp extends CameraMovement { } } - orbit (angle: THREE.Vector2): void { + protected applyOrbit (angle: THREE.Vector2): void { const locked = angle.clone().multiply(this._camera.allowedRotation) const radius = this._camera.orbitDistance diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index 06fbd3a14..30fb6a083 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -27,7 +27,7 @@ export class CameraMovementSnap extends CameraMovement { this.set(this._camera.position, point) } - orbit (angle: THREE.Vector2): void { + protected applyOrbit (angle: THREE.Vector2): void { const locked = angle.clone().multiply(this._camera.allowedRotation) const start = SphereCoord.fromForward(this._camera.forward, this._camera.orbitDistance) From 003fd3cfecf8164ec92654c6a422fd45f26187f1 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 15:00:51 -0500 Subject: [PATCH 59/94] orbit target on slect --- src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index 74a428e69..f7a17bf86 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -1,6 +1,7 @@ import {type IInputAdapter} from "../../shared/inputAdapter" import {InputHandler, PointerMode} from "../../shared/inputHandler" import { Viewer } from "./viewer" +import { Element3D } from '../loader/element3d' import * as THREE from 'three' export function createInputHandler(viewer: Viewer) { @@ -58,12 +59,14 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { //TODO: This logic should happen in shared code const result = await viewer.raycaster.raycastFromScreen(pos) if(add){ - viewer.selection.add(result?.object) } else{ viewer.selection.select(result?.object) } + if (result?.object instanceof Element3D) { + await viewer.camera.snap().setTarget(result.object) + } }, frameAtPointer: async (pos: THREE.Vector2) => { //TODO: This logic should happen in shared code From a4232474b41376d1ae42497dde645339d61ca470 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 15:07:41 -0500 Subject: [PATCH 60/94] floating target after reset --- .../core-viewers/webgl/viewer/camera/camera.ts | 16 ++++++++++++++++ .../webgl/viewer/camera/cameraMovement.ts | 5 +++++ .../webgl/viewer/camera/cameraMovementLerp.ts | 2 ++ .../webgl/viewer/camera/cameraMovementSnap.ts | 9 ++++++++- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index 67a128b20..d980e2354 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -38,6 +38,7 @@ export class Camera implements ICamera { private _orthographic: boolean = false private _target = new THREE.Vector3() private _screenTarget = new THREE.Vector2(0.5, 0.5) + private _floatingTarget = false // updates private _lastPosition = new THREE.Vector3() @@ -289,6 +290,21 @@ export class Camera implements ICamera { this._screenTarget.copy(value) } + /** + * When true the orbit target is not anchored to a scene point and + * will translate with the camera during WASD / pan movement. + * Set automatically when the orbit target is reset because it drifted + * off-screen. Cleared when the target is explicitly set (select, + * lookAt, frame, zoomTowards, etc.). + */ + get floatingTarget () { + return this._floatingTarget + } + + set floatingTarget (value: boolean) { + this._floatingTarget = value + } + /** * The distance from the camera to the target. */ diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts index 316a9038e..931054740 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -123,6 +123,7 @@ export abstract class CameraMovement { this._camera.target.copy(this._camera.position) .add(this._camera.forward.multiplyScalar(10)) this._camera.screenTarget.set(0.5, 0.5) + this._camera.floatingTarget = true } this.applyOrbit(angle) } @@ -178,6 +179,7 @@ export abstract class CameraMovement { const pos = target instanceof Element3D ? (await target.getCenter()) : target if (!pos) return this._camera.screenTarget.set(0.5, 0.5) + this._camera.floatingTarget = false this.lookAtPoint(pos) } @@ -191,6 +193,7 @@ export abstract class CameraMovement { const pos = target instanceof Element3D ? (await target.getCenter()) : target if (!pos) return this._camera.target.copy(pos) + this._camera.floatingTarget = false this.updateScreenTarget() } @@ -199,6 +202,7 @@ export abstract class CameraMovement { */ reset () { this._camera.screenTarget.set(0.5, 0.5) + this._camera.floatingTarget = false this.set(this._savedState.position, this._savedState.target) } @@ -252,6 +256,7 @@ export abstract class CameraMovement { const pos = direction.multiplyScalar(-safeDist).add(sphere.center) this._camera.screenTarget.set(0.5, 0.5) + this._camera.floatingTarget = false this.set(pos, sphere.center) } diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 5f6b4edaa..1e5898269 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -108,6 +108,7 @@ export class CameraLerp extends CameraMovement { // Set orbit target immediately (not animated) this._camera.target.copy(worldPoint) + this._camera.floatingTarget = false // Update screen target so orbit pivot stays at cursor position if (screenPoint) { @@ -160,6 +161,7 @@ export class CameraLerp extends CameraMovement { } set (position: THREE.Vector3, target?: THREE.Vector3) { + this._camera.floatingTarget = false const endTarget = target ?? this._camera.target const startPos = this._camera.position.clone() const startTarget = this._camera.target.clone() diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts index 30fb6a083..fd1e40459 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -44,6 +44,9 @@ export class CameraMovementSnap extends CameraMovement { protected applyMove (worldVector: THREE.Vector3): void { this.lockVector(worldVector, CameraMovementSnap._ZERO, this._snTmp1) + if (this._camera.floatingTarget) { + this._camera.target.add(this._snTmp1) + } this._snTmp2.copy(this._camera.position).add(this._snTmp1) this.reposition(this._snTmp2) } @@ -65,6 +68,7 @@ export class CameraMovementSnap extends CameraMovement { this.lockVector(finalPos, this._camera.position, this._snTmp1) this._camera.position.copy(this._snTmp1) this._camera.target.copy(target) + this._camera.floatingTarget = false this._camera.camPerspective.camera.up.set(0, 0, 1) this._camera.camPerspective.camera.lookAt(target) @@ -74,7 +78,10 @@ export class CameraMovementSnap extends CameraMovement { reposition (position: THREE.Vector3, target?: THREE.Vector3) { this.lockVector(position, this._camera.position, this._snTmp1) this._camera.position.copy(this._snTmp1) - if (target) this._camera.target.copy(target) + if (target) { + this._camera.target.copy(target) + this._camera.floatingTarget = false + } this.updateScreenTarget() } From 42ebb7baaac485bfa9ad59c41c76a3a0cc7a40c7 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 15:14:02 -0500 Subject: [PATCH 61/94] keep last speed upon reset --- src/vim-web/core-viewers/webgl/viewer/camera/camera.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index d980e2354..8b882386c 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -39,6 +39,7 @@ export class Camera implements ICamera { private _target = new THREE.Vector3() private _screenTarget = new THREE.Vector2(0.5, 0.5) private _floatingTarget = false + private _cachedFrustumLength = 0 // updates private _lastPosition = new THREE.Vector3() @@ -302,6 +303,9 @@ export class Camera implements ICamera { } set floatingTarget (value: boolean) { + if (value && !this._floatingTarget) { + this._cachedFrustumLength = this.frustumSizeAt(this._target).length() + } this._floatingTarget = value } @@ -402,7 +406,9 @@ export class Camera implements ICamera { private getVelocityMultiplier () { const rotated = !this._lastQuaternion.equals(this.quaternion) const mod = rotated ? 1 : 1.66 - const frustum = this.frustumSizeAt(this.target).length() + const frustum = this._floatingTarget && this._cachedFrustumLength > 0 + ? this._cachedFrustumLength + : this.frustumSizeAt(this.target).length() return mod * frustum } From c6e5b31eb29a6858df37eece8f65d0ad474e5346 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 15:33:24 -0500 Subject: [PATCH 62/94] fixed framing --- .../webgl/viewer/camera/cameraMovementLerp.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts index 1e5898269..494cad91d 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -165,10 +165,25 @@ export class CameraLerp extends CameraMovement { const endTarget = target ?? this._camera.target const startPos = this._camera.position.clone() const startTarget = this._camera.target.clone() + const startQuat = this._camera.quaternion.clone() + + // Compute the final camera state (includes elevation clamping, lookAt, screen offset) + this._movement.set(position, endTarget) + const endPos = this._camera.position.clone() + const endQuat = this._camera.quaternion.clone() + + // Restore start state + this._camera.position.copy(startPos) + this._camera.target.copy(startTarget) + this._camera.quaternion.copy(startQuat) + this.onProgress = (progress) => { - this._lrTmp.copy(startPos).lerp(position, progress) + this._lrTmp.copy(startPos).lerp(endPos, progress) this._lrTmp2.copy(startTarget).lerp(endTarget, progress) - this._movement.set(this._lrTmp, this._lrTmp2) + this._lrQuat.copy(startQuat).slerp(endQuat, progress) + this._camera.position.copy(this._lrTmp) + this._camera.target.copy(this._lrTmp2) + this._camera.quaternion.copy(this._lrQuat) } } } From 42225b3b944cdbc467ab6383b31d350e7e67d4a5 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 15:33:45 -0500 Subject: [PATCH 63/94] faster scroll --- src/vim-web/core-viewers/shared/inputHandler.ts | 6 +++--- .../webgl/viewer/settings/viewerDefaultSettings.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index 11d393e77..6496c620e 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -40,10 +40,10 @@ export class InputHandler extends BaseInputHandler { keyboard: KeyboardHandler - scrollSpeed: number = 1.6 - private _moveSpeed: number + scrollSpeed: number rotateSpeed: number orbitSpeed: number + private _moveSpeed: number private _pointerActive: PointerMode = PointerMode.ORBIT private _pointerFallback: PointerMode = PointerMode.LOOK @@ -58,7 +58,7 @@ export class InputHandler extends BaseInputHandler { this._adapter = adapter this._pointerActive = (settings.orbit === undefined || settings.orbit) ? PointerMode.ORBIT : PointerMode.LOOK - this.scrollSpeed = settings.scrollSpeed ?? 1.6 + this.scrollSpeed = settings.scrollSpeed ?? 1.75 this._moveSpeed = settings.moveSpeed ?? 1 this.rotateSpeed = settings.rotateSpeed ?? 1 this.orbitSpeed = settings.orbitSpeed ?? 1 diff --git a/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts b/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts index cb8002722..49f1d0452 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts @@ -26,7 +26,7 @@ export function getDefaultViewerSettings(): ViewerSettings { rotateSpeed: 1, orbitSpeed: 1, moveSpeed: 1, - scrollSpeed: 1.5 + scrollSpeed: 1.75 }, gizmo: { From 56fa0e3a106283a9fd365972ce80ef2ea5c78746 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 16:04:59 -0500 Subject: [PATCH 64/94] restored touch --- .../core-viewers/shared/baseInputHandler.ts | 7 +++--- .../core-viewers/shared/inputHandler.ts | 7 +++++- .../core-viewers/shared/touchHandler.ts | 24 ++++++++++--------- src/vim-web/react-viewers/panels/overlay.tsx | 14 ++++++----- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/vim-web/core-viewers/shared/baseInputHandler.ts b/src/vim-web/core-viewers/shared/baseInputHandler.ts index 5d7dbb56a..0efe61ba7 100644 --- a/src/vim-web/core-viewers/shared/baseInputHandler.ts +++ b/src/vim-web/core-viewers/shared/baseInputHandler.ts @@ -18,11 +18,12 @@ export class BaseInputHandler { protected reg( element: Document | HTMLElement | Window, eventType: string, - callback: (event: T) => void + callback: (event: T) => void, + options?: AddEventListenerOptions ): void { const f = (e: Event): void => { callback(e as T); }; - element.addEventListener(eventType, f); - this._disconnect.push(() => { element.removeEventListener(eventType, f); }); + element.addEventListener(eventType, f, options); + this._disconnect.push(() => { element.removeEventListener(eventType, f, options); }); } /** diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index 6496c620e..15aa18918 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -130,7 +130,12 @@ export class InputHandler extends BaseInputHandler { // Touch controls this.touch.onTap = (pos: THREE.Vector2) => adapter.selectAtPointer(pos, false) this.touch.onDoubleTap = adapter.frameAtPointer - this.touch.onDrag = (delta: THREE.Vector2) => adapter.orbitCamera(delta) + this.touch.onDrag = (delta: THREE.Vector2) => { + if(this.pointerActive === PointerMode.ORBIT) adapter.orbitCamera(toRotation(delta, this.orbitSpeed)) + if(this.pointerActive === PointerMode.LOOK) adapter.rotateCamera(toRotation(delta, this.rotateSpeed)) + if(this.pointerActive === PointerMode.PAN) adapter.panCamera(delta) + if(this.pointerActive === PointerMode.ZOOM) adapter.dollyCamera(delta) + } this.touch.onPinchOrSpread = adapter.zoom this.touch.onDoubleDrag = (value : THREE.Vector2) => adapter.panCamera(value) } diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index 398adf68b..eaca8e4cb 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -34,9 +34,11 @@ export class TouchHandler extends BaseInputHandler { private _touchStart: THREE.Vector2 | undefined protected override addListeners (): void { - this.reg(this._canvas, 'touchstart', this.onTouchStart) + this._canvas.style.touchAction = 'none' + const active = { passive: false } + this.reg(this._canvas, 'touchstart', this.onTouchStart, active) this.reg(this._canvas, 'touchend', this.onTouchEnd) - this.reg(this._canvas, 'touchmove', this.onTouchMove) + this.reg(this._canvas, 'touchmove', this.onTouchMove, active) } override reset = () => { @@ -50,13 +52,13 @@ export class TouchHandler extends BaseInputHandler { this._lastTapMs = time if(double) - this._onTap?.(position) - else this.onDoubleTap?.(position) + else + this.onTap?.(position) } - private onTouchStart = (event: any) => { - event.preventDefault() // prevent scrolling + private onTouchStart = (event: TouchEvent) => { + if (event.cancelable) event.preventDefault() if (!event || !event.touches || !event.touches.length) { return } @@ -104,9 +106,9 @@ export class TouchHandler extends BaseInputHandler { } */ - private onTouchMove = (event: any) => { - event.preventDefault() - if (!event || !event.touches || !event.touches.length) return + private onTouchMove = (event: TouchEvent) => { + if (event.cancelable) event.preventDefault() + if (!event.touches.length) return if (!this._touch) return if (event.touches.length === 1) { @@ -154,7 +156,7 @@ export class TouchHandler extends BaseInputHandler { } } - private onTouchEnd = (event: any) => { + private onTouchEnd = (event: TouchEvent) => { if (this.isSingleTouch() && this._touchStart && this._touch) { const touchDurationMs = Date.now() - this._touchStartTime const length = this._touch.distanceTo(this._touchStart) @@ -177,7 +179,7 @@ export class TouchHandler extends BaseInputHandler { ) } - private touchToVector (touch: any) { + private touchToVector (touch: Touch) { return new THREE.Vector2(touch.pageX, touch.pageY) } diff --git a/src/vim-web/react-viewers/panels/overlay.tsx b/src/vim-web/react-viewers/panels/overlay.tsx index f5a1e6073..6e88aa715 100644 --- a/src/vim-web/react-viewers/panels/overlay.tsx +++ b/src/vim-web/react-viewers/panels/overlay.tsx @@ -16,15 +16,15 @@ export function Overlay (props: { canvas: HTMLCanvasElement }) { const relay = ( evnt: string, construct: (s: string, e: Event) => Event, - preventDefault: boolean = true + preventDefault: boolean = true, + options?: AddEventListenerOptions ) => { - overlay.current?.addEventListener(evnt, (e) => { props.canvas.dispatchEvent(construct(evnt, e)) if (preventDefault) { e.preventDefault() } - }) + }, options) } relay('mousedown', (s, e) => new MouseEvent(s, e)) relay('mousemove', (s, e) => new MouseEvent(s, e)) @@ -39,16 +39,18 @@ export function Overlay (props: { canvas: HTMLCanvasElement }) { relay('pointerup', (s, e) => new PointerEvent(s, e), false) relay('pointerenter', (s, e) => new PointerEvent(s, e)) relay('pointerleave', (s, e) => new PointerEvent(s, e)) - relay('touchstart', (s, e) => new TouchEvent(s, e), false) + + const active = { passive: false } + relay('touchstart', (s, e) => new TouchEvent(s, e), false, active) relay('touchend', (s, e) => new TouchEvent(s, e), false) - relay('touchmove', (s, e) => new TouchEvent(s, e), false) + relay('touchmove', (s, e) => new TouchEvent(s, e), false, active) }, []) return (
e.preventDefault()} - className={'vim-overlay vc-top-0 vc-left-0 vc-z-10 vc-h-full'} + className={'vim-overlay vc-top-0 vc-left-0 vc-z-10 vc-h-full vc-touch-none'} >
) } From 4af86909f25e44b4b2a22b0d06d7a001531512e3 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 16:59:53 -0500 Subject: [PATCH 65/94] working touch --- .../core-viewers/shared/inputAdapter.ts | 3 ++ .../core-viewers/shared/inputHandler.ts | 3 +- .../core-viewers/shared/touchHandler.ts | 28 +++++++++++++------ .../core-viewers/ultra/inputAdapter.ts | 4 +++ .../core-viewers/webgl/viewer/inputAdapter.ts | 24 ++++++++++++++++ 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/vim-web/core-viewers/shared/inputAdapter.ts b/src/vim-web/core-viewers/shared/inputAdapter.ts index 3b0aa1588..bb65c19b5 100644 --- a/src/vim-web/core-viewers/shared/inputAdapter.ts +++ b/src/vim-web/core-viewers/shared/inputAdapter.ts @@ -24,4 +24,7 @@ export interface IInputAdapter{ selectAtPointer: (pos: THREE.Vector2, add: boolean) => void frameAtPointer: (pos: THREE.Vector2) => void zoom: (value: number, screenPos?: THREE.Vector2) => void + + pinchStart: (screenPos: THREE.Vector2) => void + pinchZoom: (totalRatio: number) => void } \ No newline at end of file diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index 15aa18918..52b063807 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -136,7 +136,8 @@ export class InputHandler extends BaseInputHandler { if(this.pointerActive === PointerMode.PAN) adapter.panCamera(delta) if(this.pointerActive === PointerMode.ZOOM) adapter.dollyCamera(delta) } - this.touch.onPinchOrSpread = adapter.zoom + this.touch.onPinchStart = (center: THREE.Vector2) => adapter.pinchStart(center) + this.touch.onPinchOrSpread = (totalRatio: number) => adapter.pinchZoom(totalRatio) this.touch.onDoubleDrag = (value : THREE.Vector2) => adapter.panCamera(value) } diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index eaca8e4cb..176999f6a 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -19,7 +19,8 @@ export class TouchHandler extends BaseInputHandler { onDoubleTap: (position: THREE.Vector2) => void onDrag: (delta: THREE.Vector2) => void onDoubleDrag: (delta: THREE.Vector2) => void - onPinchOrSpread: (delta: number) => void + onPinchStart: (screenCenter: THREE.Vector2) => void + onPinchOrSpread: (totalRatio: number) => void constructor (canvas: HTMLCanvasElement) { super(canvas) @@ -32,6 +33,7 @@ export class TouchHandler extends BaseInputHandler { private _touchStartTime: number | undefined = undefined // In ms since epoch private _lastTapMs: number | undefined private _touchStart: THREE.Vector2 | undefined + private _startDist: number | undefined protected override addListeners (): void { this._canvas.style.touchAction = 'none' @@ -42,7 +44,7 @@ export class TouchHandler extends BaseInputHandler { } override reset = () => { - this._touch = this._touch1 = this._touch2 = this._touchStartTime = undefined + this._touch = this._touch1 = this._touch2 = this._touchStartTime = this._startDist = undefined } private _onTap = (position: THREE.Vector2) => { @@ -71,6 +73,15 @@ export class TouchHandler extends BaseInputHandler { this._touch1 = this.touchToVector(event.touches[0]) this._touch2 = this.touchToVector(event.touches[1]) this._touch = this.average(this._touch1, this._touch2) + this._startDist = this._touch1.distanceTo(this._touch2) + + const size = this.getCanvasSize() + const rect = this._canvas.getBoundingClientRect() + const screenCenter = new THREE.Vector2( + (this._touch.x - window.scrollX - rect.left) / rect.width, + (this._touch.y - window.scrollY - rect.top) / rect.height + ) + this.onPinchStart?.(screenCenter) } this._touchStart = this._touch } @@ -138,20 +149,19 @@ export class TouchHandler extends BaseInputHandler { new THREE.Vector2(-1 / size.x, -1 / size.y) ) - const zoom = p1.distanceTo(p2) - const prevZoom = this._touch1.distanceTo(this._touch2) + const dist = p1.distanceTo(p2) + const prevDist = this._touch1.distanceTo(this._touch2) const min = Math.min(size.x, size.y) - // -1 to invert movement - const zoomDelta = (zoom - prevZoom) / -min + const zoomDelta = Math.abs(dist - prevDist) / min this._touch = p this._touch1 = p1 this._touch2 = p2 - if (moveDelta.length() > Math.abs(zoomDelta)) { + if (moveDelta.length() > zoomDelta) { this.onDoubleDrag(moveDelta) - } else { - this.onPinchOrSpread(zoomDelta) + } else if (this._startDist) { + this.onPinchOrSpread(dist / this._startDist) } } } diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index 9ef25071e..20071e751 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -101,6 +101,10 @@ function createAdapter(viewer: Viewer): IInputAdapter { moveCamera: (value: THREE.Vector3) => { // handled server side }, + pinchStart: () => {}, + pinchZoom: (totalRatio: number) => { + viewer.rpc.RPCMouseScrollEvent(totalRatio >= 1 ? -1 : 1); + }, keyDown: (code: string) => { return sendKey(viewer, code, true); }, diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index f7a17bf86..4acde4aa8 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -13,6 +13,9 @@ export function createInputHandler(viewer: Viewer) { } function createAdapter(viewer: Viewer ) : IInputAdapter { + let _pinchWorldPoint: THREE.Vector3 | undefined + let _pinchStartDist = 0 + return { init: () => {}, orbitCamera: (value: THREE.Vector2) => { @@ -87,6 +90,27 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { viewer.camera.localVelocity = value }, + pinchStart: async (screenPos: THREE.Vector2) => { + const result = await viewer.raycaster.raycastFromScreen(screenPos) + if (result?.worldPosition) { + _pinchWorldPoint = result.worldPosition.clone() + _pinchStartDist = viewer.camera.position.distanceTo(result.worldPosition) + } else { + _pinchWorldPoint = undefined + } + }, + pinchZoom: (totalRatio: number) => { + if (_pinchWorldPoint) { + const currentDist = viewer.camera.position.distanceTo(_pinchWorldPoint) + const desiredDist = _pinchStartDist / totalRatio + if (currentDist > 1e-6) { + viewer.camera.snap().zoomTowards(currentDist / desiredDist, _pinchWorldPoint) + } + } else { + viewer.camera.snap().zoom(totalRatio) + } + }, + keyDown: (keyCode: string) => {return false}, keyUp: (keyCode: string) => {return false}, mouseDown: () => {}, From 9fe1a16c0a3ddd52fa8eb4adf7ac5bb5b66d0778 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Wed, 11 Feb 2026 17:03:12 -0500 Subject: [PATCH 66/94] send bothh pinch and pan --- src/vim-web/core-viewers/shared/touchHandler.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index 176999f6a..a91ce30ee 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -150,17 +150,13 @@ export class TouchHandler extends BaseInputHandler { ) const dist = p1.distanceTo(p2) - const prevDist = this._touch1.distanceTo(this._touch2) - const min = Math.min(size.x, size.y) - const zoomDelta = Math.abs(dist - prevDist) / min this._touch = p this._touch1 = p1 this._touch2 = p2 - if (moveDelta.length() > zoomDelta) { - this.onDoubleDrag(moveDelta) - } else if (this._startDist) { + this.onDoubleDrag(moveDelta) + if (this._startDist) { this.onPinchOrSpread(dist / this._startDist) } } From 0bbda33e17764a29ec33c6889fa048c6a17bafe0 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 09:31:50 -0500 Subject: [PATCH 67/94] better touch --- .../core-viewers/shared/touchHandler.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index a91ce30ee..8b55645cb 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -53,10 +53,16 @@ export class TouchHandler extends BaseInputHandler { this._lastTapMs && time - this._lastTapMs < this.DOUBLE_TAP_DELAY_MS this._lastTapMs = time + const rect = this._canvas.getBoundingClientRect() + const screenPos = new THREE.Vector2( + (position.x - rect.left) / rect.width, + (position.y - rect.top) / rect.height + ) + if(double) - this.onDoubleTap?.(position) + this.onDoubleTap?.(screenPos) else - this.onTap?.(position) + this.onTap?.(screenPos) } private onTouchStart = (event: TouchEvent) => { @@ -75,11 +81,10 @@ export class TouchHandler extends BaseInputHandler { this._touch = this.average(this._touch1, this._touch2) this._startDist = this._touch1.distanceTo(this._touch2) - const size = this.getCanvasSize() const rect = this._canvas.getBoundingClientRect() const screenCenter = new THREE.Vector2( - (this._touch.x - window.scrollX - rect.left) / rect.width, - (this._touch.y - window.scrollY - rect.top) / rect.height + (this._touch.x - rect.left) / rect.width, + (this._touch.y - rect.top) / rect.height ) this.onPinchStart?.(screenCenter) } @@ -163,6 +168,14 @@ export class TouchHandler extends BaseInputHandler { } private onTouchEnd = (event: TouchEvent) => { + // 2→1 finger: transition to single-finger drag + if (event.touches.length === 1) { + this._touch = this.touchToVector(event.touches[0]) + this._touch1 = this._touch2 = this._startDist = undefined + return + } + + // All fingers lifted: check for tap, then reset if (this.isSingleTouch() && this._touchStart && this._touch) { const touchDurationMs = Date.now() - this._touchStartTime const length = this._touch.distanceTo(this._touchStart) @@ -186,7 +199,7 @@ export class TouchHandler extends BaseInputHandler { } private touchToVector (touch: Touch) { - return new THREE.Vector2(touch.pageX, touch.pageY) + return new THREE.Vector2(touch.clientX, touch.clientY) } private average (p1: THREE.Vector2, p2: THREE.Vector2): THREE.Vector2 { From 7480d25c3446876476ab167effb2ec7e3401a234 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 10:01:11 -0500 Subject: [PATCH 68/94] input constants --- .../core-viewers/shared/inputConstants.ts | 44 +++++++++++++++++++ .../core-viewers/shared/inputHandler.ts | 13 +++++- .../core-viewers/shared/mouseHandler.ts | 33 +++++++++----- .../core-viewers/shared/touchHandler.ts | 10 ++--- .../core-viewers/ultra/inputAdapter.ts | 18 +++++++- 5 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 src/vim-web/core-viewers/shared/inputConstants.ts diff --git a/src/vim-web/core-viewers/shared/inputConstants.ts b/src/vim-web/core-viewers/shared/inputConstants.ts new file mode 100644 index 000000000..2eb977777 --- /dev/null +++ b/src/vim-web/core-viewers/shared/inputConstants.ts @@ -0,0 +1,44 @@ +/** + * Shared constants for input handling across all device handlers + */ + +/** + * Maximum distance (in normalized canvas units) a pointer can move + * and still be considered a click (not a drag) + */ +export const CLICK_MOVEMENT_THRESHOLD = 0.003 + +/** + * Maximum distance (in pixels) between two clicks + * to be considered a double-click + */ +export const DOUBLE_CLICK_DISTANCE_THRESHOLD = 5 + +/** + * Maximum time (in milliseconds) between two clicks + * to be considered a double-click + */ +export const DOUBLE_CLICK_TIME_THRESHOLD = 300 + +/** + * Maximum duration (in milliseconds) for a touch to be considered a tap + */ +export const TAP_DURATION_MS = 500 + +/** + * Maximum distance (in pixels) a touch can move + * and still be considered a tap (not a drag) + */ +export const TAP_MOVEMENT_THRESHOLD = 5 + +/** + * Minimum move speed for keyboard camera movement. + * Negative values result in slower movement via Math.pow(1.25, speed): + * -10 → 0.107x speed, 0 → 1x speed, 10 → 9.31x speed + */ +export const MIN_MOVE_SPEED = -10 + +/** + * Maximum move speed for keyboard camera movement + */ +export const MAX_MOVE_SPEED = 10 diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index 52b063807..ed39bb684 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -6,6 +6,7 @@ import { KeyboardHandler } from './keyboardHandler' import { MouseHandler } from './mouseHandler' import { TouchHandler } from './touchHandler' import { IInputAdapter } from './inputAdapter' +import { MIN_MOVE_SPEED, MAX_MOVE_SPEED } from './inputConstants' export enum PointerMode { ORBIT = 'orbit', @@ -87,7 +88,15 @@ export class InputHandler extends BaseInputHandler { } // Mouse controls - this.mouse.onContextMenu = (pos: THREE.Vector2) => this._onContextMenu.dispatch(pos); + this.mouse.onContextMenu = (pos: THREE.Vector2) => { + // Convert canvas-relative coords (0-1) back to client coords (pixels) for menu positioning + const rect = canvas.getBoundingClientRect() + const clientPos = new THREE.Vector2( + pos.x * rect.width + rect.left, + pos.y * rect.height + rect.top + ) + this._onContextMenu.dispatch(clientPos) + }; this.mouse.onButtonDown = adapter.mouseDown this.mouse.onMouseMove = adapter.mouseMove this.mouse.onButtonUp = (pos: THREE.Vector2, button: number) => { @@ -155,7 +164,7 @@ export class InputHandler extends BaseInputHandler { } set moveSpeed (value: number) { - this._moveSpeed = value + this._moveSpeed = Math.max(MIN_MOVE_SPEED, Math.min(MAX_MOVE_SPEED, value)) this._onSettingsChanged.dispatch() } diff --git a/src/vim-web/core-viewers/shared/mouseHandler.ts b/src/vim-web/core-viewers/shared/mouseHandler.ts index 93f689a34..14eac0365 100644 --- a/src/vim-web/core-viewers/shared/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/mouseHandler.ts @@ -1,4 +1,5 @@ import { BaseInputHandler } from "./baseInputHandler"; +import { CLICK_MOVEMENT_THRESHOLD, DOUBLE_CLICK_DISTANCE_THRESHOLD, DOUBLE_CLICK_TIME_THRESHOLD } from "./inputConstants"; import * as THREE from 'three'; import * as Utils from "../../utils"; @@ -29,9 +30,10 @@ export class MouseHandler extends BaseInputHandler { } protected addListeners(): void { - + this.reg(this._canvas, 'pointerdown', e => { this.handlePointerDown(e); }); this.reg(this._canvas, 'pointerup', e => { this.handlePointerUp(e); }); + this.reg(this._canvas, 'pointercancel', e => { this.handlePointerCancel(e); }); this.reg(this._canvas, 'pointermove', e => { this.handlePointerMove(e); }); this.reg(this._canvas, 'wheel', e => { this.onMouseScroll(e); }); } @@ -77,7 +79,16 @@ export class MouseHandler extends BaseInputHandler { } this.handleContextMenu(event); - + + } + + private handlePointerCancel(event: PointerEvent): void { + if (event.pointerType !== 'mouse') return; + // Pointer was cancelled (e.g., user switched windows/tabs) + // Clean up all state + this._capture.onPointerCancel(event); + this._dragHandler.onPointerUp(); + this._clickHandler.onPointerUp(); } private async handleMouseClick(event: PointerEvent): Promise { @@ -97,14 +108,15 @@ export class MouseHandler extends BaseInputHandler { private async handleContextMenu(event: PointerEvent): Promise { if (event.pointerType !== 'mouse') return; if(event.button !== 2) return; - + const pos = this.relativePosition(event); if (!Utils.almostEqual(this._lastMouseDownPosition, pos, 0.01)) { return; } - this.onContextMenu?.(new THREE.Vector2(event.clientX, event.clientY)); + // Use canvas-relative coordinates for consistency with other events + this.onContextMenu?.(pos); } @@ -159,6 +171,10 @@ class CaptureHandler { this.release() } + onPointerCancel(event: PointerEvent) { + this.release() + } + private release(){ if (this._id >= 0 ) { this._canvas.releasePointerCapture(this._id); @@ -170,14 +186,13 @@ class CaptureHandler { class ClickHandler { private _moved: boolean = false; private _startPosition: THREE.Vector2 = new THREE.Vector2(); - private _clickThreshold: number = 0.003 ; onPointerDown(pos: THREE.Vector2): void { this._moved = false; this._startPosition.copy(pos); } onPointerMove(pos: THREE.Vector2): void { - if (pos.distanceTo(this._startPosition) > this._clickThreshold) { + if (pos.distanceTo(this._startPosition) > CLICK_MOVEMENT_THRESHOLD) { this._moved = true; } } @@ -192,9 +207,7 @@ class ClickHandler { class DoubleClickHandler { private _lastClickTime: number = 0; - private _clickDelay: number = 300; // Max time between clicks for double-click private _lastClickPosition: THREE.Vector2 | null = null; - private _positionThreshold: number = 5; // Max pixel distance between clicks isDoubleClick(event: MouseEvent): boolean { const currentTime = Date.now(); @@ -203,9 +216,9 @@ class DoubleClickHandler { const isClose = this._lastClickPosition !== null && - this._lastClickPosition.distanceTo(currentPosition) < this._positionThreshold; + this._lastClickPosition.distanceTo(currentPosition) < DOUBLE_CLICK_DISTANCE_THRESHOLD; - const isWithinTime = timeDiff < this._clickDelay; + const isWithinTime = timeDiff < DOUBLE_CLICK_TIME_THRESHOLD; this._lastClickTime = currentTime; this._lastClickPosition = currentPosition; diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index 8b55645cb..865d8d592 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -4,14 +4,12 @@ import * as THREE from 'three' import { BaseInputHandler } from './baseInputHandler'; +import { TAP_DURATION_MS, TAP_MOVEMENT_THRESHOLD, DOUBLE_CLICK_TIME_THRESHOLD } from './inputConstants'; /** * Manages user touch inputs. */ export class TouchHandler extends BaseInputHandler { - private readonly TAP_DURATION_MS: number = 500 - private readonly DOUBLE_TAP_DELAY_MS = 500 - private readonly TAP_MAX_MOVE_PIXEL = 5 private readonly ZOOM_SPEED = 1 private readonly MOVE_SPEED = 100 @@ -50,7 +48,7 @@ export class TouchHandler extends BaseInputHandler { private _onTap = (position: THREE.Vector2) => { const time = Date.now() const double = - this._lastTapMs && time - this._lastTapMs < this.DOUBLE_TAP_DELAY_MS + this._lastTapMs && time - this._lastTapMs < DOUBLE_CLICK_TIME_THRESHOLD this._lastTapMs = time const rect = this._canvas.getBoundingClientRect() @@ -180,8 +178,8 @@ export class TouchHandler extends BaseInputHandler { const touchDurationMs = Date.now() - this._touchStartTime const length = this._touch.distanceTo(this._touchStart) if ( - touchDurationMs < this.TAP_DURATION_MS && - length < this.TAP_MAX_MOVE_PIXEL + touchDurationMs < TAP_DURATION_MS && + length < TAP_MOVEMENT_THRESHOLD ) { this._onTap(this._touch) } diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index 20071e751..a3aed224d 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -103,7 +103,23 @@ function createAdapter(viewer: Viewer): IInputAdapter { }, pinchStart: () => {}, pinchZoom: (totalRatio: number) => { - viewer.rpc.RPCMouseScrollEvent(totalRatio >= 1 ? -1 : 1); + // Convert ratio to scroll steps with better granularity + // log2(ratio) gives us: 2x zoom = +1, 0.5x zoom = -1 + const logRatio = Math.log2(totalRatio); + // Quantize to ±1/2/3 steps based on magnitude + let steps: number; + if (Math.abs(logRatio) < 0.3) { + steps = 0; // Too small, ignore + } else if (Math.abs(logRatio) < 0.7) { + steps = Math.sign(logRatio) * 1; + } else if (Math.abs(logRatio) < 1.2) { + steps = Math.sign(logRatio) * 2; + } else { + steps = Math.sign(logRatio) * 3; + } + if (steps !== 0) { + viewer.rpc.RPCMouseScrollEvent(-steps); // Negative because scroll up = zoom in + } }, keyDown: (code: string) => { return sendKey(viewer, code, true); From 068e36ea643f0d4db0801463b57efb16eabd9a91 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 10:13:22 -0500 Subject: [PATCH 69/94] rmovd input allocations --- .../core-viewers/shared/inputHandler.ts | 5 +- .../core-viewers/shared/mouseHandler.ts | 30 +++-- .../core-viewers/shared/touchHandler.ts | 108 ++++++++++-------- 3 files changed, 84 insertions(+), 59 deletions(-) diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index ed39bb684..775f05e7b 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -274,6 +274,9 @@ export class InputHandler extends BaseInputHandler { } } +// Reusable vector to avoid per-frame allocations +const _tempRotation = new THREE.Vector2() + function toRotation (delta: THREE.Vector2, speed: number) { - return delta.clone().negate().multiplyScalar(180 * speed) + return _tempRotation.copy(delta).negate().multiplyScalar(180 * speed) } \ No newline at end of file diff --git a/src/vim-web/core-viewers/shared/mouseHandler.ts b/src/vim-web/core-viewers/shared/mouseHandler.ts index 14eac0365..29cce6370 100644 --- a/src/vim-web/core-viewers/shared/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/mouseHandler.ts @@ -14,6 +14,9 @@ export class MouseHandler extends BaseInputHandler { private _doubleClickHandler: DoubleClickHandler = new DoubleClickHandler(); private _clickHandler: ClickHandler = new ClickHandler(); + // Reusable vectors to avoid per-frame allocations + private _tempPosition = new THREE.Vector2(); + onButtonDown: (pos: THREE.Vector2, button: number) => void; onButtonUp: (pos: THREE.Vector2, button: number) => void; onMouseMove: (event: THREE.Vector2) => void; @@ -142,10 +145,11 @@ export class MouseHandler extends BaseInputHandler { private relativePosition(event: PointerEvent | MouseEvent): THREE.Vector2 { const rect = this._canvas.getBoundingClientRect(); - return new THREE.Vector2( + this._tempPosition.set( event.offsetX / rect.width, event.offsetY / rect.height ); + return this._tempPosition; } } @@ -228,11 +232,14 @@ class DoubleClickHandler { } class DragHandler { - private _lastDragPosition:THREE.Vector2 | null = null; + private _lastDragPosition = new THREE.Vector2(); + private _hasDrag = false; private _button: number; private _onDrag: DragCallback; - + + // Reusable vector to avoid per-frame allocations + private _delta = new THREE.Vector2(); constructor( onDrag: DragCallback) { this._onDrag = onDrag; @@ -243,7 +250,8 @@ class DragHandler { * @param pos The initial pointer position. */ onPointerDown(pos: THREE.Vector2, button: number): void { - this._lastDragPosition = pos; + this._lastDragPosition.copy(pos); + this._hasDrag = true; this._button = button; } @@ -254,11 +262,13 @@ class DragHandler { */ onPointerMove(pos: THREE.Vector2): void { - if (this._lastDragPosition) { - const x = pos.x - this._lastDragPosition.x - const y = pos.y - this._lastDragPosition.y - this._lastDragPosition = pos; - this._onDrag(new THREE.Vector2(x,y), this._button); + if (this._hasDrag) { + this._delta.set( + pos.x - this._lastDragPosition.x, + pos.y - this._lastDragPosition.y + ); + this._lastDragPosition.copy(pos); + this._onDrag(this._delta, this._button); } } @@ -266,7 +276,7 @@ class DragHandler { * Ends the drag operation and resets the last drag position. */ onPointerUp(): void { - this._lastDragPosition = null; + this._hasDrag = false; } } diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index 865d8d592..62d3bf72a 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -14,23 +14,34 @@ export class TouchHandler extends BaseInputHandler { private readonly MOVE_SPEED = 100 onTap: (position: THREE.Vector2) => void - onDoubleTap: (position: THREE.Vector2) => void + onDoubleTap: (position: THREE.Vector2) => void onDrag: (delta: THREE.Vector2) => void onDoubleDrag: (delta: THREE.Vector2) => void onPinchStart: (screenCenter: THREE.Vector2) => void onPinchOrSpread: (totalRatio: number) => void + // Reusable vectors to avoid per-frame allocations + private _tempVec = new THREE.Vector2() + private _tempVec2 = new THREE.Vector2() + private _tempDelta = new THREE.Vector2() + private _tempSize = new THREE.Vector2() + private _tempScreenPos = new THREE.Vector2() + constructor (canvas: HTMLCanvasElement) { super(canvas) } - // State - private _touch: THREE.Vector2 | undefined = undefined // When one touch occurs this is the value, when two or more touches occur it is the average of the first two. - private _touch1: THREE.Vector2 | undefined = undefined // The first touch when multiple touches occur, otherwise left undefined - private _touch2: THREE.Vector2 | undefined = undefined // The second touch when multiple touches occur, otherwise left undefined + // State - these store actual values, not references + private _touch = new THREE.Vector2() + private _touch1 = new THREE.Vector2() + private _touch2 = new THREE.Vector2() + private _hasTouch = false + private _hasTouch1 = false + private _hasTouch2 = false private _touchStartTime: number | undefined = undefined // In ms since epoch private _lastTapMs: number | undefined - private _touchStart: THREE.Vector2 | undefined + private _touchStart = new THREE.Vector2() + private _hasTouchStart = false private _startDist: number | undefined protected override addListeners (): void { @@ -42,7 +53,8 @@ export class TouchHandler extends BaseInputHandler { } override reset = () => { - this._touch = this._touch1 = this._touch2 = this._touchStartTime = this._startDist = undefined + this._hasTouch = this._hasTouch1 = this._hasTouch2 = this._hasTouchStart = false + this._touchStartTime = this._startDist = undefined } private _onTap = (position: THREE.Vector2) => { @@ -52,15 +64,15 @@ export class TouchHandler extends BaseInputHandler { this._lastTapMs = time const rect = this._canvas.getBoundingClientRect() - const screenPos = new THREE.Vector2( + this._tempScreenPos.set( (position.x - rect.left) / rect.width, (position.y - rect.top) / rect.height ) if(double) - this.onDoubleTap?.(screenPos) + this.onDoubleTap?.(this._tempScreenPos) else - this.onTap?.(screenPos) + this.onTap?.(this._tempScreenPos) } private onTouchStart = (event: TouchEvent) => { @@ -71,22 +83,25 @@ export class TouchHandler extends BaseInputHandler { this._touchStartTime = Date.now() if (event.touches.length === 1) { - this._touch = this.touchToVector(event.touches[0]) - this._touch1 = this._touch2 = undefined + this._touch.copy(this.touchToVector(event.touches[0])) + this._hasTouch = true + this._hasTouch1 = this._hasTouch2 = false } else if (event.touches.length === 2) { - this._touch1 = this.touchToVector(event.touches[0]) - this._touch2 = this.touchToVector(event.touches[1]) - this._touch = this.average(this._touch1, this._touch2) + this._touch1.copy(this.touchToVector(event.touches[0])) + this._touch2.copy(this.touchToVector(event.touches[1])) + this._touch.copy(this.average(this._touch1, this._touch2)) + this._hasTouch = this._hasTouch1 = this._hasTouch2 = true this._startDist = this._touch1.distanceTo(this._touch2) const rect = this._canvas.getBoundingClientRect() - const screenCenter = new THREE.Vector2( + this._tempScreenPos.set( (this._touch.x - rect.left) / rect.width, (this._touch.y - rect.top) / rect.height ) - this.onPinchStart?.(screenCenter) + this.onPinchStart?.(this._tempScreenPos) } - this._touchStart = this._touch + this._touchStart.copy(this._touch) + this._hasTouchStart = true } private toRotation (delta: THREE.Vector2, speed: number) { @@ -123,42 +138,35 @@ export class TouchHandler extends BaseInputHandler { private onTouchMove = (event: TouchEvent) => { if (event.cancelable) event.preventDefault() if (!event.touches.length) return - if (!this._touch) return + if (!this._hasTouch) return if (event.touches.length === 1) { const pos = this.touchToVector(event.touches[0]) - const size = this.getCanvasSize() - const delta = pos - .clone() - .sub(this._touch) - .multiply(new THREE.Vector2(1 / size.x, 1 / size.y)) - - this._touch = pos - this.onDrag(delta) + this._tempSize.set(this._canvas.clientWidth, this._canvas.clientHeight) + this._tempDelta.copy(pos).sub(this._touch) + .multiply(this._tempSize.set(1 / this._tempSize.x, 1 / this._tempSize.y)) + + this._touch.copy(pos) + this.onDrag(this._tempDelta) return } - if (!this._touch1 || !this._touch2) return + if (!this._hasTouch1 || !this._hasTouch2) return if (event.touches.length >= 2) { const p1 = this.touchToVector(event.touches[0]) const p2 = this.touchToVector(event.touches[1]) const p = this.average(p1, p2) - const size = this.getCanvasSize() - const moveDelta = this._touch - .clone() - .sub(p) - .multiply( - // -1 to invert movement - new THREE.Vector2(-1 / size.x, -1 / size.y) - ) + this._tempSize.set(this._canvas.clientWidth, this._canvas.clientHeight) + this._tempDelta.copy(this._touch).sub(p) + .multiply(this._tempSize.set(-1 / this._tempSize.x, -1 / this._tempSize.y)) const dist = p1.distanceTo(p2) - this._touch = p - this._touch1 = p1 - this._touch2 = p2 + this._touch.copy(p) + this._touch1.copy(p1) + this._touch2.copy(p2) - this.onDoubleDrag(moveDelta) + this.onDoubleDrag(this._tempDelta) if (this._startDist) { this.onPinchOrSpread(dist / this._startDist) } @@ -168,13 +176,15 @@ export class TouchHandler extends BaseInputHandler { private onTouchEnd = (event: TouchEvent) => { // 2→1 finger: transition to single-finger drag if (event.touches.length === 1) { - this._touch = this.touchToVector(event.touches[0]) - this._touch1 = this._touch2 = this._startDist = undefined + this._touch.copy(this.touchToVector(event.touches[0])) + this._hasTouch = true + this._hasTouch1 = this._hasTouch2 = false + this._startDist = undefined return } // All fingers lifted: check for tap, then reset - if (this.isSingleTouch() && this._touchStart && this._touch) { + if (this.isSingleTouch() && this._hasTouchStart) { const touchDurationMs = Date.now() - this._touchStartTime const length = this._touch.distanceTo(this._touchStart) if ( @@ -189,19 +199,21 @@ export class TouchHandler extends BaseInputHandler { private isSingleTouch (): boolean { return ( - this._touch !== undefined && + this._hasTouch && this._touchStartTime !== undefined && - this._touch1 === undefined && - this._touch2 === undefined + !this._hasTouch1 && + !this._hasTouch2 ) } private touchToVector (touch: Touch) { - return new THREE.Vector2(touch.clientX, touch.clientY) + this._tempVec.set(touch.clientX, touch.clientY) + return this._tempVec } private average (p1: THREE.Vector2, p2: THREE.Vector2): THREE.Vector2 { - return p1.clone().lerp(p2, 0.5) + this._tempVec2.copy(p1).lerp(p2, 0.5) + return this._tempVec2 } /** From b0b8316581a1cb8879070bb0ac934a9190ed6e1f Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 10:48:40 -0500 Subject: [PATCH 70/94] dev docs --- CLAUDE.md | 54 +- INPUT.md | 717 ++++++++++++++++++ .../core-viewers/shared/inputHandler.ts | 14 +- .../core-viewers/shared/keyboardHandler.ts | 9 +- .../core-viewers/shared/mouseHandler.ts | 87 ++- .../core-viewers/shared/touchHandler.ts | 14 +- 6 files changed, 858 insertions(+), 37 deletions(-) create mode 100644 INPUT.md diff --git a/CLAUDE.md b/CLAUDE.md index 097264f7f..bd9f2e92d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,30 +263,72 @@ state.useMemo((v) => compute(v)) --- -## Input Bindings +## Input System + +> **📖 Full Documentation**: See [INPUT.md](./INPUT.md) for architecture, patterns, and advanced customization + +### Default Bindings | Input | Action | |-------|--------| | Left Drag | Orbit (or mode-specific) | | Right Drag | Look | | Middle Drag | Pan | -| Wheel | Zoom to cursor (if over geometry) or zoom toward current target | +| Wheel | Zoom to cursor | | Click | Select | | Shift+Click | Add to selection | | Double-Click | Frame | -| WASD | Move camera | +| WASD / Arrows | Move camera | +| E / Q | Move up / down | +| Shift | 3x speed boost | | F | Frame selection | | Escape | Clear selection | | P | Toggle orthographic | +| Space | Toggle Orbit/Look | | Home | Reset camera | +| +/- | Adjust move speed | + +### Quick API Reference ```typescript -// Register custom key handler -viewer.core.inputs.keyboard.registerKeyDown('KeyR', 'replace', () => { - // Custom action on R key +const inputs = viewer.core.inputs + +// Pointer modes: ORBIT | LOOK | PAN | ZOOM | RECT +inputs.pointerActive = VIM.Core.PointerMode.LOOK +inputs.moveSpeed = 5 // Range: -10 to +10, exponential (1.25^speed) + +// Custom key handlers (mode: 'replace' | 'append' | 'prepend') +inputs.keyboard.registerKeyDown('KeyR', 'replace', () => { /* ... */ }) +inputs.keyboard.registerKeyUp(['Equal', 'NumpadAdd'], 'replace', () => { + inputs.moveSpeed++ }) + +// Custom callbacks (all positions are canvas-relative [0-1]) +inputs.mouse.onClick = (pos, ctrl) => { /* ... */ } +inputs.mouse.onDrag = (delta, button) => { /* ... */ } +inputs.touch.onPinchOrSpread = (ratio) => { /* ... */ } ``` +### Common Patterns + +**Plan View (Top-Down, Pan-Only)**: +```typescript +viewer.camera.snap().orbitTowards(new VIM.THREE.Vector3(0, 0, -1)) +viewer.camera.allowedRotation = new VIM.THREE.Vector2(0, 0) +viewer.camera.orthographic = true +viewer.core.inputs.pointerActive = VIM.Core.PointerMode.PAN +``` + +**Custom Tool Mode**: +```typescript +const originalMode = viewer.core.inputs.pointerActive +viewer.core.inputs.pointerActive = VIM.Core.PointerMode.RECT +viewer.core.inputs.mouse.onClick = (pos) => { /* custom logic */ } +// Restore: viewer.core.inputs.pointerActive = originalMode +``` + +See [INPUT.md](./INPUT.md) for more patterns, coordinate systems, performance optimization, and debugging techniques + --- ## Ultra Viewer diff --git a/INPUT.md b/INPUT.md new file mode 100644 index 000000000..d89574708 --- /dev/null +++ b/INPUT.md @@ -0,0 +1,717 @@ +# Input System Architecture + +The VIM Web input system uses a layered adapter pattern to decouple device handling from viewer-specific camera control. This allows the same input handlers to work with both local WebGL rendering and remote Ultra streaming. + +## Architecture Overview + +``` ++-------------------------------------------------------------+ +| DOM Events | +| (pointer, touch, keyboard, wheel) | ++-------------------------------------------------------------+ + | + v ++-------------------------------------------------------------+ +| Device Handlers (Framework-agnostic) | ++-------------------------------------------------------------+ +| MouseHandler -> Pointer events -> Drag, Click, DoubleClick | +| TouchHandler -> Touch events -> Tap, Pinch, Pan | +| KeyboardHandler -> Key events -> WASD movement | ++-------------------------------------------------------------+ + | + v ++-------------------------------------------------------------+ +| InputHandler (Coordinator) | ++-------------------------------------------------------------+ +| - Manages pointer modes (ORBIT/LOOK/PAN/ZOOM) | +| - Routes events to appropriate adapter methods | +| - Applies speed multipliers | ++-------------------------------------------------------------+ + | + v ++-------------------------------------------------------------+ +| IInputAdapter (Viewer-specific implementation) | ++-------------------------------------------------------------+ +| WebGL: Direct THREE.js camera manipulation | +| Ultra: RPC calls to server-side renderer | ++-------------------------------------------------------------+ +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `inputHandler.ts` | Main coordinator, manages modes and routing | +| `mouseHandler.ts` | Mouse/pointer event handling | +| `touchHandler.ts` | Touch gesture recognition | +| `keyboardHandler.ts` | Keyboard input with WASD movement | +| `inputAdapter.ts` | Interface definition | +| `webgl/viewer/inputAdapter.ts` | WebGL implementation | +| `ultra/inputAdapter.ts` | Ultra/RPC implementation | +| `inputConstants.ts` | Shared constants | + +--- + +## Device Handlers + +### MouseHandler + +Handles mouse and pointer input with support for: +- **Click detection**: Single-click with modifier key support +- **Double-click**: Within 300ms threshold +- **Drag**: Continuous movement with button tracking +- **Context menu**: Right-click without drag +- **Wheel**: Zoom with Ctrl modifier detection + +**Coordinate System**: All positions are canvas-relative [0-1]: +- (0, 0) = top-left corner +- (1, 1) = bottom-right corner + +**Button Mapping**: +- 0 = Left button (primary interaction) +- 1 = Middle button (pan override) +- 2 = Right button (look override) + +### TouchHandler + +Handles touch gestures with support for: + +**Single Touch**: +- **Tap**: Touch down + up within 500ms, <5px movement +- **Double-tap**: Two taps within 300ms +- **Drag**: Touch down + continuous movement + +**Two Touch**: +- **Pinch/Spread**: Distance between fingers changes (zoom) +- **Pan**: Average position of fingers moves (two-finger pan) +- **Simultaneous**: Both pinch and pan can occur at the same time + +**State Management**: +- Uses explicit boolean flags (`_hasTouch`, `_hasTouch1`, `_hasTouch2`) +- Storage vectors hold actual position values +- Temp vectors are reused for calculations + +### KeyboardHandler + +Handles keyboard input with: +- **WASD movement**: Continuous camera movement while keys are held +- **Arrow keys**: Alternative movement keys +- **E/Q**: Up/down movement +- **Shift**: 3x speed multiplier +- **Custom handlers**: Register key down/up callbacks + +**Callback Modes**: +- `'replace'`: Replace existing handler +- `'append'`: Run after existing handler +- `'prepend'`: Run before existing handler + +```typescript +viewer.core.inputs.keyboard.registerKeyDown('KeyR', 'replace', () => { + // Custom action on R key press +}) + +viewer.core.inputs.keyboard.registerKeyUp(['Equal', 'NumpadAdd'], 'replace', () => { + viewer.core.inputs.moveSpeed++ +}) +``` + +--- + +## Pointer Mode System + +Three-tier mode management for flexible interaction: + +### 1. pointerActive (Primary Mode) + +The user's preferred interaction style: +- **ORBIT**: Rotate camera around target point (target stays fixed) +- **LOOK**: First-person rotation (camera rotates in place) +- **PAN**: Translate camera parallel to view plane +- **ZOOM**: Move camera along view direction (dolly) +- **RECT**: Custom mode for rectangle selection tools + +Set by: User preference, Space key toggle +Used for: Left-click dragging + +```typescript +viewer.core.inputs.pointerActive = VIM.Core.PointerMode.LOOK +``` + +### 2. pointerOverride (Temporary Mode) + +Temporarily overrides the active mode during interaction: +- Set by: Middle-click (PAN), Right-click (LOOK) +- Cleared on: Mouse up +- Used for: Icon display, temporary mode switches + +```typescript +// Automatically set by mouse handler on middle/right click +// Returns to pointerActive when mouse is released +``` + +### 3. pointerFallback (Last Active Mode) + +Stores the last ORBIT or LOOK mode: +- Used when: Returning from temporary modes +- Maintains: User's base interaction preference + +**Priority**: `override > active > fallback` + +```typescript +// Listen for mode changes +viewer.core.inputs.onPointerModeChanged.subscribe(() => { + console.log('Mode changed to:', viewer.core.inputs.pointerActive) +}) + +viewer.core.inputs.onPointerOverrideChanged.subscribe(() => { + console.log('Override:', viewer.core.inputs.pointerOverride) +}) +``` + +--- + +## Coordinate Systems + +### Canvas-Relative [0-1] + +Used internally for all input callbacks: +- Range: [0, 1] x [0, 1] +- (0, 0) = top-left of canvas +- (1, 1) = bottom-right of canvas +- Independent of canvas pixel size +- Used for: clicks, drags, raycasting + +```typescript +viewer.core.inputs.mouse.onClick = (pos, ctrl) => { + console.log(pos) // THREE.Vector2(0.5, 0.5) = center +} +``` + +### Client Pixels (Absolute) + +Used for UI positioning: +- Range: screen coordinates in pixels +- Used for: context menus, overlays, tooltips + +**Conversion Example**: +```typescript +const rect = canvas.getBoundingClientRect() +const clientX = canvasRelative.x * rect.width + rect.left +const clientY = canvasRelative.y * rect.height + rect.top +``` + +### World Space + +Convert canvas position to 3D world coordinates: +```typescript +const result = await viewer.core.raycaster.raycastFromScreen(canvasPos) +if (result?.worldPosition) { + console.log(result.worldPosition) // THREE.Vector3 in world space +} +``` + +--- + +## Performance: Reusable Vectors + +Critical pattern to eliminate per-frame garbage collection. + +### Temp Vectors (Reusable) + +Used for intermediate calculations, **never store references**: + +```typescript +class MouseHandler { + private _tempPosition = new THREE.Vector2() // Reused every frame + + private relativePosition(event: PointerEvent): THREE.Vector2 { + this._tempPosition.set( + event.offsetX / rect.width, + event.offsetY / rect.height + ) + return this._tempPosition // Safe to return for immediate use + } +} +``` + +### Storage Vectors (State) + +Hold actual state values, **must copy from temp**: + +```typescript +class DragHandler { + private _lastDragPosition = new THREE.Vector2() // Storage + private _delta = new THREE.Vector2() // Temp + + onPointerDown(pos: THREE.Vector2): void { + this._lastDragPosition.copy(pos) // ✅ Copy values + // this._lastDragPosition = pos // ❌ Stores reference! + } + + onPointerMove(pos: THREE.Vector2): void { + this._delta.set( + pos.x - this._lastDragPosition.x, + pos.y - this._lastDragPosition.y + ) + this._lastDragPosition.copy(pos) // ✅ Copy values + this._onDrag(this._delta) + } +} +``` + +### Common Pitfall + +```typescript +// ❌ WRONG: Stores reference to temp vector +const pos = handler.relativePosition(event) +this._lastPosition = pos + +// Next frame, pos points to new values +// this._lastPosition also sees new values (same object!) +// Delta calculation: newPos - newPos = (0, 0) + +// ✅ CORRECT: Copy values +const pos = handler.relativePosition(event) +this._lastPosition.copy(pos) // Creates independent copy of values +``` + +**Performance Impact**: +- Without reusable vectors: 600+ allocations/second during touch gestures +- With reusable vectors: Near-zero allocations per frame + +--- + +## IInputAdapter Pattern + +The adapter interface decouples input handling from viewer implementation. + +### Interface Definition + +```typescript +interface IInputAdapter { + init: () => void + + // Camera controls + orbitCamera: (rotation: THREE.Vector2) => void + rotateCamera: (rotation: THREE.Vector2) => void + panCamera: (delta: THREE.Vector2) => void + dollyCamera: (delta: THREE.Vector2) => void + moveCamera: (velocity: THREE.Vector3) => void + + // Camera actions + toggleOrthographic: () => void + toggleCameraOrbitMode: () => void + resetCamera: () => void + frameCamera: () => void + + // Interaction + selectAtPointer: (pos: THREE.Vector2, add: boolean) => Promise + frameAtPointer: (pos: THREE.Vector2) => Promise + zoom: (value: number, screenPos?: THREE.Vector2) => Promise + + // Touch + pinchStart: (screenPos: THREE.Vector2) => Promise + pinchZoom: (totalRatio: number) => void + + // Selection + clearSelection: () => void + + // Raw events (for custom handling) + keyDown: (keyCode: string) => boolean + keyUp: (keyCode: string) => boolean + mouseDown: (pos: THREE.Vector2, button: number) => void + mouseMove: (pos: THREE.Vector2) => void + mouseUp: (pos: THREE.Vector2, button: number) => void +} +``` + +### WebGL Implementation + +Direct THREE.js camera manipulation: + +```typescript +function createAdapter(viewer: Viewer): IInputAdapter { + return { + orbitCamera: (rotation: THREE.Vector2) => { + viewer.camera.snap().orbit(rotation) + }, + + zoom: async (value: number, screenPos?: THREE.Vector2) => { + if (screenPos) { + // Zoom towards point under cursor + const result = await viewer.raycaster.raycastFromScreen(screenPos) + if (result?.worldPosition) { + viewer.camera.lerp(0.25).zoomTowards(value, result.worldPosition, screenPos) + return + } + } + // Fallback: zoom towards current target + viewer.camera.lerp(0.75).zoom(value) + }, + + pinchStart: async (screenPos: THREE.Vector2) => { + // Raycast to find world point under pinch center + const result = await viewer.raycaster.raycastFromScreen(screenPos) + if (result?.worldPosition) { + _pinchWorldPoint = result.worldPosition.clone() + _pinchStartDist = viewer.camera.position.distanceTo(result.worldPosition) + } + } + } +} +``` + +### Ultra Implementation + +RPC calls to server-side renderer: + +```typescript +function createAdapter(viewer: Viewer): IInputAdapter { + return { + orbitCamera: (rotation: THREE.Vector2) => { + viewer.rpc.RPCOrbitEvent(rotation.x, rotation.y) + }, + + zoom: async (value: number, screenPos?: THREE.Vector2) => { + // Ultra handles zoom server-side, screenPos not used + viewer.rpc.RPCMouseScrollEvent(value >= 1 ? -1 : 1) + }, + + pinchZoom: (totalRatio: number) => { + // Convert ratio to discrete scroll steps + const logRatio = Math.log2(totalRatio) + let steps: number + if (Math.abs(logRatio) < 0.3) steps = 0 + else if (Math.abs(logRatio) < 0.7) steps = Math.sign(logRatio) * 1 + else if (Math.abs(logRatio) < 1.2) steps = Math.sign(logRatio) * 2 + else steps = Math.sign(logRatio) * 3 + + if (steps !== 0) { + viewer.rpc.RPCMouseScrollEvent(-steps) + } + } + } +} +``` + +--- + +## Speed Settings + +### Move Speed (WASD) + +Exponential scaling via `Math.pow(1.25, moveSpeed)`: + +```typescript +viewer.core.inputs.moveSpeed = 5 // 1.25^5 = 3.05x speed +viewer.core.inputs.moveSpeed = 0 // 1.25^0 = 1.0x (default) +viewer.core.inputs.moveSpeed = -5 // 1.25^-5 = 0.32x (slow) +``` + +**Range**: -10 to +10 +- -10 -> 0.107x speed (very slow) +- 0 -> 1.0x speed (default) +- +10 -> 9.31x speed (very fast) + +**Adjust via keyboard**: +- `+` or `NumpadAdd`: Increase speed +- `-` or `NumpadSubtract`: Decrease speed +- `Ctrl` + `Wheel`: Adjust speed via mouse wheel + +### Other Speeds + +```typescript +viewer.core.inputs.scrollSpeed // Wheel zoom multiplier (default: 1.75) +viewer.core.inputs.rotateSpeed // LOOK mode rotation speed (default: 1) +viewer.core.inputs.orbitSpeed // ORBIT mode rotation speed (default: 1) +``` + +--- + +## Common Patterns + +### Plan View Setup + +Lock to top-down orthographic view with pan-only interaction: + +```typescript +// Set camera to top-down +viewer.camera.snap().orbitTowards(new VIM.THREE.Vector3(0, 0, -1)) + +// Lock rotation +viewer.camera.allowedRotation = new VIM.THREE.Vector2(0, 0) + +// Enable orthographic projection +viewer.camera.orthographic = true + +// Switch to pan mode +viewer.core.inputs.pointerActive = VIM.Core.PointerMode.PAN +``` + +### Custom Tool Mode + +Implement a custom rectangle selection tool: + +```typescript +const inputs = viewer.core.inputs +const originalMode = inputs.pointerActive +const originalOnClick = inputs.mouse.onClick + +// Enter tool mode +inputs.pointerActive = VIM.Core.PointerMode.RECT +inputs.mouse.onClick = (pos, ctrl) => { + // Custom rectangle selection logic + startRectangle(pos) +} + +// Exit tool mode +const exitTool = () => { + inputs.pointerActive = originalMode + inputs.mouse.onClick = originalOnClick +} +``` + +### Multi-Key Bindings + +Register the same action for multiple keys: + +```typescript +// Speed controls +viewer.core.inputs.keyboard.registerKeyUp( + ['Equal', 'NumpadAdd'], + 'replace', + () => viewer.core.inputs.moveSpeed++ +) + +viewer.core.inputs.keyboard.registerKeyUp( + ['Minus', 'NumpadSubtract'], + 'replace', + () => viewer.core.inputs.moveSpeed-- +) +``` + +### Custom Touch Gestures + +Override default pinch behavior: + +```typescript +viewer.core.inputs.touch.onPinchOrSpread = (ratio) => { + // Custom zoom logic + const zoomAmount = Math.log2(ratio) * 0.5 + viewer.camera.snap().zoom(1 + zoomAmount) +} + +viewer.core.inputs.touch.onDoubleTap = async (pos) => { + const result = await viewer.core.raycaster.raycastFromScreen(pos) + if (result) { + // Custom double-tap action + viewer.camera.lerp(1).frame(result.object) + } +} +``` + +### Disable Specific Inputs + +```typescript +// Disable keyboard +viewer.core.inputs.keyboard.unregister() + +// Disable mouse +viewer.core.inputs.mouse.unregister() + +// Disable touch +viewer.core.inputs.touch.unregister() + +// Re-enable all +viewer.core.inputs.registerAll() +``` + +--- + +## Extension Points + +### Custom Key Handlers + +```typescript +// Add handler with mode support +viewer.core.inputs.keyboard.registerKeyDown('KeyR', 'replace', () => { + console.log('R key pressed') +}) + +// Chain handlers +viewer.core.inputs.keyboard.registerKeyDown('KeyR', 'append', () => { + console.log('This runs after existing handler') +}) + +viewer.core.inputs.keyboard.registerKeyDown('KeyR', 'prepend', () => { + console.log('This runs before existing handler') +}) +``` + +### Custom Mouse Callbacks + +All callbacks receive canvas-relative positions [0-1]: + +```typescript +const inputs = viewer.core.inputs + +// Override click behavior +inputs.mouse.onClick = (pos, ctrl) => { + if (ctrl) { + // Custom Ctrl+Click action + } else { + // Custom click action + } +} + +// Add drag behavior +inputs.mouse.onDrag = (delta, button) => { + if (button === 0) { // Left button + // Custom drag action + console.log('Drag delta:', delta) + } +} + +// Add button down/up handlers +inputs.mouse.onButtonDown = (pos, button) => { + console.log('Button down:', button, 'at', pos) +} + +inputs.mouse.onButtonUp = (pos, button) => { + console.log('Button up:', button, 'at', pos) +} +``` + +### Custom Pointer Modes + +Create your own pointer modes for custom tools: + +```typescript +// Save current mode +const originalMode = viewer.core.inputs.pointerActive + +// Enter custom mode +viewer.core.inputs.pointerActive = VIM.Core.PointerMode.RECT + +// Override drag behavior for this mode +const originalDrag = viewer.core.inputs.mouse.onDrag +viewer.core.inputs.mouse.onDrag = (delta, button) => { + if (viewer.core.inputs.pointerActive === VIM.Core.PointerMode.RECT) { + // Custom rectangle drawing logic + updateRectangle(delta) + } else { + originalDrag(delta, button) + } +} + +// Exit custom mode +viewer.core.inputs.pointerActive = originalMode +``` + +--- + +## Debugging + +### Input State Inspection + +```typescript +const inputs = viewer.core.inputs + +// Check current mode +console.log('Active mode:', inputs.pointerActive) +console.log('Override:', inputs.pointerOverride) +console.log('Fallback:', inputs.pointerFallback) + +// Check speeds +console.log('Move speed:', inputs.moveSpeed) +console.log('Scroll speed:', inputs.scrollSpeed) + +// Check key state +console.log('W pressed:', inputs.keyboard.isKeyPressed('KeyW')) +``` + +### Event Logging + +```typescript +// Log all pointer mode changes +viewer.core.inputs.onPointerModeChanged.subscribe(() => { + console.log('Mode changed:', viewer.core.inputs.pointerActive) +}) + +// Log all clicks +viewer.core.inputs.mouse.onClick = (pos, ctrl) => { + console.log('Click at:', pos, 'Ctrl:', ctrl) + // Call default handler + viewer.core.inputs.mouse.onClick?.(pos, ctrl) +} +``` + +--- + +## Constants Reference + +From `inputConstants.ts`: + +```typescript +// Click detection +CLICK_MOVEMENT_THRESHOLD = 0.003 // Canvas-relative units + +// Double-click detection +DOUBLE_CLICK_DISTANCE_THRESHOLD = 5 // Pixels +DOUBLE_CLICK_TIME_THRESHOLD = 300 // Milliseconds + +// Touch detection +TAP_DURATION_MS = 500 // Maximum tap duration +TAP_MOVEMENT_THRESHOLD = 5 // Pixels + +// Move speed range +MIN_MOVE_SPEED = -10 // 1.25^-10 = 0.107x +MAX_MOVE_SPEED = 10 // 1.25^10 = 9.31x +``` + +--- + +## Best Practices + +1. **Always use `.copy()` when storing from temp vectors** + ```typescript + this._lastPosition.copy(pos) // ✅ Correct + this._lastPosition = pos // ❌ Stores reference + ``` + +2. **Never store references to callback vectors** + ```typescript + onClick: (pos) => { + this.clickPos = pos.clone() // ✅ Clone if storing + this.clickPos = pos // ❌ Reference to temp vector + } + ``` + +3. **Use appropriate coordinate systems** + - Canvas-relative [0-1] for raycasting and internal logic + - Client pixels for UI positioning + - World space for 3D operations + +4. **Handle mode changes properly** + ```typescript + // Save original state + const original = viewer.core.inputs.pointerActive + + // Change mode + viewer.core.inputs.pointerActive = VIM.Core.PointerMode.PAN + + // Restore on exit + viewer.core.inputs.pointerActive = original + ``` + +5. **Clean up custom handlers** + ```typescript + // Save originals + const originalClick = viewer.core.inputs.mouse.onClick + + // Override + viewer.core.inputs.mouse.onClick = customHandler + + // Restore on dispose + viewer.core.inputs.mouse.onClick = originalClick + ``` diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index 775f05e7b..bd32bffd5 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -1,3 +1,9 @@ +/** + * Input coordinator that routes device events to viewer-specific adapters. + * + * See INPUT.md for architecture, pointer modes, and customization patterns. + */ + import { SignalDispatcher } from 'ste-signals' import { SimpleEventDispatcher } from 'ste-simple-events' import * as THREE from 'three' @@ -8,6 +14,7 @@ import { TouchHandler } from './touchHandler' import { IInputAdapter } from './inputAdapter' import { MIN_MOVE_SPEED, MAX_MOVE_SPEED } from './inputConstants' +/** Pointer interaction modes. See INPUT.md for details. */ export enum PointerMode { ORBIT = 'orbit', LOOK = 'look', @@ -16,7 +23,6 @@ export enum PointerMode { RECT = 'rect' } - interface InputSettings{ orbit: boolean scrollSpeed: number @@ -25,6 +31,12 @@ interface InputSettings{ orbitSpeed: number } +/** + * Input handler coordinator. + * + * Manages three-tier pointer modes (active/override/fallback). + * See INPUT.md for mode system and customization. + */ export class InputHandler extends BaseInputHandler { /** diff --git a/src/vim-web/core-viewers/shared/keyboardHandler.ts b/src/vim-web/core-viewers/shared/keyboardHandler.ts index 3801aa017..b77615c19 100644 --- a/src/vim-web/core-viewers/shared/keyboardHandler.ts +++ b/src/vim-web/core-viewers/shared/keyboardHandler.ts @@ -5,7 +5,14 @@ import * as THREE from 'three'; import { BaseInputHandler } from './baseInputHandler'; -type CallbackMode = 'replace' | 'append' | 'prepend'; +/** + * Mode for registering key handlers. + * + * - replace: Replace existing handler + * - append: Run after existing handler + * - prepend: Run before existing handler + */ +export type CallbackMode = 'replace' | 'append' | 'prepend'; /** * KeyboardHandler diff --git a/src/vim-web/core-viewers/shared/mouseHandler.ts b/src/vim-web/core-viewers/shared/mouseHandler.ts index 29cce6370..a8a84fe8b 100644 --- a/src/vim-web/core-viewers/shared/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/mouseHandler.ts @@ -1,3 +1,9 @@ +/** + * Mouse and pointer input handler. + * + * See INPUT.md for architecture, coordinate systems, and performance patterns. + */ + import { BaseInputHandler } from "./baseInputHandler"; import { CLICK_MOVEMENT_THRESHOLD, DOUBLE_CLICK_DISTANCE_THRESHOLD, DOUBLE_CLICK_TIME_THRESHOLD } from "./inputConstants"; @@ -6,7 +12,12 @@ import * as Utils from "../../utils"; type DragCallback = (delta: THREE.Vector2, button: number) => void; -// Existing MouseHandler class +/** + * Handles mouse/pointer input with support for click, drag, and double-click detection. + * + * Uses Pointer Events API for unified mouse/pen/touch handling. + * Filters to mouse-only via pointerType check. + */ export class MouseHandler extends BaseInputHandler { private _lastMouseDownPosition = new THREE.Vector2(0, 0); private _capture: CaptureHandler; @@ -17,13 +28,60 @@ export class MouseHandler extends BaseInputHandler { // Reusable vectors to avoid per-frame allocations private _tempPosition = new THREE.Vector2(); + /** + * Called on every pointer down event. + * @param pos Canvas-relative position [0-1] + * @param button 0=left, 1=middle, 2=right + */ onButtonDown: (pos: THREE.Vector2, button: number) => void; + + /** + * Called on every pointer up event. + * @param pos Canvas-relative position [0-1] + * @param button 0=left, 1=middle, 2=right + */ onButtonUp: (pos: THREE.Vector2, button: number) => void; + + /** + * Called on every pointer move (regardless of button state). + * @param pos Canvas-relative position [0-1] + */ onMouseMove: (event: THREE.Vector2) => void; - onDrag: DragCallback; // Callback for drag movement + + /** + * Called during pointer drag (pointer down + move). + * @param delta Canvas-relative movement since last frame + * @param button Button being dragged (0=left, 1=middle, 2=right) + * @note Delta is a reference to reusable vector - do not store! + */ + onDrag: DragCallback; + + /** + * Called on single click (pointer down + up without drag). + * @param position Canvas-relative click position [0-1] + * @param ctrl True if Shift or Ctrl was held + */ onClick: (position: THREE.Vector2, ctrl: boolean) => void; + + /** + * Called on double-click within 300ms. + * @param position Canvas-relative click position [0-1] + */ onDoubleClick: (position: THREE.Vector2) => void; + + /** + * Called on mouse wheel scroll. + * @param value Scroll direction: +1 (down) or -1 (up) + * @param ctrl True if Ctrl key was held + * @param clientX Client X coordinate in pixels + * @param clientY Client Y coordinate in pixels + */ onWheel: (value: number, ctrl: boolean, clientX: number, clientY: number) => void; + + /** + * Called on right-click without drag. + * @param position Canvas-relative position [0-1] + */ onContextMenu: (position: THREE.Vector2) => void; constructor(canvas: HTMLCanvasElement) { @@ -231,50 +289,35 @@ class DoubleClickHandler { } } +/** Tracks drag operations with zero-allocation delta calculation. */ class DragHandler { - private _lastDragPosition = new THREE.Vector2(); + private _lastDragPosition = new THREE.Vector2(); // Storage (use .copy()) private _hasDrag = false; private _button: number; - private _onDrag: DragCallback; - - // Reusable vector to avoid per-frame allocations - private _delta = new THREE.Vector2(); + private _delta = new THREE.Vector2(); // Temp (reused) constructor( onDrag: DragCallback) { this._onDrag = onDrag; } - /** - * Initializes the drag operation by setting the starting position. - * @param pos The initial pointer position. - */ onPointerDown(pos: THREE.Vector2, button: number): void { - this._lastDragPosition.copy(pos); + this._lastDragPosition.copy(pos); // MUST copy, not assign reference this._hasDrag = true; this._button = button; } - /** - * Updates the drag operation, calculates and returns the delta movement. - * @param pos The current pointer position. - * @returns The delta movement vector, or null if no previous position exists. - */ onPointerMove(pos: THREE.Vector2): void { - if (this._hasDrag) { this._delta.set( pos.x - this._lastDragPosition.x, pos.y - this._lastDragPosition.y ); - this._lastDragPosition.copy(pos); + this._lastDragPosition.copy(pos); // MUST copy this._onDrag(this._delta, this._button); } } - /** - * Ends the drag operation and resets the last drag position. - */ onPointerUp(): void { this._hasDrag = false; } diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index 62d3bf72a..5a3a3fc82 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -1,14 +1,14 @@ /** - * @module viw-webgl-viewer/inputs + * Touch input handler with support for tap, pinch, and pan gestures. + * + * See INPUT.md for gesture recognition, state management, and performance patterns. */ import * as THREE from 'three' import { BaseInputHandler } from './baseInputHandler'; import { TAP_DURATION_MS, TAP_MOVEMENT_THRESHOLD, DOUBLE_CLICK_TIME_THRESHOLD } from './inputConstants'; -/** - * Manages user touch inputs. - */ +/** Handles touch gestures with zero-allocation vector reuse. */ export class TouchHandler extends BaseInputHandler { private readonly ZOOM_SPEED = 1 private readonly MOVE_SPEED = 100 @@ -20,7 +20,7 @@ export class TouchHandler extends BaseInputHandler { onPinchStart: (screenCenter: THREE.Vector2) => void onPinchOrSpread: (totalRatio: number) => void - // Reusable vectors to avoid per-frame allocations + // Temp vectors (reused, never store references!) private _tempVec = new THREE.Vector2() private _tempVec2 = new THREE.Vector2() private _tempDelta = new THREE.Vector2() @@ -31,14 +31,14 @@ export class TouchHandler extends BaseInputHandler { super(canvas) } - // State - these store actual values, not references + // Storage vectors (actual values, use .copy() when storing from temp) private _touch = new THREE.Vector2() private _touch1 = new THREE.Vector2() private _touch2 = new THREE.Vector2() private _hasTouch = false private _hasTouch1 = false private _hasTouch2 = false - private _touchStartTime: number | undefined = undefined // In ms since epoch + private _touchStartTime: number | undefined = undefined private _lastTapMs: number | undefined private _touchStart = new THREE.Vector2() private _hasTouchStart = false From 20fd93ac6c52caaa06fd5e34ba55e7fc0cacb11a Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 11:27:00 -0500 Subject: [PATCH 71/94] removed spacebar bindings --- src/vim-web/core-viewers/shared/inputAdapter.ts | 11 +++++------ src/vim-web/core-viewers/shared/inputHandler.ts | 11 +---------- src/vim-web/core-viewers/shared/mouseHandler.ts | 11 +++++++---- src/vim-web/core-viewers/shared/touchHandler.ts | 9 +++------ src/vim-web/core-viewers/ultra/inputAdapter.ts | 4 ---- src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts | 7 +------ src/vim-web/react-viewers/state/pointerState.ts | 5 ++--- 7 files changed, 19 insertions(+), 39 deletions(-) diff --git a/src/vim-web/core-viewers/shared/inputAdapter.ts b/src/vim-web/core-viewers/shared/inputAdapter.ts index bb65c19b5..59bdad5e7 100644 --- a/src/vim-web/core-viewers/shared/inputAdapter.ts +++ b/src/vim-web/core-viewers/shared/inputAdapter.ts @@ -4,10 +4,9 @@ export interface IInputAdapter{ init: () => void toggleOrthographic: () => void - toggleCameraOrbitMode: () => void resetCamera: () => void clearSelection: () => void - frameCamera: () => void + frameCamera: () => void | Promise moveCamera: (value: THREE.Vector3) => void orbitCamera: (value: THREE.Vector2) => void rotateCamera: (value: THREE.Vector2) => void @@ -21,10 +20,10 @@ export interface IInputAdapter{ mouseUp: (pos: THREE.Vector2, button: number) => void mouseMove: (pos: THREE.Vector2) => void - selectAtPointer: (pos: THREE.Vector2, add: boolean) => void - frameAtPointer: (pos: THREE.Vector2) => void - zoom: (value: number, screenPos?: THREE.Vector2) => void + selectAtPointer: (pos: THREE.Vector2, add: boolean) => void | Promise + frameAtPointer: (pos: THREE.Vector2) => void | Promise + zoom: (value: number, screenPos?: THREE.Vector2) => void | Promise - pinchStart: (screenPos: THREE.Vector2) => void + pinchStart: (screenPos: THREE.Vector2) => void | Promise pinchZoom: (totalRatio: number) => void } \ No newline at end of file diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/inputHandler.ts index bd32bffd5..75682f8ba 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/inputHandler.ts @@ -59,7 +59,6 @@ export class InputHandler extends BaseInputHandler { private _moveSpeed: number private _pointerActive: PointerMode = PointerMode.ORBIT - private _pointerFallback: PointerMode = PointerMode.LOOK private _pointerOverride: PointerMode | undefined private _onPointerOverrideChanged = new SignalDispatcher() private _onPointerModeChanged = new SignalDispatcher() @@ -87,7 +86,6 @@ export class InputHandler extends BaseInputHandler { this.keyboard.registerKeyUp('KeyP', 'replace', () => adapter.toggleOrthographic()); this.keyboard.registerKeyUp(['Equal', 'NumpadAdd'], 'replace', () => this.moveSpeed++); this.keyboard.registerKeyUp(['Minus', 'NumpadSubtract'], 'replace', () => this.moveSpeed--); - this.keyboard.registerKeyUp('Space', 'replace', () => adapter.toggleCameraOrbitMode()); this.keyboard.registerKeyUp('Home', 'replace', () => adapter.resetCamera()); this.keyboard.registerKeyUp('Escape', 'replace', () => adapter.clearSelection()); this.keyboard.registerKeyUp('KeyF', 'replace', () => { @@ -184,13 +182,6 @@ export class InputHandler extends BaseInputHandler { return this._onSettingsChanged.asEvent() } - /** - * Returns the last main mode (orbit, look) that was active. - */ - get pointerFallback () : PointerMode { - return this._pointerFallback - } - /** * Returns current pointer mode. */ @@ -201,7 +192,7 @@ export class InputHandler extends BaseInputHandler { /** * A temporary pointer mode used for temporary icons. */ - get pointerOverride (): PointerMode { + get pointerOverride (): PointerMode | undefined { return this._pointerOverride } diff --git a/src/vim-web/core-viewers/shared/mouseHandler.ts b/src/vim-web/core-viewers/shared/mouseHandler.ts index a8a84fe8b..cbc431fcb 100644 --- a/src/vim-web/core-viewers/shared/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/mouseHandler.ts @@ -170,13 +170,12 @@ export class MouseHandler extends BaseInputHandler { if (event.pointerType !== 'mouse') return; if(event.button !== 2) return; - const pos = this.relativePosition(event); - - if (!Utils.almostEqual(this._lastMouseDownPosition, pos, 0.01)) { + // Don't show context menu if there was a drag + if (this._clickHandler.wasMoved()) { return; } - // Use canvas-relative coordinates for consistency with other events + const pos = this.relativePosition(event); this.onContextMenu?.(pos); } @@ -265,6 +264,10 @@ class ClickHandler { if (event.button !== 0) return false; // Only left button return !this._moved; } + + wasMoved(): boolean { + return this._moved; + } } class DoubleClickHandler { diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts index 5a3a3fc82..3dd9480e0 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/touchHandler.ts @@ -10,9 +10,6 @@ import { TAP_DURATION_MS, TAP_MOVEMENT_THRESHOLD, DOUBLE_CLICK_TIME_THRESHOLD } /** Handles touch gestures with zero-allocation vector reuse. */ export class TouchHandler extends BaseInputHandler { - private readonly ZOOM_SPEED = 1 - private readonly MOVE_SPEED = 100 - onTap: (position: THREE.Vector2) => void onDoubleTap: (position: THREE.Vector2) => void onDrag: (delta: THREE.Vector2) => void @@ -147,7 +144,7 @@ export class TouchHandler extends BaseInputHandler { .multiply(this._tempSize.set(1 / this._tempSize.x, 1 / this._tempSize.y)) this._touch.copy(pos) - this.onDrag(this._tempDelta) + this.onDrag?.(this._tempDelta) return } @@ -166,9 +163,9 @@ export class TouchHandler extends BaseInputHandler { this._touch1.copy(p1) this._touch2.copy(p2) - this.onDoubleDrag(this._tempDelta) + this.onDoubleDrag?.(this._tempDelta) if (this._startDist) { - this.onPinchOrSpread(dist / this._startDist) + this.onPinchOrSpread?.(dist / this._startDist) } } } diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index a3aed224d..7dfb64d9b 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -57,10 +57,6 @@ function createAdapter(viewer: Viewer): IInputAdapter { toggleOrthographic: () => { console.log('toggleOrthographic. Not supported yet'); }, - toggleCameraOrbitMode: () => { - // A bit hacky, but we send a space key event to toggle orbit mode - viewer.rpc.RPCKeyEvent(CODE_TO_KEYCODE['Space'], true); - }, resetCamera: () => { viewer.camera.restoreSavedPosition(); }, diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index 4acde4aa8..d824d58af 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -1,5 +1,5 @@ import {type IInputAdapter} from "../../shared/inputAdapter" -import {InputHandler, PointerMode} from "../../shared/inputHandler" +import {InputHandler} from "../../shared/inputHandler" import { Viewer } from "./viewer" import { Element3D } from '../loader/element3d' import * as THREE from 'three' @@ -37,11 +37,6 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { toggleOrthographic: () => { viewer.camera.orthographic = !viewer.camera.orthographic }, - toggleCameraOrbitMode: () => { - viewer.inputs.pointerActive = viewer.inputs.pointerActive === PointerMode.ORBIT - ? PointerMode.LOOK - : PointerMode.ORBIT; - }, resetCamera: () => { viewer.camera.lerp(0.75).reset() diff --git a/src/vim-web/react-viewers/state/pointerState.ts b/src/vim-web/react-viewers/state/pointerState.ts index 9ac6c925b..89dde6369 100644 --- a/src/vim-web/react-viewers/state/pointerState.ts +++ b/src/vim-web/react-viewers/state/pointerState.ts @@ -12,9 +12,8 @@ export function getPointerState (viewer: Core.Webgl.Viewer) { }, []) const onModeBtn = (target: Core.PointerMode) => { - const next = mode === target ? viewer.inputs.pointerFallback : target - viewer.inputs.pointerActive = next - setMode(next) + viewer.inputs.pointerActive = target + setMode(target) } return { From 3173d53358cb5487d35bea24265e9e3a021b8756 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 11:56:24 -0500 Subject: [PATCH 72/94] input folder and refactors --- CLAUDE.md | 1 - INPUT.md | 2 +- src/vim-web/core-viewers/shared/index.ts | 9 +- .../shared/{ => input}/baseInputHandler.ts | 0 .../shared/input/clickDetection.ts | 73 ++++++++ .../core-viewers/shared/input/coordinates.ts | 72 ++++++++ .../shared/input/doubleClickDetection.ts | 73 ++++++++ .../core-viewers/shared/input/dragTracking.ts | 80 +++++++++ .../core-viewers/shared/input/index.ts | 29 ++++ .../shared/{ => input}/inputAdapter.ts | 0 .../shared/{ => input}/inputConstants.ts | 0 .../shared/{ => input}/inputHandler.ts | 8 +- .../shared/{ => input}/keyboardHandler.ts | 0 .../shared/{ => input}/mouseHandler.ts | 163 ++---------------- .../shared/input/pointerCapture.ts | 65 +++++++ .../shared/{ => input}/touchHandler.ts | 44 +---- .../core-viewers/ultra/inputAdapter.ts | 4 +- .../core-viewers/webgl/viewer/inputAdapter.ts | 4 +- 18 files changed, 423 insertions(+), 204 deletions(-) rename src/vim-web/core-viewers/shared/{ => input}/baseInputHandler.ts (100%) create mode 100644 src/vim-web/core-viewers/shared/input/clickDetection.ts create mode 100644 src/vim-web/core-viewers/shared/input/coordinates.ts create mode 100644 src/vim-web/core-viewers/shared/input/doubleClickDetection.ts create mode 100644 src/vim-web/core-viewers/shared/input/dragTracking.ts create mode 100644 src/vim-web/core-viewers/shared/input/index.ts rename src/vim-web/core-viewers/shared/{ => input}/inputAdapter.ts (100%) rename src/vim-web/core-viewers/shared/{ => input}/inputConstants.ts (100%) rename src/vim-web/core-viewers/shared/{ => input}/inputHandler.ts (97%) rename src/vim-web/core-viewers/shared/{ => input}/keyboardHandler.ts (100%) rename src/vim-web/core-viewers/shared/{ => input}/mouseHandler.ts (59%) create mode 100644 src/vim-web/core-viewers/shared/input/pointerCapture.ts rename src/vim-web/core-viewers/shared/{ => input}/touchHandler.ts (82%) diff --git a/CLAUDE.md b/CLAUDE.md index bd9f2e92d..de625ecf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -284,7 +284,6 @@ state.useMemo((v) => compute(v)) | F | Frame selection | | Escape | Clear selection | | P | Toggle orthographic | -| Space | Toggle Orbit/Look | | Home | Reset camera | | +/- | Adjust move speed | diff --git a/INPUT.md b/INPUT.md index d89574708..78a1e3b1c 100644 --- a/INPUT.md +++ b/INPUT.md @@ -130,7 +130,7 @@ The user's preferred interaction style: - **ZOOM**: Move camera along view direction (dolly) - **RECT**: Custom mode for rectangle selection tools -Set by: User preference, Space key toggle +Set by: User preference or application default Used for: Left-click dragging ```typescript diff --git a/src/vim-web/core-viewers/shared/index.ts b/src/vim-web/core-viewers/shared/index.ts index 1df68c7db..888b6d67f 100644 --- a/src/vim-web/core-viewers/shared/index.ts +++ b/src/vim-web/core-viewers/shared/index.ts @@ -1,16 +1,9 @@ // Full export -export * from './inputHandler' +export * from './input' export * from './loadResult' -// Partial export -export {PointerMode} from './inputHandler' - // Type export -export type * from './baseInputHandler' -export type * from './keyboardHandler' -export type * from './mouseHandler' export type * from './raycaster' export type * from './selection' -export type * from './touchHandler' export type * from './vim' export type * from './vimCollection' \ No newline at end of file diff --git a/src/vim-web/core-viewers/shared/baseInputHandler.ts b/src/vim-web/core-viewers/shared/input/baseInputHandler.ts similarity index 100% rename from src/vim-web/core-viewers/shared/baseInputHandler.ts rename to src/vim-web/core-viewers/shared/input/baseInputHandler.ts diff --git a/src/vim-web/core-viewers/shared/input/clickDetection.ts b/src/vim-web/core-viewers/shared/input/clickDetection.ts new file mode 100644 index 000000000..4ef750baa --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/clickDetection.ts @@ -0,0 +1,73 @@ +/** + * Click detection helper that distinguishes clicks from drags. + * + * Tracks pointer movement to determine if an interaction was a click + * (minimal movement) or a drag (movement exceeds threshold). + */ + +import * as THREE from 'three' + +/** + * Detects whether a pointer interaction is a click or drag based on movement. + * + * Usage: + * - Call onPointerDown when pointer is pressed + * - Call onPointerMove during pointer movement + * - Call onPointerUp when pointer is released + * - Check isClick() or wasMoved() to determine interaction type + */ +export class ClickDetector { + private _moved: boolean = false + private _startPosition: THREE.Vector2 = new THREE.Vector2() + private _threshold: number + + /** + * @param threshold - Maximum movement distance to still be considered a click + */ + constructor(threshold: number) { + this._threshold = threshold + } + + /** + * Call when pointer is pressed down. + * @param pos - Current pointer position + */ + onPointerDown(pos: THREE.Vector2): void { + this._moved = false + this._startPosition.copy(pos) + } + + /** + * Call during pointer movement. + * @param pos - Current pointer position + */ + onPointerMove(pos: THREE.Vector2): void { + if (pos.distanceTo(this._startPosition) > this._threshold) { + this._moved = true + } + } + + /** + * Call when pointer is released. + */ + onPointerUp(): void { + // State is preserved until next onPointerDown + } + + /** + * Check if the pointer moved beyond threshold (was a drag). + */ + wasMoved(): boolean { + return this._moved + } + + /** + * Check if this was a click for a specific button. + * @param button - Button number (0=left, 1=middle, 2=right) + * @returns True if button matches and pointer didn't move beyond threshold + */ + isClick(button: number, targetButton: number = 0): boolean { + if (button !== targetButton) return false + return !this._moved + } +} diff --git a/src/vim-web/core-viewers/shared/input/coordinates.ts b/src/vim-web/core-viewers/shared/input/coordinates.ts new file mode 100644 index 000000000..5c5cb46d4 --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/coordinates.ts @@ -0,0 +1,72 @@ +/** + * Coordinate conversion utilities for input handling. + * + * All functions modify the result parameter in-place and return it for chaining. + * This pattern avoids allocations and matches THREE.js conventions. + */ + +import * as THREE from 'three' + +/** + * Converts pointer/mouse event to canvas-relative coordinates [0-1]. + * + * @param event - Pointer or mouse event + * @param canvas - Target canvas element + * @param result - Vector to store result (modified in-place) + * @returns The result vector (for chaining) + */ +export function pointerToCanvas( + event: PointerEvent | MouseEvent, + canvas: HTMLCanvasElement, + result: THREE.Vector2 +): THREE.Vector2 { + const rect = canvas.getBoundingClientRect() + return result.set( + event.offsetX / rect.width, + event.offsetY / rect.height + ) +} + +/** + * Converts client pixel coordinates to canvas-relative [0-1]. + * + * @param clientX - Client X coordinate in pixels + * @param clientY - Client Y coordinate in pixels + * @param canvas - Target canvas element + * @param result - Vector to store result (modified in-place) + * @returns The result vector (for chaining) + */ +export function clientToCanvas( + clientX: number, + clientY: number, + canvas: HTMLCanvasElement, + result: THREE.Vector2 +): THREE.Vector2 { + const rect = canvas.getBoundingClientRect() + return result.set( + (clientX - rect.left) / rect.width, + (clientY - rect.top) / rect.height + ) +} + +/** + * Converts canvas-relative [0-1] to client pixel coordinates. + * + * @param canvasX - Canvas X coordinate [0-1] + * @param canvasY - Canvas Y coordinate [0-1] + * @param canvas - Target canvas element + * @param result - Vector to store result (modified in-place) + * @returns The result vector (for chaining) + */ +export function canvasToClient( + canvasX: number, + canvasY: number, + canvas: HTMLCanvasElement, + result: THREE.Vector2 +): THREE.Vector2 { + const rect = canvas.getBoundingClientRect() + return result.set( + canvasX * rect.width + rect.left, + canvasY * rect.height + rect.top + ) +} diff --git a/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts b/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts new file mode 100644 index 000000000..c0b26fdd4 --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts @@ -0,0 +1,73 @@ +/** + * Double-click/tap detection helper. + * + * Detects when two clicks/taps occur within a time window + * and distance threshold. + */ + +import * as THREE from 'three' + +/** + * Detects double-click or double-tap gestures. + * + * Checks if two activations occur: + * - Within the time threshold (e.g., 300ms) + * - Within the distance threshold (e.g., 10 pixels) + * + * Usage: + * - Call check() on each click/tap with the position + * - Returns true if this was a double-click, false otherwise + */ +export class DoubleClickDetector { + private _lastTime: number = 0 + private _lastPosition: THREE.Vector2 | null = null + private _timeThreshold: number + private _distanceThreshold: number + + /** + * @param timeThreshold - Max time between clicks (ms) + * @param distanceThreshold - Max distance between clicks (pixels or canvas units) + */ + constructor(timeThreshold: number, distanceThreshold: number) { + this._timeThreshold = timeThreshold + this._distanceThreshold = distanceThreshold + } + + /** + * Check if this click/tap is a double-click. + * + * @param position - Position of current click/tap + * @returns True if this was a double-click/tap + * + * Note: This method has side effects - it updates internal state + * to track the last click position and time. + */ + check(position: THREE.Vector2): boolean { + const currentTime = Date.now() + const timeDiff = currentTime - this._lastTime + + const isClose = + this._lastPosition !== null && + this._lastPosition.distanceTo(position) < this._distanceThreshold + + const isWithinTime = timeDiff < this._timeThreshold + + // Update state for next check + this._lastTime = currentTime + if (this._lastPosition === null) { + this._lastPosition = position.clone() + } else { + this._lastPosition.copy(position) + } + + return isClose && isWithinTime + } + + /** + * Reset the detector state. + */ + reset(): void { + this._lastTime = 0 + this._lastPosition = null + } +} diff --git a/src/vim-web/core-viewers/shared/input/dragTracking.ts b/src/vim-web/core-viewers/shared/input/dragTracking.ts new file mode 100644 index 000000000..e9ab6dcb8 --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/dragTracking.ts @@ -0,0 +1,80 @@ +/** + * Drag tracking helper with zero-allocation delta calculation. + * + * Tracks pointer drag operations and calculates movement deltas + * without creating new objects. + */ + +import * as THREE from 'three' + +/** + * Callback type for drag events. + * @param delta - Movement delta since last frame (reusable vector - do not store!) + * @param button - Button being dragged (0=left, 1=middle, 2=right) + */ +export type DragCallback = (delta: THREE.Vector2, button: number) => void + +/** + * Tracks drag operations with zero-allocation delta calculation. + * + * Usage: + * - Call onPointerDown when drag starts + * - Call onPointerMove during drag + * - Call onPointerUp when drag ends + * - Callback is invoked during onPointerMove with delta + */ +export class DragTracker { + private _lastDragPosition = new THREE.Vector2() // Storage (use .copy()) + private _hasDrag = false + private _button: number = -1 + private _onDrag: DragCallback + private _delta = new THREE.Vector2() // Temp (reused) + + /** + * @param onDrag - Callback invoked during drag with delta and button + */ + constructor(onDrag: DragCallback) { + this._onDrag = onDrag + } + + /** + * Call when pointer is pressed to start potential drag. + * @param pos - Pointer position + * @param button - Button number (0=left, 1=middle, 2=right) + */ + onPointerDown(pos: THREE.Vector2, button: number): void { + this._lastDragPosition.copy(pos) // MUST copy, not assign reference + this._hasDrag = true + this._button = button + } + + /** + * Call during pointer movement. + * Invokes callback with delta if drag is active. + * @param pos - Current pointer position + */ + onPointerMove(pos: THREE.Vector2): void { + if (this._hasDrag) { + this._delta.set( + pos.x - this._lastDragPosition.x, + pos.y - this._lastDragPosition.y + ) + this._lastDragPosition.copy(pos) // MUST copy + this._onDrag(this._delta, this._button) + } + } + + /** + * Call when pointer is released to end drag. + */ + onPointerUp(): void { + this._hasDrag = false + } + + /** + * Check if drag is currently active. + */ + isDragging(): boolean { + return this._hasDrag + } +} diff --git a/src/vim-web/core-viewers/shared/input/index.ts b/src/vim-web/core-viewers/shared/input/index.ts new file mode 100644 index 000000000..3dae581cb --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/index.ts @@ -0,0 +1,29 @@ +/** + * Input system public API. + * + * Exports the client-facing parts of the input system. + * Internal helpers (coordinate conversion, click detection, etc.) are not exported. + */ + +// Main coordinator +export { InputHandler, PointerMode } from './inputHandler' + +// Adapter interface for custom implementations +export type { IInputAdapter } from './inputAdapter' + +// Individual device handlers (for advanced use cases) +export type { MouseHandler } from './mouseHandler' +export type { TouchHandler } from './touchHandler' +export type { KeyboardHandler } from './keyboardHandler' +export type { BaseInputHandler } from './baseInputHandler' + +// Constants (for users who need thresholds/limits) +export { + CLICK_MOVEMENT_THRESHOLD, + DOUBLE_CLICK_DISTANCE_THRESHOLD, + DOUBLE_CLICK_TIME_THRESHOLD, + TAP_DURATION_MS, + TAP_MOVEMENT_THRESHOLD, + MIN_MOVE_SPEED, + MAX_MOVE_SPEED +} from './inputConstants' diff --git a/src/vim-web/core-viewers/shared/inputAdapter.ts b/src/vim-web/core-viewers/shared/input/inputAdapter.ts similarity index 100% rename from src/vim-web/core-viewers/shared/inputAdapter.ts rename to src/vim-web/core-viewers/shared/input/inputAdapter.ts diff --git a/src/vim-web/core-viewers/shared/inputConstants.ts b/src/vim-web/core-viewers/shared/input/inputConstants.ts similarity index 100% rename from src/vim-web/core-viewers/shared/inputConstants.ts rename to src/vim-web/core-viewers/shared/input/inputConstants.ts diff --git a/src/vim-web/core-viewers/shared/inputHandler.ts b/src/vim-web/core-viewers/shared/input/inputHandler.ts similarity index 97% rename from src/vim-web/core-viewers/shared/inputHandler.ts rename to src/vim-web/core-viewers/shared/input/inputHandler.ts index 75682f8ba..31537964c 100644 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/input/inputHandler.ts @@ -13,6 +13,7 @@ import { MouseHandler } from './mouseHandler' import { TouchHandler } from './touchHandler' import { IInputAdapter } from './inputAdapter' import { MIN_MOVE_SPEED, MAX_MOVE_SPEED } from './inputConstants' +import { canvasToClient } from './coordinates' /** Pointer interaction modes. See INPUT.md for details. */ export enum PointerMode { @@ -100,11 +101,8 @@ export class InputHandler extends BaseInputHandler { // Mouse controls this.mouse.onContextMenu = (pos: THREE.Vector2) => { // Convert canvas-relative coords (0-1) back to client coords (pixels) for menu positioning - const rect = canvas.getBoundingClientRect() - const clientPos = new THREE.Vector2( - pos.x * rect.width + rect.left, - pos.y * rect.height + rect.top - ) + const clientPos = new THREE.Vector2() + canvasToClient(pos.x, pos.y, canvas, clientPos) this._onContextMenu.dispatch(clientPos) }; this.mouse.onButtonDown = adapter.mouseDown diff --git a/src/vim-web/core-viewers/shared/keyboardHandler.ts b/src/vim-web/core-viewers/shared/input/keyboardHandler.ts similarity index 100% rename from src/vim-web/core-viewers/shared/keyboardHandler.ts rename to src/vim-web/core-viewers/shared/input/keyboardHandler.ts diff --git a/src/vim-web/core-viewers/shared/mouseHandler.ts b/src/vim-web/core-viewers/shared/input/mouseHandler.ts similarity index 59% rename from src/vim-web/core-viewers/shared/mouseHandler.ts rename to src/vim-web/core-viewers/shared/input/mouseHandler.ts index cbc431fcb..802d8dfbe 100644 --- a/src/vim-web/core-viewers/shared/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/input/mouseHandler.ts @@ -6,11 +6,13 @@ import { BaseInputHandler } from "./baseInputHandler"; import { CLICK_MOVEMENT_THRESHOLD, DOUBLE_CLICK_DISTANCE_THRESHOLD, DOUBLE_CLICK_TIME_THRESHOLD } from "./inputConstants"; +import { pointerToCanvas } from "./coordinates"; +import { ClickDetector } from "./clickDetection"; +import { DoubleClickDetector } from "./doubleClickDetection"; +import { DragTracker, type DragCallback } from "./dragTracking"; +import { PointerCapture } from "./pointerCapture"; import * as THREE from 'three'; -import * as Utils from "../../utils"; - -type DragCallback = (delta: THREE.Vector2, button: number) => void; /** * Handles mouse/pointer input with support for click, drag, and double-click detection. @@ -19,11 +21,10 @@ type DragCallback = (delta: THREE.Vector2, button: number) => void; * Filters to mouse-only via pointerType check. */ export class MouseHandler extends BaseInputHandler { - private _lastMouseDownPosition = new THREE.Vector2(0, 0); - private _capture: CaptureHandler; - private _dragHandler: DragHandler; - private _doubleClickHandler: DoubleClickHandler = new DoubleClickHandler(); - private _clickHandler: ClickHandler = new ClickHandler(); + private _capture: PointerCapture; + private _dragHandler: DragTracker; + private _doubleClickHandler: DoubleClickDetector; + private _clickHandler: ClickDetector; // Reusable vectors to avoid per-frame allocations private _tempPosition = new THREE.Vector2(); @@ -86,8 +87,10 @@ export class MouseHandler extends BaseInputHandler { constructor(canvas: HTMLCanvasElement) { super(canvas); - this._capture = new CaptureHandler(canvas); - this._dragHandler = new DragHandler((delta: THREE.Vector2, button:number) => this.onDrag(delta, button)); + this._capture = new PointerCapture(canvas); + this._dragHandler = new DragTracker((delta: THREE.Vector2, button:number) => this.onDrag(delta, button)); + this._doubleClickHandler = new DoubleClickDetector(DOUBLE_CLICK_TIME_THRESHOLD, DOUBLE_CLICK_DISTANCE_THRESHOLD); + this._clickHandler = new ClickDetector(CLICK_MOVEMENT_THRESHOLD); } protected addListeners(): void { @@ -108,7 +111,6 @@ export class MouseHandler extends BaseInputHandler { const pos = this.relativePosition(event); this.onButtonDown?.(pos, event.button); - this._lastMouseDownPosition = pos; // Start drag this._dragHandler.onPointerDown(pos, event.button); this._clickHandler.onPointerDown(pos); @@ -124,17 +126,17 @@ export class MouseHandler extends BaseInputHandler { // Button up event this.onButtonUp?.(pos, event.button); - this._capture.onPointerUp(event); + this._capture.onPointerUp(); this._dragHandler.onPointerUp(); this._clickHandler.onPointerUp(); // Click type event - if(this._doubleClickHandler.isDoubleClick(event)){ + if(this._doubleClickHandler.check(pos)){ this.handleDoubleClick(event); return } - if(this._clickHandler.isClick(event)){ + if(this._clickHandler.isClick(event.button, 0)){ this.handleMouseClick(event); return } @@ -147,7 +149,7 @@ export class MouseHandler extends BaseInputHandler { if (event.pointerType !== 'mouse') return; // Pointer was cancelled (e.g., user switched windows/tabs) // Clean up all state - this._capture.onPointerCancel(event); + this._capture.onPointerCancel(); this._dragHandler.onPointerUp(); this._clickHandler.onPointerUp(); } @@ -155,13 +157,8 @@ export class MouseHandler extends BaseInputHandler { private async handleMouseClick(event: PointerEvent): Promise { if (event.pointerType !== 'mouse') return; if(event.button !== 0) return; - - const pos = this.relativePosition(event); - - if (!Utils.almostEqual(this._lastMouseDownPosition, pos, 0.01)) { - return; - } + const pos = this.relativePosition(event); const modif = event.getModifierState('Shift') || event.getModifierState('Control'); this.onClick?.(pos, modif); } @@ -201,128 +198,6 @@ export class MouseHandler extends BaseInputHandler { } private relativePosition(event: PointerEvent | MouseEvent): THREE.Vector2 { - const rect = this._canvas.getBoundingClientRect(); - this._tempPosition.set( - event.offsetX / rect.width, - event.offsetY / rect.height - ); - return this._tempPosition; - } -} - -/** - * Small helper class to manage pointer capture on the canvas. - */ -class CaptureHandler { - private _canvas: HTMLCanvasElement; - private _id: number; - - constructor(canvas: HTMLCanvasElement) { - this._canvas = canvas; - this._id = -1; + return pointerToCanvas(event, this._canvas, this._tempPosition); } - - onPointerDown(event: PointerEvent): void { - this.release() - this._canvas.setPointerCapture(event.pointerId); - this._id = event.pointerId; - } - - onPointerUp(event: PointerEvent) { - this.release() - } - - onPointerCancel(event: PointerEvent) { - this.release() - } - - private release(){ - if (this._id >= 0 ) { - this._canvas.releasePointerCapture(this._id); - this._id = -1; - } - } -} - -class ClickHandler { - private _moved: boolean = false; - private _startPosition: THREE.Vector2 = new THREE.Vector2(); - - onPointerDown(pos: THREE.Vector2): void { - this._moved = false; - this._startPosition.copy(pos); - } - onPointerMove(pos: THREE.Vector2): void { - if (pos.distanceTo(this._startPosition) > CLICK_MOVEMENT_THRESHOLD) { - this._moved = true; - } - } - - onPointerUp(): void { } - - isClick(event: PointerEvent): boolean { - if (event.button !== 0) return false; // Only left button - return !this._moved; - } - - wasMoved(): boolean { - return this._moved; - } -} - -class DoubleClickHandler { - private _lastClickTime: number = 0; - private _lastClickPosition: THREE.Vector2 | null = null; - - isDoubleClick(event: MouseEvent): boolean { - const currentTime = Date.now(); - const currentPosition = new THREE.Vector2(event.clientX, event.clientY); - const timeDiff = currentTime - this._lastClickTime; - - const isClose = - this._lastClickPosition !== null && - this._lastClickPosition.distanceTo(currentPosition) < DOUBLE_CLICK_DISTANCE_THRESHOLD; - - const isWithinTime = timeDiff < DOUBLE_CLICK_TIME_THRESHOLD; - - this._lastClickTime = currentTime; - this._lastClickPosition = currentPosition; - - return isClose && isWithinTime; - } -} - -/** Tracks drag operations with zero-allocation delta calculation. */ -class DragHandler { - private _lastDragPosition = new THREE.Vector2(); // Storage (use .copy()) - private _hasDrag = false; - private _button: number; - private _onDrag: DragCallback; - private _delta = new THREE.Vector2(); // Temp (reused) - - constructor( onDrag: DragCallback) { - this._onDrag = onDrag; - } - - onPointerDown(pos: THREE.Vector2, button: number): void { - this._lastDragPosition.copy(pos); // MUST copy, not assign reference - this._hasDrag = true; - this._button = button; - } - - onPointerMove(pos: THREE.Vector2): void { - if (this._hasDrag) { - this._delta.set( - pos.x - this._lastDragPosition.x, - pos.y - this._lastDragPosition.y - ); - this._lastDragPosition.copy(pos); // MUST copy - this._onDrag(this._delta, this._button); - } - } - - onPointerUp(): void { - this._hasDrag = false; - } - } diff --git a/src/vim-web/core-viewers/shared/input/pointerCapture.ts b/src/vim-web/core-viewers/shared/input/pointerCapture.ts new file mode 100644 index 000000000..928874a90 --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/pointerCapture.ts @@ -0,0 +1,65 @@ +/** + * Pointer capture management helper. + * + * Manages pointer capture on a canvas element to ensure + * pointer events are received even when the pointer moves + * outside the canvas bounds. + */ + +/** + * Manages pointer capture for a canvas element. + * + * Pointer capture ensures that pointer events continue to be + * delivered to the canvas even if the pointer moves outside + * the canvas bounds during a drag operation. + * + * Usage: + * - Call onPointerDown when pointer is pressed + * - Call onPointerUp when pointer is released + * - Call onPointerCancel if pointer is cancelled + */ +export class PointerCapture { + private _canvas: HTMLCanvasElement + private _id: number = -1 + + /** + * @param canvas - Canvas element to capture pointer on + */ + constructor(canvas: HTMLCanvasElement) { + this._canvas = canvas + } + + /** + * Capture pointer when pressed. + * @param event - Pointer event + */ + onPointerDown(event: PointerEvent): void { + this.release() + this._canvas.setPointerCapture(event.pointerId) + this._id = event.pointerId + } + + /** + * Release capture when pointer is released. + */ + onPointerUp(): void { + this.release() + } + + /** + * Release capture when pointer is cancelled. + */ + onPointerCancel(): void { + this.release() + } + + /** + * Release pointer capture if active. + */ + private release(): void { + if (this._id >= 0) { + this._canvas.releasePointerCapture(this._id) + this._id = -1 + } + } +} diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/input/touchHandler.ts similarity index 82% rename from src/vim-web/core-viewers/shared/touchHandler.ts rename to src/vim-web/core-viewers/shared/input/touchHandler.ts index 3dd9480e0..fcf5c257a 100644 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/input/touchHandler.ts @@ -7,6 +7,7 @@ import * as THREE from 'three' import { BaseInputHandler } from './baseInputHandler'; import { TAP_DURATION_MS, TAP_MOVEMENT_THRESHOLD, DOUBLE_CLICK_TIME_THRESHOLD } from './inputConstants'; +import { clientToCanvas } from './coordinates'; /** Handles touch gestures with zero-allocation vector reuse. */ export class TouchHandler extends BaseInputHandler { @@ -60,11 +61,7 @@ export class TouchHandler extends BaseInputHandler { this._lastTapMs && time - this._lastTapMs < DOUBLE_CLICK_TIME_THRESHOLD this._lastTapMs = time - const rect = this._canvas.getBoundingClientRect() - this._tempScreenPos.set( - (position.x - rect.left) / rect.width, - (position.y - rect.top) / rect.height - ) + clientToCanvas(position.x, position.y, this._canvas, this._tempScreenPos) if(double) this.onDoubleTap?.(this._tempScreenPos) @@ -90,48 +87,13 @@ export class TouchHandler extends BaseInputHandler { this._hasTouch = this._hasTouch1 = this._hasTouch2 = true this._startDist = this._touch1.distanceTo(this._touch2) - const rect = this._canvas.getBoundingClientRect() - this._tempScreenPos.set( - (this._touch.x - rect.left) / rect.width, - (this._touch.y - rect.top) / rect.height - ) + clientToCanvas(this._touch.x, this._touch.y, this._canvas, this._tempScreenPos) this.onPinchStart?.(this._tempScreenPos) } this._touchStart.copy(this._touch) this._hasTouchStart = true } - private toRotation (delta: THREE.Vector2, speed: number) { - return delta.clone().multiplyScalar(-180 * speed) - } - - /* - private onDrag = (delta: THREE.Vector2) => { - if (this._viewer.inputs.pointerActive === 'orbit') { - this.camera.snap().orbit(this.toRotation(delta, this.orbitSpeed)) - } else { - this.camera.snap().rotate(this.toRotation(delta, this.rotateSpeed)) - } - } - */ - - /* - private onDoubleDrag = (delta: THREE.Vector2) => { - const move = delta.clone().multiplyScalar(this.MOVE_SPEED) - this.camera.snap().move('XY', move, 'local') - } - */ - - /* - private onPinchOrSpread = (delta: number) => { - if (this._viewer.inputs.pointerActive === 'orbit') { - this.camera.snap().zoom(1 + delta * this.ZOOM_SPEED) - } else { - this.camera.snap().move('Z', delta * this.ZOOM_SPEED, 'local') - } - } - */ - private onTouchMove = (event: TouchEvent) => { if (event.cancelable) event.preventDefault() if (!event.touches.length) return diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index 7dfb64d9b..f1a7480cd 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -1,5 +1,5 @@ -import { IInputAdapter } from "../shared/inputAdapter"; -import { InputHandler } from "../shared/inputHandler"; +import { IInputAdapter } from "../shared/input/inputAdapter"; +import { InputHandler } from "../shared/input/inputHandler"; import { Viewer } from "./viewer"; import * as THREE from 'three'; diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index d824d58af..29c0c385b 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -1,5 +1,5 @@ -import {type IInputAdapter} from "../../shared/inputAdapter" -import {InputHandler} from "../../shared/inputHandler" +import {type IInputAdapter} from "../../shared/input/inputAdapter" +import {InputHandler} from "../../shared/input/inputHandler" import { Viewer } from "./viewer" import { Element3D } from '../loader/element3d' import * as THREE from 'three' From 8314178ebdc8060a6e9f456cbd4e066cd3289009 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 12:18:53 -0500 Subject: [PATCH 73/94] small fixes --- .../shared/input/doubleClickDetection.ts | 21 ++++++---- .../shared/input/keyboardHandler.ts | 8 ++++ .../core-viewers/shared/input/mouseHandler.ts | 4 +- .../core-viewers/shared/input/touchHandler.ts | 40 ++++++++++++------- 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts b/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts index c0b26fdd4..e34ca5582 100644 --- a/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts +++ b/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts @@ -40,7 +40,8 @@ export class DoubleClickDetector { * @returns True if this was a double-click/tap * * Note: This method has side effects - it updates internal state - * to track the last click position and time. + * to track the last click position and time. If a double-click is + * detected, state is reset to prevent triple-click false positives. */ check(position: THREE.Vector2): boolean { const currentTime = Date.now() @@ -51,16 +52,22 @@ export class DoubleClickDetector { this._lastPosition.distanceTo(position) < this._distanceThreshold const isWithinTime = timeDiff < this._timeThreshold + const isDouble = isClose && isWithinTime - // Update state for next check - this._lastTime = currentTime - if (this._lastPosition === null) { - this._lastPosition = position.clone() + if (isDouble) { + // Reset state to prevent triple-click detection + this.reset() } else { - this._lastPosition.copy(position) + // Update state for next check + this._lastTime = currentTime + if (this._lastPosition === null) { + this._lastPosition = position.clone() + } else { + this._lastPosition.copy(position) + } } - return isClose && isWithinTime + return isDouble } /** diff --git a/src/vim-web/core-viewers/shared/input/keyboardHandler.ts b/src/vim-web/core-viewers/shared/input/keyboardHandler.ts index b77615c19..209bc4163 100644 --- a/src/vim-web/core-viewers/shared/input/keyboardHandler.ts +++ b/src/vim-web/core-viewers/shared/input/keyboardHandler.ts @@ -80,6 +80,14 @@ export class KeyboardHandler extends BaseInputHandler { // Reset state when focus is lost or on window resize. this.reg(this._canvas, 'focusout', () => this.reset()); this.reg(window, 'resize', () => this.reset()); + + // Reset on window blur (e.g., Alt+Tab) to prevent stuck modifiers + this.reg(window, 'blur', () => this.reset()); + + // Reset on visibility change (e.g., tab switch) + this.reg(document, 'visibilitychange', () => { + if (document.hidden) this.reset(); + }); } private registerMovementHandlers(): void { diff --git a/src/vim-web/core-viewers/shared/input/mouseHandler.ts b/src/vim-web/core-viewers/shared/input/mouseHandler.ts index 802d8dfbe..ace581ffa 100644 --- a/src/vim-web/core-viewers/shared/input/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/input/mouseHandler.ts @@ -141,7 +141,9 @@ export class MouseHandler extends BaseInputHandler { return } - this.handleContextMenu(event); + if (event.button === 2) { + this.handleContextMenu(event); + } } diff --git a/src/vim-web/core-viewers/shared/input/touchHandler.ts b/src/vim-web/core-viewers/shared/input/touchHandler.ts index fcf5c257a..033148dc7 100644 --- a/src/vim-web/core-viewers/shared/input/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/input/touchHandler.ts @@ -24,6 +24,8 @@ export class TouchHandler extends BaseInputHandler { private _tempDelta = new THREE.Vector2() private _tempSize = new THREE.Vector2() private _tempScreenPos = new THREE.Vector2() + private _tempTouch1 = new THREE.Vector2() + private _tempTouch2 = new THREE.Vector2() constructor (canvas: HTMLCanvasElement) { super(canvas) @@ -48,6 +50,7 @@ export class TouchHandler extends BaseInputHandler { this.reg(this._canvas, 'touchstart', this.onTouchStart, active) this.reg(this._canvas, 'touchend', this.onTouchEnd) this.reg(this._canvas, 'touchmove', this.onTouchMove, active) + this.reg(this._canvas, 'touchcancel', this.onTouchCancel) } override reset = () => { @@ -55,6 +58,10 @@ export class TouchHandler extends BaseInputHandler { this._touchStartTime = this._startDist = undefined } + dispose(): void { + this.unregister() + } + private _onTap = (position: THREE.Vector2) => { const time = Date.now() const double = @@ -77,12 +84,12 @@ export class TouchHandler extends BaseInputHandler { this._touchStartTime = Date.now() if (event.touches.length === 1) { - this._touch.copy(this.touchToVector(event.touches[0])) + this.touchToVector(event.touches[0], this._touch) this._hasTouch = true this._hasTouch1 = this._hasTouch2 = false } else if (event.touches.length === 2) { - this._touch1.copy(this.touchToVector(event.touches[0])) - this._touch2.copy(this.touchToVector(event.touches[1])) + this.touchToVector(event.touches[0], this._touch1) + this.touchToVector(event.touches[1], this._touch2) this._touch.copy(this.average(this._touch1, this._touch2)) this._hasTouch = this._hasTouch1 = this._hasTouch2 = true this._startDist = this._touch1.distanceTo(this._touch2) @@ -100,7 +107,7 @@ export class TouchHandler extends BaseInputHandler { if (!this._hasTouch) return if (event.touches.length === 1) { - const pos = this.touchToVector(event.touches[0]) + const pos = this.touchToVector(event.touches[0], this._tempTouch1) this._tempSize.set(this._canvas.clientWidth, this._canvas.clientHeight) this._tempDelta.copy(pos).sub(this._touch) .multiply(this._tempSize.set(1 / this._tempSize.x, 1 / this._tempSize.y)) @@ -112,18 +119,18 @@ export class TouchHandler extends BaseInputHandler { if (!this._hasTouch1 || !this._hasTouch2) return if (event.touches.length >= 2) { - const p1 = this.touchToVector(event.touches[0]) - const p2 = this.touchToVector(event.touches[1]) - const p = this.average(p1, p2) + this.touchToVector(event.touches[0], this._tempTouch1) + this.touchToVector(event.touches[1], this._tempTouch2) + const p = this.average(this._tempTouch1, this._tempTouch2) this._tempSize.set(this._canvas.clientWidth, this._canvas.clientHeight) this._tempDelta.copy(this._touch).sub(p) .multiply(this._tempSize.set(-1 / this._tempSize.x, -1 / this._tempSize.y)) - const dist = p1.distanceTo(p2) + const dist = this._tempTouch1.distanceTo(this._tempTouch2) this._touch.copy(p) - this._touch1.copy(p1) - this._touch2.copy(p2) + this._touch1.copy(this._tempTouch1) + this._touch2.copy(this._tempTouch2) this.onDoubleDrag?.(this._tempDelta) if (this._startDist) { @@ -135,7 +142,7 @@ export class TouchHandler extends BaseInputHandler { private onTouchEnd = (event: TouchEvent) => { // 2→1 finger: transition to single-finger drag if (event.touches.length === 1) { - this._touch.copy(this.touchToVector(event.touches[0])) + this.touchToVector(event.touches[0], this._touch) this._hasTouch = true this._hasTouch1 = this._hasTouch2 = false this._startDist = undefined @@ -156,6 +163,12 @@ export class TouchHandler extends BaseInputHandler { this.reset() } + private onTouchCancel = (_event: TouchEvent) => { + // Touch was interrupted (e.g., phone call, alert, browser UI) + // Reset all state to prevent stuck gestures + this.reset() + } + private isSingleTouch (): boolean { return ( this._hasTouch && @@ -165,9 +178,8 @@ export class TouchHandler extends BaseInputHandler { ) } - private touchToVector (touch: Touch) { - this._tempVec.set(touch.clientX, touch.clientY) - return this._tempVec + private touchToVector (touch: Touch, result: THREE.Vector2): THREE.Vector2 { + return result.set(touch.clientX, touch.clientY) } private average (p1: THREE.Vector2, p2: THREE.Vector2): THREE.Vector2 { From e6f0321a75e288960f5173c1aa29caa96ead2e98 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 12:29:31 -0500 Subject: [PATCH 74/94] some tmp vctors --- .../core-viewers/shared/input/clickDetection.ts | 6 ++++++ .../shared/input/doubleClickDetection.ts | 14 ++++++-------- .../core-viewers/shared/input/inputHandler.ts | 12 +++++++----- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/vim-web/core-viewers/shared/input/clickDetection.ts b/src/vim-web/core-viewers/shared/input/clickDetection.ts index 4ef750baa..3db2b6c3b 100644 --- a/src/vim-web/core-viewers/shared/input/clickDetection.ts +++ b/src/vim-web/core-viewers/shared/input/clickDetection.ts @@ -10,6 +10,12 @@ import * as THREE from 'three' /** * Detects whether a pointer interaction is a click or drag based on movement. * + * State Management: + * - onPointerDown() resets state and captures start position + * - onPointerMove() sets moved flag if movement exceeds threshold + * - onPointerUp() does NOT reset state - state persists until next onPointerDown + * - This allows checking wasMoved() or isClick() after pointer release + * * Usage: * - Call onPointerDown when pointer is pressed * - Call onPointerMove during pointer movement diff --git a/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts b/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts index e34ca5582..ee94aa1a0 100644 --- a/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts +++ b/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts @@ -20,7 +20,8 @@ import * as THREE from 'three' */ export class DoubleClickDetector { private _lastTime: number = 0 - private _lastPosition: THREE.Vector2 | null = null + private _lastPosition: THREE.Vector2 = new THREE.Vector2() + private _hasLastPosition: boolean = false private _timeThreshold: number private _distanceThreshold: number @@ -48,7 +49,7 @@ export class DoubleClickDetector { const timeDiff = currentTime - this._lastTime const isClose = - this._lastPosition !== null && + this._hasLastPosition && this._lastPosition.distanceTo(position) < this._distanceThreshold const isWithinTime = timeDiff < this._timeThreshold @@ -60,11 +61,8 @@ export class DoubleClickDetector { } else { // Update state for next check this._lastTime = currentTime - if (this._lastPosition === null) { - this._lastPosition = position.clone() - } else { - this._lastPosition.copy(position) - } + this._lastPosition.copy(position) + this._hasLastPosition = true } return isDouble @@ -75,6 +73,6 @@ export class DoubleClickDetector { */ reset(): void { this._lastTime = 0 - this._lastPosition = null + this._hasLastPosition = false } } diff --git a/src/vim-web/core-viewers/shared/input/inputHandler.ts b/src/vim-web/core-viewers/shared/input/inputHandler.ts index 31537964c..c8d418136 100644 --- a/src/vim-web/core-viewers/shared/input/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/input/inputHandler.ts @@ -101,9 +101,8 @@ export class InputHandler extends BaseInputHandler { // Mouse controls this.mouse.onContextMenu = (pos: THREE.Vector2) => { // Convert canvas-relative coords (0-1) back to client coords (pixels) for menu positioning - const clientPos = new THREE.Vector2() - canvasToClient(pos.x, pos.y, canvas, clientPos) - this._onContextMenu.dispatch(clientPos) + canvasToClient(pos.x, pos.y, canvas, _tempClientPos) + this._onContextMenu.dispatch(_tempClientPos) }; this.mouse.onButtonDown = adapter.mouseDown this.mouse.onMouseMove = adapter.mouseMove @@ -140,7 +139,8 @@ export class InputHandler extends BaseInputHandler { const rect = this._canvas.getBoundingClientRect() const screenX = (clientX - rect.left) / rect.width const screenY = (clientY - rect.top) / rect.height - adapter.zoom(this.getZoomValue(value), new THREE.Vector2(screenX, screenY)) + _tempScreenPos.set(screenX, screenY) + adapter.zoom(this.getZoomValue(value), _tempScreenPos) } } @@ -275,8 +275,10 @@ export class InputHandler extends BaseInputHandler { } } -// Reusable vector to avoid per-frame allocations +// Reusable vectors to avoid per-frame allocations const _tempRotation = new THREE.Vector2() +const _tempScreenPos = new THREE.Vector2() +const _tempClientPos = new THREE.Vector2() function toRotation (delta: THREE.Vector2, speed: number) { return _tempRotation.copy(delta).negate().multiplyScalar(180 * speed) From 8255601c5fa738d4b8c6d7d27b48927fc688bd20 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 12:46:32 -0500 Subject: [PATCH 75/94] polish --- INPUT.md | 53 +++++++++++-------- .../shared/input/clickDetection.ts | 5 +- .../shared/input/doubleClickDetection.ts | 10 +++- .../core-viewers/shared/input/inputAdapter.ts | 48 +++++++++++++++-- .../core-viewers/shared/input/inputHandler.ts | 16 +++--- .../shared/input/keyboardHandler.ts | 8 +++ .../core-viewers/shared/input/mouseHandler.ts | 15 +++--- .../core-viewers/shared/input/touchHandler.ts | 22 +++++--- .../core-viewers/ultra/inputAdapter.ts | 6 +-- .../gizmos/sectionBox/sectionBoxInputs.ts | 18 +++---- .../core-viewers/webgl/viewer/inputAdapter.ts | 6 +-- 11 files changed, 142 insertions(+), 65 deletions(-) diff --git a/INPUT.md b/INPUT.md index 78a1e3b1c..58afb20d3 100644 --- a/INPUT.md +++ b/INPUT.md @@ -52,6 +52,27 @@ The VIM Web input system uses a layered adapter pattern to decouple device handl --- +## Recent Changes + +**Breaking Changes (v0.6.0):** +- Standardized naming to `onPointer*` prefix: + - `onButtonDown` → `onPointerDown` + - `onButtonUp` → `onPointerUp` + - `onMouseMove` → `onPointerMove` + - `mouseDown` → `pointerDown` (IInputAdapter) + - `mouseUp` → `pointerUp` (IInputAdapter) + - `mouseMove` → `pointerMove` (IInputAdapter) + +**Improvements:** +- Added `touchcancel` event handling to prevent stuck gestures +- Added constructor validation for threshold parameters (throws on invalid values) +- Fixed double-click race condition with triple-click prevention +- Improved keyboard handling for Alt+Tab scenarios (window blur + visibility change listeners) +- Optimized memory usage with vector reuse in InputHandler +- Added comprehensive JSDoc to IInputAdapter interface + +--- + ## Device Handlers ### MouseHandler @@ -119,7 +140,7 @@ viewer.core.inputs.keyboard.registerKeyUp(['Equal', 'NumpadAdd'], 'replace', () ## Pointer Mode System -Three-tier mode management for flexible interaction: +Two-tier mode management for flexible interaction: ### 1. pointerActive (Primary Mode) @@ -144,18 +165,7 @@ Temporarily overrides the active mode during interaction: - Cleared on: Mouse up - Used for: Icon display, temporary mode switches -```typescript -// Automatically set by mouse handler on middle/right click -// Returns to pointerActive when mouse is released -``` - -### 3. pointerFallback (Last Active Mode) - -Stores the last ORBIT or LOOK mode: -- Used when: Returning from temporary modes -- Maintains: User's base interaction preference - -**Priority**: `override > active > fallback` +**Priority**: `override > active` ```typescript // Listen for mode changes @@ -319,9 +329,9 @@ interface IInputAdapter { // Raw events (for custom handling) keyDown: (keyCode: string) => boolean keyUp: (keyCode: string) => boolean - mouseDown: (pos: THREE.Vector2, button: number) => void - mouseMove: (pos: THREE.Vector2) => void - mouseUp: (pos: THREE.Vector2, button: number) => void + pointerDown: (pos: THREE.Vector2, button: number) => void + pointerMove: (pos: THREE.Vector2) => void + pointerUp: (pos: THREE.Vector2, button: number) => void } ``` @@ -572,13 +582,13 @@ inputs.mouse.onDrag = (delta, button) => { } } -// Add button down/up handlers -inputs.mouse.onButtonDown = (pos, button) => { - console.log('Button down:', button, 'at', pos) +// Add pointer down/up handlers +inputs.mouse.onPointerDown = (pos, button) => { + console.log('Pointer down:', button, 'at', pos) } -inputs.mouse.onButtonUp = (pos, button) => { - console.log('Button up:', button, 'at', pos) +inputs.mouse.onPointerUp = (pos, button) => { + console.log('Pointer up:', button, 'at', pos) } ``` @@ -620,7 +630,6 @@ const inputs = viewer.core.inputs // Check current mode console.log('Active mode:', inputs.pointerActive) console.log('Override:', inputs.pointerOverride) -console.log('Fallback:', inputs.pointerFallback) // Check speeds console.log('Move speed:', inputs.moveSpeed) diff --git a/src/vim-web/core-viewers/shared/input/clickDetection.ts b/src/vim-web/core-viewers/shared/input/clickDetection.ts index 3db2b6c3b..f0fec4875 100644 --- a/src/vim-web/core-viewers/shared/input/clickDetection.ts +++ b/src/vim-web/core-viewers/shared/input/clickDetection.ts @@ -28,9 +28,12 @@ export class ClickDetector { private _threshold: number /** - * @param threshold - Maximum movement distance to still be considered a click + * @param threshold - Maximum movement distance to still be considered a click (must be > 0) */ constructor(threshold: number) { + if (threshold <= 0 || !isFinite(threshold)) { + throw new Error('ClickDetector threshold must be a positive number') + } this._threshold = threshold } diff --git a/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts b/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts index ee94aa1a0..98f84b51a 100644 --- a/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts +++ b/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts @@ -26,10 +26,16 @@ export class DoubleClickDetector { private _distanceThreshold: number /** - * @param timeThreshold - Max time between clicks (ms) - * @param distanceThreshold - Max distance between clicks (pixels or canvas units) + * @param timeThreshold - Max time between clicks in milliseconds (must be > 0) + * @param distanceThreshold - Max distance between clicks in pixels or canvas units (must be > 0) */ constructor(timeThreshold: number, distanceThreshold: number) { + if (timeThreshold <= 0 || !isFinite(timeThreshold)) { + throw new Error('DoubleClickDetector timeThreshold must be a positive number') + } + if (distanceThreshold <= 0 || !isFinite(distanceThreshold)) { + throw new Error('DoubleClickDetector distanceThreshold must be a positive number') + } this._timeThreshold = timeThreshold this._distanceThreshold = distanceThreshold } diff --git a/src/vim-web/core-viewers/shared/input/inputAdapter.ts b/src/vim-web/core-viewers/shared/input/inputAdapter.ts index 59bdad5e7..fd4aaf9c2 100644 --- a/src/vim-web/core-viewers/shared/input/inputAdapter.ts +++ b/src/vim-web/core-viewers/shared/input/inputAdapter.ts @@ -1,29 +1,69 @@ import * as THREE from 'three' +/** + * Input adapter interface that decouples input handling from viewer implementation. + * + * Allows the same input handlers to work with both WebGL (local rendering) + * and Ultra (server-side streaming) viewers. + */ export interface IInputAdapter{ + /** Initialize the adapter (called once during setup) */ init: () => void + /** Toggle between perspective and orthographic projection */ toggleOrthographic: () => void + + /** Reset camera to default/saved position */ resetCamera: () => void + + /** Clear the current selection */ clearSelection: () => void + + /** Frame the camera to fit the current selection or entire scene */ frameCamera: () => void | Promise + + /** Move camera by velocity vector (WASD movement) */ moveCamera: (value: THREE.Vector3) => void + + /** Orbit camera around target by rotation delta in degrees */ orbitCamera: (value: THREE.Vector2) => void + + /** Rotate camera in place (first-person) by rotation delta in degrees */ rotateCamera: (value: THREE.Vector2) => void + + /** Pan camera parallel to view plane by normalized delta [0-1] */ panCamera: (value: THREE.Vector2) => void + + /** Dolly camera along view direction by normalized delta [0-1] */ dollyCamera: (value: THREE.Vector2) => void - // Raw input handlers for Ultra + /** Handle key down event - return true if handled */ keyDown: (keyCode: string) => boolean + + /** Handle key up event - return true if handled */ keyUp: (keyCode: string) => boolean - mouseDown: (pos: THREE.Vector2, button: number) => void - mouseUp: (pos: THREE.Vector2, button: number) => void - mouseMove: (pos: THREE.Vector2) => void + /** Handle pointer button down at canvas-relative position [0-1] */ + pointerDown: (pos: THREE.Vector2, button: number) => void + + /** Handle pointer button up at canvas-relative position [0-1] */ + pointerUp: (pos: THREE.Vector2, button: number) => void + + /** Handle pointer move at canvas-relative position [0-1] */ + pointerMove: (pos: THREE.Vector2) => void + + /** Select object at pointer position (add to selection if add=true) */ selectAtPointer: (pos: THREE.Vector2, add: boolean) => void | Promise + + /** Frame camera to object at pointer position */ frameAtPointer: (pos: THREE.Vector2) => void | Promise + + /** Zoom by value (>1 = zoom out, <1 = zoom in), optionally toward screenPos */ zoom: (value: number, screenPos?: THREE.Vector2) => void | Promise + /** Called when pinch gesture starts at screen center position */ pinchStart: (screenPos: THREE.Vector2) => void | Promise + + /** Handle pinch zoom by total ratio (2.0 = 2x zoom, 0.5 = 0.5x zoom) */ pinchZoom: (totalRatio: number) => void } \ No newline at end of file diff --git a/src/vim-web/core-viewers/shared/input/inputHandler.ts b/src/vim-web/core-viewers/shared/input/inputHandler.ts index c8d418136..c6e9f1c8c 100644 --- a/src/vim-web/core-viewers/shared/input/inputHandler.ts +++ b/src/vim-web/core-viewers/shared/input/inputHandler.ts @@ -15,6 +15,9 @@ import { IInputAdapter } from './inputAdapter' import { MIN_MOVE_SPEED, MAX_MOVE_SPEED } from './inputConstants' import { canvasToClient } from './coordinates' +/** Base multiplier for exponential move speed scaling (1.25^moveSpeed) */ +const MOVE_SPEED_BASE = 1.25 + /** Pointer interaction modes. See INPUT.md for details. */ export enum PointerMode { ORBIT = 'orbit', @@ -35,7 +38,7 @@ interface InputSettings{ /** * Input handler coordinator. * - * Manages three-tier pointer modes (active/override/fallback). + * Manages two-tier pointer modes (active/override). * See INPUT.md for mode system and customization. */ export class InputHandler extends BaseInputHandler { @@ -94,7 +97,7 @@ export class InputHandler extends BaseInputHandler { }); this.keyboard.onMove = (value: THREE.Vector3 ) =>{ - const mul = Math.pow(1.25, this._moveSpeed) + const mul = Math.pow(MOVE_SPEED_BASE, this._moveSpeed) adapter.moveCamera(value.multiplyScalar(mul)) } @@ -104,11 +107,11 @@ export class InputHandler extends BaseInputHandler { canvasToClient(pos.x, pos.y, canvas, _tempClientPos) this._onContextMenu.dispatch(_tempClientPos) }; - this.mouse.onButtonDown = adapter.mouseDown - this.mouse.onMouseMove = adapter.mouseMove - this.mouse.onButtonUp = (pos: THREE.Vector2, button: number) => { + this.mouse.onPointerDown = adapter.pointerDown + this.mouse.onPointerMove = adapter.pointerMove + this.mouse.onPointerUp = (pos: THREE.Vector2, button: number) => { this.pointerOverride = undefined - adapter.mouseUp(pos, button) + adapter.pointerUp(pos, button) } this.mouse.onDrag = (delta: THREE.Vector2, button: number) =>{ if(button === 0){ @@ -132,7 +135,6 @@ export class InputHandler extends BaseInputHandler { this.mouse.onDoubleClick = adapter.frameAtPointer this.mouse.onWheel = (value: number, ctrl: boolean, clientX: number, clientY: number) => { if(ctrl){ - console.log('ctrl', value) this.moveSpeed -= Math.sign(value) } else{ diff --git a/src/vim-web/core-viewers/shared/input/keyboardHandler.ts b/src/vim-web/core-viewers/shared/input/keyboardHandler.ts index 209bc4163..34e7fec1a 100644 --- a/src/vim-web/core-viewers/shared/input/keyboardHandler.ts +++ b/src/vim-web/core-viewers/shared/input/keyboardHandler.ts @@ -27,7 +27,15 @@ export class KeyboardHandler extends BaseInputHandler { * Callback invoked whenever the calculated movement vector is updated. */ public onMove: (value: THREE.Vector3) => void; + + /** + * Called on key up event - return true if handled to prevent default. + */ public onKeyUp: (code: string) => boolean; + + /** + * Called on key down event - return true if handled to prevent default. + */ public onKeyDown: (code: string) => boolean; diff --git a/src/vim-web/core-viewers/shared/input/mouseHandler.ts b/src/vim-web/core-viewers/shared/input/mouseHandler.ts index ace581ffa..18150769c 100644 --- a/src/vim-web/core-viewers/shared/input/mouseHandler.ts +++ b/src/vim-web/core-viewers/shared/input/mouseHandler.ts @@ -34,20 +34,20 @@ export class MouseHandler extends BaseInputHandler { * @param pos Canvas-relative position [0-1] * @param button 0=left, 1=middle, 2=right */ - onButtonDown: (pos: THREE.Vector2, button: number) => void; + onPointerDown: (pos: THREE.Vector2, button: number) => void; /** * Called on every pointer up event. * @param pos Canvas-relative position [0-1] * @param button 0=left, 1=middle, 2=right */ - onButtonUp: (pos: THREE.Vector2, button: number) => void; + onPointerUp: (pos: THREE.Vector2, button: number) => void; /** * Called on every pointer move (regardless of button state). * @param pos Canvas-relative position [0-1] */ - onMouseMove: (event: THREE.Vector2) => void; + onPointerMove: (event: THREE.Vector2) => void; /** * Called during pointer drag (pointer down + move). @@ -102,6 +102,9 @@ export class MouseHandler extends BaseInputHandler { this.reg(this._canvas, 'wheel', e => { this.onMouseScroll(e); }); } + /** + * Cleanup method - unregisters all event listeners. + */ dispose(): void { this.unregister(); } @@ -110,7 +113,7 @@ export class MouseHandler extends BaseInputHandler { if (event.pointerType !== 'mouse') return; // We don't handle touch yet const pos = this.relativePosition(event); - this.onButtonDown?.(pos, event.button); + this.onPointerDown?.(pos, event.button); // Start drag this._dragHandler.onPointerDown(pos, event.button); this._clickHandler.onPointerDown(pos); @@ -125,7 +128,7 @@ export class MouseHandler extends BaseInputHandler { const pos = this.relativePosition(event); // Button up event - this.onButtonUp?.(pos, event.button); + this.onPointerUp?.(pos, event.button); this._capture.onPointerUp(); this._dragHandler.onPointerUp(); this._clickHandler.onPointerUp(); @@ -185,7 +188,7 @@ export class MouseHandler extends BaseInputHandler { const pos = this.relativePosition(event); this._dragHandler.onPointerMove(pos); this._clickHandler.onPointerMove(pos); - this.onMouseMove?.(pos); + this.onPointerMove?.(pos); } private async handleDoubleClick(event: MouseEvent): Promise { diff --git a/src/vim-web/core-viewers/shared/input/touchHandler.ts b/src/vim-web/core-viewers/shared/input/touchHandler.ts index 033148dc7..c3f4a4e7d 100644 --- a/src/vim-web/core-viewers/shared/input/touchHandler.ts +++ b/src/vim-web/core-viewers/shared/input/touchHandler.ts @@ -11,11 +11,22 @@ import { clientToCanvas } from './coordinates'; /** Handles touch gestures with zero-allocation vector reuse. */ export class TouchHandler extends BaseInputHandler { + /** Called on single tap (touch down + up within 500ms, <5px movement) */ onTap: (position: THREE.Vector2) => void + + /** Called on double-tap (two taps within 300ms) */ onDoubleTap: (position: THREE.Vector2) => void + + /** Called during single-finger drag */ onDrag: (delta: THREE.Vector2) => void + + /** Called during two-finger pan (average position moves) */ onDoubleDrag: (delta: THREE.Vector2) => void + + /** Called when two-finger pinch starts at screen center */ onPinchStart: (screenCenter: THREE.Vector2) => void + + /** Called during pinch/spread (totalRatio: 2.0 = 2x zoom, 0.5 = 0.5x zoom) */ onPinchOrSpread: (totalRatio: number) => void // Temp vectors (reused, never store references!) @@ -58,6 +69,9 @@ export class TouchHandler extends BaseInputHandler { this._touchStartTime = this._startDist = undefined } + /** + * Cleanup method - unregisters all event listeners and resets state. + */ dispose(): void { this.unregister() } @@ -186,12 +200,4 @@ export class TouchHandler extends BaseInputHandler { this._tempVec2.copy(p1).lerp(p2, 0.5) return this._tempVec2 } - - /** - * Returns the pixel size of the canvas. - * @returns {THREE.Vector2} The pixel size of the canvas. - */ - getCanvasSize () { - return new THREE.Vector2(this._canvas.clientWidth, this._canvas.clientHeight) - } } diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index f1a7480cd..d56d6b356 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -123,13 +123,13 @@ function createAdapter(viewer: Viewer): IInputAdapter { keyUp: (code: string) => { return sendKey(viewer, code, false); }, - mouseDown: (pos: THREE.Vector2, button: number) => { + pointerDown: (pos: THREE.Vector2, button: number) => { viewer.rpc.RPCMouseButtonEvent(pos, button, true); }, - mouseUp: (pos: THREE.Vector2, button: number) => { + pointerUp: (pos: THREE.Vector2, button: number) => { viewer.rpc.RPCMouseButtonEvent(pos, button, false); }, - mouseMove: (pos: THREE.Vector2) => { + pointerMove: (pos: THREE.Vector2) => { viewer.rpc.RPCMouseMoveEvent(pos); }, }; diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxInputs.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxInputs.ts index 33421eafa..849be5342 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxInputs.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxInputs.ts @@ -106,28 +106,28 @@ export class BoxInputs { const mouse = this._viewer.inputs.mouse; - const up = mouse.onButtonUp - const down = mouse.onButtonDown - const move = mouse.onMouseMove + const up = mouse.onPointerUp + const down = mouse.onPointerDown + const move = mouse.onPointerMove const drag = mouse.onDrag this._restoreOriginalInputs = () => { - mouse.onButtonUp = up - mouse.onButtonDown = down - mouse.onMouseMove = move + mouse.onPointerUp = up + mouse.onPointerDown = down + mouse.onPointerMove = move mouse.onDrag = drag } - mouse.onButtonUp = (pos, btn) => { + mouse.onPointerUp = (pos, btn) => { up(pos, btn) this.onMouseUp(pos) } - mouse.onButtonDown = (pos, btn) => { + mouse.onPointerDown = (pos, btn) => { down(pos, btn) this.onMouseDown(pos) } - mouse.onMouseMove = (pos) => { + mouse.onPointerMove = (pos) => { move(pos) this.onMouseMove(pos) } diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index 29c0c385b..e98e76ec5 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -108,9 +108,9 @@ function createAdapter(viewer: Viewer ) : IInputAdapter { keyDown: (keyCode: string) => {return false}, keyUp: (keyCode: string) => {return false}, - mouseDown: () => {}, - mouseMove: () => {}, - mouseUp: () => {}, + pointerDown: () => {}, + pointerMove: () => {}, + pointerUp: () => {}, } } From 0ac49ed1fdf1f7627525fcc6e20e45689e3a0fc1 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 14:17:44 -0500 Subject: [PATCH 76/94] moved code in the if --- src/vim-web/core-viewers/webgl/loader/element3d.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/element3d.ts b/src/vim-web/core-viewers/webgl/loader/element3d.ts index a5093ddb2..5fab5e059 100644 --- a/src/vim-web/core-viewers/webgl/loader/element3d.ts +++ b/src/vim-web/core-viewers/webgl/loader/element3d.ts @@ -106,13 +106,13 @@ export class Element3D implements IVimElement { set visible (value: boolean) { if (this._visibleAttribute.apply(value)) { this.renderer.notifySceneUpdate() - } - // Show all involved meshes - if(value){ - this._meshes?.forEach((m) => { - m.mesh.mesh.visible = true - }) + // Show all involved meshes + if(value){ + this._meshes?.forEach((m) => { + m.mesh.mesh.visible = true + }) + } } } From 9b64dec11b30e831beb1ce2ac95d54024982285a Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 14:17:59 -0500 Subject: [PATCH 77/94] return progress on signal Signed-off-by: vim-sroberge --- src/vim-web/core-viewers/webgl/loader/vim.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index 25921cc9e..955d08085 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -12,9 +12,11 @@ import { ElementNoMapping } from './elementMapping' import { ISignal, SignalDispatcher } from 'ste-signals' +import { SimpleEventDispatcher } from 'ste-simple-events' import { G3dSubset } from './progressive/g3dSubset' import { VimMeshFactory } from './progressive/vimMeshFactory' import { IVim } from '../../shared/vim' +import { IProgress } from '../../shared/loadResult' /** * Represents a container for the built three.js meshes and the vim data from which they were constructed. @@ -71,13 +73,13 @@ export class Vim implements IVim { private readonly _factory: VimMeshFactory private readonly _loadedInstances = new Set() private readonly _elementToObject = new Map() - private _onUpdate = new SignalDispatcher() + private _onUpdate = new SimpleEventDispatcher() /** * Getter for accessing the event dispatched whenever a subset begins or finishes loading. - * @returns {ISignal} The event dispatcher for loading updates. + * Consumers can subscribe to track loading progress. */ - get onLoadingUpdate (): ISignal { + get onLoadingUpdate () { return this._onUpdate.asEvent() } @@ -240,7 +242,11 @@ export class Vim implements IVim { } // Build meshes and add to scene this._factory.add(subset) - this._onUpdate.dispatch() + this._onUpdate.dispatch({ + type: 'percent', + current: this._loadedInstances.size, + total: this.getFullSet().getInstanceCount() + }) } /** @@ -263,7 +269,11 @@ export class Vim implements IVim { this._elementToObject.clear() this._loadedInstances.clear() this.scene.clear() - this._onUpdate.dispatch() + this._onUpdate.dispatch({ + type: 'percent', + current: 0, + total: this.getFullSet().getInstanceCount() + }) } /** From f2c061f9970f971018aad55ae00693ebb35328f0 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 14:18:13 -0500 Subject: [PATCH 78/94] documentation --- src/vim-web/core-viewers/webgl/viewer/camera/camera.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts index 8b882386c..d9602f2dd 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -217,6 +217,8 @@ export class Camera implements ICamera { /** * The quaternion representing the camera's orientation. + * @returns Live reference to internal state. Mutations affect the camera. + * Call `.clone()` if you need an independent copy. */ get quaternion () { return this.camPerspective.camera.quaternion @@ -224,6 +226,8 @@ export class Camera implements ICamera { /** * The position of the camera. + * @returns Live reference to internal state. Mutations affect the camera. + * Call `.clone()` if you need an independent copy. */ get position () { return this.camPerspective.camera.position @@ -231,6 +235,8 @@ export class Camera implements ICamera { /** * The matrix representing the transformation of the camera. + * @returns Live reference to internal state. Mutations affect the camera. + * Call `.clone()` if you need an independent copy. */ get matrix () { this.camPerspective.camera.updateMatrix() @@ -239,6 +245,7 @@ export class Camera implements ICamera { /** * The forward direction of the camera. + * @returns A new Vector3 instance (read-only). Mutations do not affect the camera. */ get forward () { return this.camPerspective.camera.getWorldDirection(new THREE.Vector3()) @@ -246,6 +253,7 @@ export class Camera implements ICamera { /** * The current velocity in camera-local Z-up space (X = right, Y = forward, Z = up). + * @returns A new Vector3 instance (read-only). Mutations do not affect the camera. */ get localVelocity () { const result = this._velocity.clone() @@ -274,6 +282,8 @@ export class Camera implements ICamera { /** * The point the camera looks at and orbits around. + * @returns Live reference to internal state. Mutations affect the camera. + * Call `.clone()` if you need an independent copy. */ get target () { return this._target From 3a449e81dc5a96ce318f76f705531c7835b3cd76 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 14:44:18 -0500 Subject: [PATCH 79/94] dont create empty meshes --- .../webgl/loader/progressive/g3dOffsets.ts | 7 +++++++ .../loader/progressive/insertableMeshFactory.ts | 8 ++++++++ .../webgl/loader/progressive/instancedMeshFactory.ts | 8 ++++++++ .../webgl/loader/progressive/vimMeshFactory.ts | 12 ++++++++---- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts index 01665c7c8..21f7c430f 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts @@ -80,4 +80,11 @@ export class G3dMeshOffsets { : this.counts.vertices } + /** + * Returns true if this offset has any geometry (indices > 0). + */ + any () { + return this.counts.indices > 0 + } + } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts index cf97a5a98..0740c40d0 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts @@ -31,10 +31,18 @@ export class InsertableMeshFactory { } createOpaqueFromVim (g3d: G3d, subset: G3dSubset) { + // Skip if no opaque geometry + if (!subset.getOffsets('opaque').any()) { + return undefined + } return this.createFromVim(g3d, subset, 'opaque', false) } createTransparentFromVim (g3d: G3d, subset: G3dSubset) { + // Skip if no transparent geometry + if (!subset.getOffsets('transparent').any()) { + return undefined + } return this.createFromVim(g3d, subset, 'transparent', true) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 7560e38fe..86ec2bae5 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -27,10 +27,18 @@ export class InstancedMeshFactory { } createOpaqueFromVim (g3d: G3d, mesh: number, instances: number[]) { + // Skip if no opaque geometry + if (g3d.getMeshIndexEnd(mesh, 'opaque') <= g3d.getMeshIndexStart(mesh, 'opaque')) { + return undefined + } return this.createFromVim(g3d, mesh, instances, 'opaque', false) } createTransparentFromVim (g3d: G3d, mesh: number, instances: number[]) { + // Skip if no transparent geometry + if (g3d.getMeshIndexEnd(mesh, 'transparent') <= g3d.getMeshIndexStart(mesh, 'transparent')) { + return undefined + } return this.createFromVim(g3d, mesh, instances, 'transparent', true) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts index 73e2dbbc5..33965201c 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts @@ -50,8 +50,11 @@ export class VimMeshFactory { } private addMergedMesh (scene: Scene, subset: G3dSubset) { - scene.addMesh(this._insertableFactory.createOpaqueFromVim(this.g3d, subset)) - scene.addMesh(this._insertableFactory.createTransparentFromVim(this.g3d, subset)) + const opaque = this._insertableFactory.createOpaqueFromVim(this.g3d, subset) + if (opaque) scene.addMesh(opaque) + + const transparent = this._insertableFactory.createTransparentFromVim(this.g3d, subset) + if (transparent) scene.addMesh(transparent) } private addInstancedMeshes (scene: Scene, subset: G3dSubset) { @@ -66,13 +69,14 @@ export class VimMeshFactory { mesh, instances ) + if (opaque) scene.addMesh(opaque) + const transparent = this._instancedFactory.createTransparentFromVim( this.g3d, mesh, instances ) - scene.addMesh(opaque) - scene.addMesh(transparent) + if (transparent) scene.addMesh(transparent) } } } From 0f7b0283500f5480f78c4729346b78e534fdce8f Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 15:50:49 -0500 Subject: [PATCH 80/94] cleaning up g3dsubset --- .../webgl/loader/elementMapping.ts | 5 +- .../core-viewers/webgl/loader/geometry.ts | 7 ++- .../webgl/loader/progressive/g3dSubset.ts | 56 ++++++++++--------- .../loader/progressive/insertableGeometry.ts | 13 +++-- .../loader/progressive/insertableMesh.ts | 5 +- .../progressive/insertableMeshFactory.ts | 9 +-- .../webgl/loader/progressive/instancedMesh.ts | 47 +++++++++++++--- .../progressive/instancedMeshFactory.ts | 11 ++-- .../webgl/loader/progressive/loadRequest.ts | 27 +++++---- .../webgl/loader/progressive/mappedG3d.ts | 41 ++++++++++++++ .../loader/progressive/vimMeshFactory.ts | 7 ++- src/vim-web/core-viewers/webgl/loader/vim.ts | 9 +-- 12 files changed, 163 insertions(+), 74 deletions(-) create mode 100644 src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts diff --git a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts index 035cb415c..a85082cd7 100644 --- a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts +++ b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts @@ -2,7 +2,8 @@ * @module vim-loader */ -import { G3d, VimDocument } from 'vim-format' +import { VimDocument } from 'vim-format' +import { MappedG3d } from './progressive/mappedG3d' export class ElementNoMapping { getElementsFromElementId (id: number) { @@ -55,7 +56,7 @@ export class ElementMapping { this._instanceMeshes = instanceMeshes } - static async fromG3d (g3d: G3d, bim: VimDocument) { + static async fromG3d (g3d: MappedG3d, bim: VimDocument) { const instanceToElement = await bim.node.getAllElementIndex() const elementIds = await bim.element.getAllId() diff --git a/src/vim-web/core-viewers/webgl/loader/geometry.ts b/src/vim-web/core-viewers/webgl/loader/geometry.ts index 20464f7db..8b6d2b6b8 100644 --- a/src/vim-web/core-viewers/webgl/loader/geometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/geometry.ts @@ -3,7 +3,8 @@ */ import * as THREE from 'three' -import { G3d, MeshSection } from 'vim-format' +import { MeshSection } from 'vim-format' +import { MappedG3d } from './progressive/mappedG3d' export namespace Transparency { /** @@ -35,7 +36,7 @@ export namespace Transparency { * @param transparent specify to use RGB or RGBA for colors */ export function createGeometryFromMesh ( - g3d: G3d, + g3d: MappedG3d, mesh: number, section: MeshSection, transparent: boolean @@ -60,7 +61,7 @@ export function createGeometryFromMesh ( * Expands submesh colors into vertex colors as RGB or RGBA */ function createVertexColors ( - g3d: G3d, + g3d: MappedG3d, mesh: number, useAlpha: boolean ): Float32Array { diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts index 9f2ea35d0..7148effb7 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts @@ -2,15 +2,16 @@ * @module vim-loader */ -import { G3d, MeshSection, FilterMode } from 'vim-format' +import { MeshSection, FilterMode } from 'vim-format' import { G3dMeshOffsets, G3dMeshCounts } from './g3dOffsets' +import { MappedG3d } from './mappedG3d' /** * Represents a subset of a complete scene definition. * Allows for further filtering or to get offsets needed to build the scene. */ export class G3dSubset { - private _source: G3d + private _source: MappedG3d // source-based indices of included instanced private _instances: number[] @@ -20,17 +21,17 @@ export class G3dSubset { private _meshInstances: Array> /** - * @param source Underlying data source for the subset + * @param source Underlying data source for the subset (must be a MappedG3d with pre-computed map) * @param instances source-based instance indices of included instances. */ constructor ( - source: G3d, + source: MappedG3d, // source-based indices of included instanced instances?: number[] ) { this._source = source - // Consider removing this if too slow. + // Build full instance list if not provided if (!instances) { instances = [] for (let i = 0; i < source.instanceMeshes.length; i++) { @@ -41,23 +42,20 @@ export class G3dSubset { } this._instances = instances - // Compute mesh data. + // Build mesh data from pre-computed G3d map (shared by all subsets) + const g3dMap = this._source._meshInstances + const instanceSet = new Set(this._instances) + this._meshes = [] - const map = new Map>() - for (const instance of instances) { - const mesh = source.instanceMeshes[instance] - if (!map.has(mesh)) { + this._meshInstances = [] + + for (const [mesh, allInstances] of g3dMap) { + const filteredInstances = allInstances.filter((i: number) => instanceSet.has(i)) + if (filteredInstances.length > 0) { this._meshes.push(mesh) - map.set(mesh, [instance]) - } else { - map.get(mesh)?.push(instance) + this._meshInstances.push(filteredInstances) } } - - this._meshInstances = new Array>(this._meshes.length) - for (let i = 0; i < this._meshes.length; i++) { - this._meshInstances[i] = map.get(this._meshes[i]) - } } /** @@ -69,27 +67,31 @@ export class G3dSubset { const chunks: G3dSubset[] = [] let currentSize = 0 let currentInstances: number[] = [] - for(let i = 0; i < this.getMeshCount(); i++) { - + for(let i = 0; i < this._meshes.length; i++) { + // Get mesh size and instances const meshSize = this.getMeshIndexCount(i, 'all') - const instances = this.getMeshInstances(i) + const instances = this._meshInstances[i] currentSize += meshSize - currentInstances.push(...instances) + + // Avoid spread operator - it creates temporary arrays + for (const instance of instances) { + currentInstances.push(instance) + } // Push chunk if size is reached if(currentSize > count) { chunks.push(new G3dSubset(this._source, currentInstances)) currentInstances = [] currentSize = 0 - } + } } - + // Don't forget remaining instances if (currentInstances.length > 0) { chunks.push(new G3dSubset(this._source, currentInstances)) } - + return chunks } @@ -206,9 +208,9 @@ export class G3dSubset { */ getAttributeCounts (section: MeshSection = 'all') { const result = new G3dMeshCounts() - const count = this.getMeshCount() + const count = this._meshes.length for (let i = 0; i < count; i++) { - result.instances += this.getMeshInstanceCount(i) + result.instances += this._meshInstances[i].length result.indices += this.getMeshIndexCount(i, section) result.vertices += this.getMeshVertexCount(i, section) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index c47f59aed..b90a96248 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -24,6 +24,7 @@ import { Scene } from '../scene' import { G3dMeshOffsets } from './g3dOffsets' import { ElementMapping } from '../elementMapping' import { packPickingId } from '../../viewer/rendering/gpuPicker' +import { MappedG3d } from './mappedG3d' export class GeometrySubmesh { instance: number @@ -111,7 +112,7 @@ export class InsertableGeometry { * with offset adjustment, sets per-vertex colors and packed picking IDs, * and creates a GeometrySubmesh tracking the index range and bounding box. */ - insertFromG3d (g3d: G3d, mesh: number) { + insertFromG3d (g3d: MappedG3d, mesh: number) { const added: number[] = [] const meshG3dIndex = this.offsets.subset.getSourceMesh(mesh) const subStart = g3d.getMeshSubmeshStart(meshG3dIndex, this.offsets.section) @@ -261,9 +262,13 @@ export class InsertableGeometry { this._packedIdAttribute.needsUpdate = true if (this._computeBoundingBox) { - this.geometry.computeBoundingBox() - this.geometry.computeBoundingSphere() - this.boundingBox = this.geometry.boundingBox + // Use incrementally computed bounding box (already maintained via expandBox) + // instead of recomputing from all vertices - avoids iterating 4M vertices on each update + this.geometry.boundingBox = this.boundingBox?.clone() ?? null + // Compute bounding sphere from box (cheaper than iterating all vertices) + this.geometry.boundingSphere = this.boundingBox + ? this.boundingBox.getBoundingSphere(new THREE.Sphere()) + : new THREE.Sphere() } } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts index 7e6177313..8998214b2 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts @@ -3,13 +3,14 @@ */ import * as THREE from 'three' -import { G3d, G3dMaterial } from 'vim-format' +import { G3dMaterial } from 'vim-format' import { InsertableGeometry } from './insertableGeometry' import { InsertableSubmesh } from './insertableSubmesh' import { G3dMeshOffsets } from './g3dOffsets' import { Vim } from '../vim' import { ModelMaterial, Materials, applyMaterial } from '../materials/materials' import { ElementMapping } from '../elementMapping' +import { MappedG3d } from './mappedG3d' export class InsertableMesh { offsets: G3dMeshOffsets @@ -72,7 +73,7 @@ export class InsertableMesh { return this.geometry.progress } - insertFromVim (g3d: G3d, mesh: number) { + insertFromVim (g3d: MappedG3d, mesh: number) { this.geometry.insertFromG3d(g3d, mesh) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts index 0740c40d0..fa89d266f 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts @@ -14,10 +14,11 @@ * 4. Finalize with update() to upload buffer ranges to GPU */ -import { G3d, G3dMaterial, MeshSection } from 'vim-format' +import { G3dMaterial, MeshSection } from 'vim-format' import { InsertableMesh } from './insertableMesh' import { G3dSubset } from './g3dSubset' import { ElementMapping } from '../elementMapping' +import { MappedG3d } from './mappedG3d' export class InsertableMeshFactory { private _materials: G3dMaterial @@ -30,7 +31,7 @@ export class InsertableMeshFactory { this._vimIndex = vimIndex } - createOpaqueFromVim (g3d: G3d, subset: G3dSubset) { + createOpaqueFromVim (g3d: MappedG3d, subset: G3dSubset) { // Skip if no opaque geometry if (!subset.getOffsets('opaque').any()) { return undefined @@ -38,7 +39,7 @@ export class InsertableMeshFactory { return this.createFromVim(g3d, subset, 'opaque', false) } - createTransparentFromVim (g3d: G3d, subset: G3dSubset) { + createTransparentFromVim (g3d: MappedG3d, subset: G3dSubset) { // Skip if no transparent geometry if (!subset.getOffsets('transparent').any()) { return undefined @@ -53,7 +54,7 @@ export class InsertableMeshFactory { * 3. Finalize: upload dirty buffer ranges to GPU */ private createFromVim ( - g3d: G3d, + g3d: MappedG3d, subset: G3dSubset, section: MeshSection, transparent: boolean diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts index 9e625ddf8..7f3d0b47c 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts @@ -12,7 +12,7 @@ export class InstancedMesh { mesh: THREE.InstancedMesh instances: ArrayLike boundingBox: THREE.Box3 - boxes: THREE.Box3[] + private _boxes?: THREE.Box3[] // State ignoreSceneMaterial: boolean @@ -28,9 +28,12 @@ export class InstancedMesh { this.mesh.userData.vim = this this.instances = instances - this.boxes = this.computeBoundingBoxes() - this.size = this.boxes[0]?.getSize(new THREE.Vector3()).length() ?? 0 - this.boundingBox = this.computeBoundingBox(this.boxes) + // Compute size from geometry bounding box (untransformed, represents typical instance size) + this.mesh.geometry.computeBoundingBox() + this.size = this.mesh.geometry.boundingBox?.getSize(new THREE.Vector3()).length() ?? 0 + + // Compute overall bounding box without allocating per-instance boxes + this.boundingBox = this.computeBoundingBox() this._material = this.mesh.material } @@ -38,6 +41,17 @@ export class InstancedMesh { return false } + /** + * Returns all per-instance bounding boxes. + * Computed lazily on first access - only allocates if actually needed. + */ + get boxes(): THREE.Box3[] { + if (!this._boxes) { + this._boxes = this.computeBoundingBoxes() + } + return this._boxes + } + /** * Returns submesh for given index. */ @@ -73,11 +87,26 @@ export class InstancedMesh { return boxes } - computeBoundingBox (boxes: THREE.Box3[]) { - const box = boxes[0].clone() - for (let i = 1; i < boxes.length; i++) { - box.union(boxes[i]) + /** + * Computes overall bounding box without allocating per-instance boxes. + * This is more efficient than computing all boxes upfront when only the + * overall bounds are needed. + */ + private computeBoundingBox (): THREE.Box3 { + // Geometry bounding box already computed in constructor + const matrix = new THREE.Matrix4() + let result: THREE.Box3 | undefined + + for (let i = 0; i < this.mesh.count; i++) { + this.mesh.getMatrixAt(i, matrix) + const box = this.mesh.geometry.boundingBox.clone().applyMatrix4(matrix) + if (result) { + result.union(box) + } else { + result = box + } } - return box + + return result ?? new THREE.Box3() } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 86ec2bae5..710e8c38c 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -10,12 +10,13 @@ */ import * as THREE from 'three' -import { G3d, MeshSection } from 'vim-format' +import { MeshSection } from 'vim-format' import { InstancedMesh } from './instancedMesh' import { Materials } from '../materials/materials' import * as Geometry from '../geometry' import { ElementMapping } from '../elementMapping' import { packPickingId } from '../../viewer/rendering/gpuPicker' +import { MappedG3d } from './mappedG3d' export class InstancedMeshFactory { private _mapping: ElementMapping | undefined @@ -26,7 +27,7 @@ export class InstancedMeshFactory { this._vimIndex = vimIndex } - createOpaqueFromVim (g3d: G3d, mesh: number, instances: number[]) { + createOpaqueFromVim (g3d: MappedG3d, mesh: number, instances: number[]) { // Skip if no opaque geometry if (g3d.getMeshIndexEnd(mesh, 'opaque') <= g3d.getMeshIndexStart(mesh, 'opaque')) { return undefined @@ -34,7 +35,7 @@ export class InstancedMeshFactory { return this.createFromVim(g3d, mesh, instances, 'opaque', false) } - createTransparentFromVim (g3d: G3d, mesh: number, instances: number[]) { + createTransparentFromVim (g3d: MappedG3d, mesh: number, instances: number[]) { // Skip if no transparent geometry if (g3d.getMeshIndexEnd(mesh, 'transparent') <= g3d.getMeshIndexStart(mesh, 'transparent')) { return undefined @@ -47,7 +48,7 @@ export class InstancedMeshFactory { * then sets per-instance transforms and packed picking IDs. */ createFromVim ( - g3d: G3d, + g3d: MappedG3d, mesh: number, instances: number[] | undefined, section: MeshSection, @@ -77,7 +78,7 @@ export class InstancedMeshFactory { private setMatrices ( three: THREE.InstancedMesh, - source: G3d, + source: MappedG3d, instances: number[] ) { const matrix = new THREE.Matrix4() diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts index 7a3022778..080a19930 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -21,6 +21,7 @@ import { G3dMaterial } from 'vim-format' import { DefaultLog } from 'vim-format/dist/logging' +import { createMappedG3d } from './mappedG3d' export type RequestSource = { url?: string, @@ -57,10 +58,11 @@ export class LoadRequest extends BaseLoadRequest { /** * Parses a VIM file into a Vim object. Steps: * 1. Parse G3d geometry from the BFast 'geometry' buffer - * 2. Parse BIM document (VimDocument) from the BFast - * 3. Build ElementMapping (instance → element index) needed for GPU picking - * 4. Create Scene and VimMeshFactory (no geometry built yet) - * 5. Return Vim — caller must invoke loadAll()/loadSubset() to build meshes + * 2. Augment G3d with pre-computed mesh→instances map (MappedG3d) + * 3. Parse BIM document (VimDocument) from the BFast + * 4. Build ElementMapping (instance → element index) needed for GPU picking + * 5. Create Scene and VimMeshFactory (no geometry built yet) + * 6. Return Vim — caller must invoke loadAll()/loadSubset() to build meshes */ private async loadFromVim ( bfast: BFast, @@ -79,23 +81,26 @@ export class LoadRequest extends BaseLoadRequest { // Step 1: Parse G3d geometry const geometry = await bfast.getBfast('geometry') const g3d = await G3d.createFromBfast(geometry) - const materials = new G3dMaterial(g3d.materialColors) - // Step 2-3: Parse BIM document and build instance → element mapping + // Step 2: Augment with pre-computed mesh→instances map (shared by all G3dSubsets) + const mappedG3d = createMappedG3d(g3d) + const materials = new G3dMaterial(mappedG3d.materialColors) + + // Step 3-4: Parse BIM document and build instance → element mapping const doc = await VimDocument.createFromBfast(bfast) - const mapping = await ElementMapping.fromG3d(g3d, doc) + const mapping = await ElementMapping.fromG3d(mappedG3d, doc) - // Step 4: Create scene and factory (factory needs mapping for GPU picking IDs) + // Step 5: Create scene and factory (factory needs mapping for GPU picking IDs) const scene = new Scene(fullSettings.matrix) - const factory = new VimMeshFactory(g3d, materials, scene, mapping, vimIndex) + const factory = new VimMeshFactory(mappedG3d, materials, scene, mapping, vimIndex) const header = await requestHeader(bfast) - // Step 5: Create Vim — geometry will be built later via loadAll()/loadSubset() + // Step 6: Create Vim — geometry will be built later via loadAll()/loadSubset() const vim = new Vim( header, doc, - g3d, + mappedG3d, scene, fullSettings, vimIndex, diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts new file mode 100644 index 000000000..ddad51d27 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts @@ -0,0 +1,41 @@ +/** + * @module vim-loader + */ + +import { G3d } from 'vim-format' + +/** + * G3d augmented with a pre-computed mesh→instances map. + * The map is computed once during loading and shared by all G3dSubsets, + * eliminating O(N) iterations on every subset construction. + */ +export interface MappedG3d extends G3d { + _meshInstances: Map +} + +/** + * Augments a G3d instance with the pre-computed mesh→instances map. + * This should be called once during the loading pipeline, right after + * G3d.createFromBfast(). + * + * @param g3d The G3d instance to augment + * @returns The same G3d instance, now typed as MappedG3d + */ +export function createMappedG3d(g3d: G3d): MappedG3d { + const mapped = g3d as MappedG3d + + // Build the mesh→instances map + const map = new Map() + for (let i = 0; i < g3d.instanceMeshes.length; i++) { + const mesh = g3d.instanceMeshes[i] + if (mesh >= 0) { + if (!map.has(mesh)) { + map.set(mesh, []) + } + map.get(mesh)!.push(i) + } + } + mapped._meshInstances = map + + return mapped +} diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts index 33965201c..5bc74931a 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts @@ -11,19 +11,20 @@ */ import { Scene } from '../scene' -import { G3dMaterial, G3d } from 'vim-format' +import { G3dMaterial } from 'vim-format' import { InsertableMeshFactory } from './insertableMeshFactory' import { InstancedMeshFactory } from './instancedMeshFactory' import { G3dSubset } from './g3dSubset' import { ElementMapping } from '../elementMapping' +import { MappedG3d } from './mappedG3d' export class VimMeshFactory { - readonly g3d: G3d + readonly g3d: MappedG3d private _insertableFactory: InsertableMeshFactory private _instancedFactory: InstancedMeshFactory private _scene: Scene - constructor (g3d: G3d, materials: G3dMaterial, scene: Scene, mapping: ElementMapping, vimIndex: number = 0) { + constructor (g3d: MappedG3d, materials: G3dMaterial, scene: Scene, mapping: ElementMapping, vimIndex: number = 0) { this.g3d = g3d this._scene = scene this._insertableFactory = new InsertableMeshFactory(materials, mapping, vimIndex) diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index 955d08085..bbe9d3c5b 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -3,7 +3,7 @@ */ import * as THREE from 'three' -import { VimDocument, G3d, VimHeader, FilterMode } from 'vim-format' +import { VimDocument, VimHeader, FilterMode } from 'vim-format' import { Scene } from './scene' import { VimSettings } from './vimSettings' import { Element3D } from './element3d' @@ -17,6 +17,7 @@ import { G3dSubset } from './progressive/g3dSubset' import { VimMeshFactory } from './progressive/vimMeshFactory' import { IVim } from '../../shared/vim' import { IProgress } from '../../shared/loadResult' +import { MappedG3d } from './progressive/mappedG3d' /** * Represents a container for the built three.js meshes and the vim data from which they were constructed. @@ -51,9 +52,9 @@ export class Vim implements IVim { readonly bim: VimDocument | undefined /** - * The raw g3d geometry scene definition. + * The raw g3d geometry scene definition with pre-computed mesh map. */ - readonly g3d: G3d | undefined + readonly g3d: MappedG3d | undefined /** * The settings used when this vim was opened. @@ -96,7 +97,7 @@ export class Vim implements IVim { constructor ( header: VimHeader | undefined, document: VimDocument, - g3d: G3d | undefined, + g3d: MappedG3d | undefined, scene: Scene, settings: VimSettings, vimIndex: number, From 854e28810804cc002243aad7b75f72477739d7bb Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 16:39:28 -0500 Subject: [PATCH 81/94] array maps --- .../webgl/loader/progressive/g3dSubset.ts | 39 +++++++++++++++++++ .../loader/progressive/vimMeshFactory.ts | 8 ++-- .../core-viewers/webgl/loader/scene.ts | 34 ++++++++++------ 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts index 7148effb7..da1938e1b 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts @@ -196,6 +196,45 @@ export class G3dSubset { return new G3dSubset(this._source, instances) } + /** + * Splits subset into two based on instance count threshold. + * More efficient than calling filterByCount twice - does single pass. + * @param threshold Instance count threshold + * @returns [low (<=threshold), high (>threshold)] + */ + splitByCount (threshold: number): [G3dSubset, G3dSubset] { + const lowMeshes = new Set() + const highMeshes = new Set() + + // Single pass through mesh instances + this._meshInstances.forEach((instances, i) => { + const mesh = this._meshes[i] + if (instances.length <= threshold) { + lowMeshes.add(mesh) + } else { + highMeshes.add(mesh) + } + }) + + // Single pass through instances to split them + const lowInstances: number[] = [] + const highInstances: number[] = [] + + for (const instance of this._instances) { + const mesh = this._source.instanceMeshes[instance] + if (lowMeshes.has(mesh)) { + lowInstances.push(instance) + } else if (highMeshes.has(mesh)) { + highInstances.push(instance) + } + } + + return [ + new G3dSubset(this._source, lowInstances), + new G3dSubset(this._source, highInstances) + ] + } + /** * Returns offsets needed to build geometry. */ diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts index 5bc74931a..9505411cb 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts @@ -38,13 +38,13 @@ export class VimMeshFactory { * - >5 instances per mesh → GPU instanced (geometry shared, one mesh per unique geometry) */ public add (subset: G3dSubset) { - const uniques = subset.filterByCount((count) => count <= 5) - const nonUniques = subset.filterByCount((count) => count > 5) + // Split in single pass instead of two filterByCount calls + const [merged, instanced] = subset.splitByCount(5) // Instanced meshes first (one Three.js InstancedMesh per unique geometry) - this.addInstancedMeshes(this._scene, nonUniques) + this.addInstancedMeshes(this._scene, instanced) // Merged meshes chunked at 4M indices to keep buffer sizes manageable - const chunks = uniques.chunks(4_000_000) + const chunks = merged.chunks(4_000_000) for(const chunk of chunks) { this.addMergedMesh(this._scene, chunk) } diff --git a/src/vim-web/core-viewers/webgl/loader/scene.ts b/src/vim-web/core-viewers/webgl/loader/scene.ts index 4207cbda0..6d243ec5f 100644 --- a/src/vim-web/core-viewers/webgl/loader/scene.ts +++ b/src/vim-web/core-viewers/webgl/loader/scene.ts @@ -43,7 +43,8 @@ export class Scene { private _averageBoundingBox: THREE.Box3 | undefined - private _instanceToMeshes: Map = new Map() + // Array-based lookup for O(1) access (instance indices are dense 0..N) + private _instanceToMeshes: Array = [] private _material: ModelMaterial constructor (matrix: THREE.Matrix4) { @@ -107,7 +108,7 @@ export class Scene { * For instanced mesh, index refers to instance index. */ getMeshFromInstance (instance: number) { - return this._instanceToMeshes.get(instance) + return this._instanceToMeshes[instance] } getMeshesFromInstances (instances: number[] | undefined) { @@ -118,7 +119,7 @@ export class Scene { const instance = instances[i] if (instance < 0) continue const submeshes = this.getMeshFromInstance(instance) - submeshes?.forEach((s) => meshes.push(s)) + submeshes?.forEach((s: Submesh) => meshes.push(s)) } if (meshes.length === 0) return return meshes @@ -150,9 +151,12 @@ export class Scene { * so that visibility/color/outline changes propagate to the right geometry. */ addSubmesh (submesh: Submesh) { - const meshes = this._instanceToMeshes.get(submesh.instance) ?? [] + let meshes = this._instanceToMeshes[submesh.instance] + if (!meshes) { + meshes = [] + this._instanceToMeshes[submesh.instance] = meshes + } meshes.push(submesh) - this._instanceToMeshes.set(submesh.instance, meshes) this.setDirty() if (this.vim) { const obj = this.vim.getElement(submesh.instance) @@ -190,11 +194,19 @@ export class Scene { merge (other: Scene) { if (!other) return this other.meshes.forEach((mesh) => this.meshes.push(mesh)) - other._instanceToMeshes.forEach((meshes, instance) => { - const set = this._instanceToMeshes.get(instance) ?? [] - meshes.forEach((m) => set.push(m)) - this._instanceToMeshes.set(instance, set) - }) + + // Merge instance→mesh mappings + for (let instance = 0; instance < other._instanceToMeshes.length; instance++) { + const otherMeshes = other._instanceToMeshes[instance] + if (!otherMeshes) continue + + let thisMeshes = this._instanceToMeshes[instance] + if (!thisMeshes) { + thisMeshes = [] + this._instanceToMeshes[instance] = thisMeshes + } + otherMeshes.forEach((m) => thisMeshes!.push(m)) + } if (other._boundingBox) { this._boundingBox = @@ -234,7 +246,7 @@ export class Scene { m.mesh.geometry.dispose() } this.meshes.length = 0 - this._instanceToMeshes.clear() + this._instanceToMeshes.length = 0 this.renderer?.add(this) this._boundingBox = undefined From b88da18483929622b371d5de5d26892cb003468c Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 18:10:20 -0500 Subject: [PATCH 82/94] working colors --- .../core-viewers/webgl/loader/geometry.ts | 48 ++++++++++++++++++- .../loader/materials/standardMaterial.ts | 37 +++++++++++++- .../loader/progressive/insertableGeometry.ts | 25 +++++++++- .../webgl/loader/progressive/loadRequest.ts | 44 +++++++++++++++++ .../loader/progressive/vimMeshFactory.ts | 6 +-- 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/geometry.ts b/src/vim-web/core-viewers/webgl/loader/geometry.ts index 8b6d2b6b8..ebb395642 100644 --- a/src/vim-web/core-viewers/webgl/loader/geometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/geometry.ts @@ -42,6 +42,7 @@ export function createGeometryFromMesh ( transparent: boolean ): THREE.BufferGeometry { const colors = createVertexColors(g3d, mesh, transparent) + const submeshIndices = createSubmeshIndices(g3d, mesh, section) const positions = g3d.positions.subarray( g3d.getMeshVertexStart(mesh) * 3, g3d.getMeshVertexEnd(mesh) * 3 @@ -54,7 +55,8 @@ export function createGeometryFromMesh ( positions, indices, colors, - transparent ? 4 : 3 + transparent ? 4 : 3, + submeshIndices ) } /** @@ -87,19 +89,53 @@ function createVertexColors ( return result } +/** + * Creates submesh indices for each vertex (for color palette lookup) + * Uses color index mapping if available for memory optimization + */ +function createSubmeshIndices ( + g3d: MappedG3d, + mesh: number, + section: MeshSection +): Uint16Array { + const vertexCount = g3d.getMeshVertexCount(mesh) + const result = new Uint16Array(vertexCount) + + const subStart = g3d.getMeshSubmeshStart(mesh, section) + const subEnd = g3d.getMeshSubmeshEnd(mesh, section) + const colorIndexMap = (g3d as any).submeshToColorIndex // Unique color palette mapping + + for (let submesh = subStart; submesh < subEnd; submesh++) { + const start = g3d.getSubmeshIndexStart(submesh) + const end = g3d.getSubmeshIndexEnd(submesh) + + // Use color index if available, otherwise submesh index + const index = colorIndexMap?.[submesh] ?? submesh + + for (let i = start; i < end; i++) { + const vertexIndex = g3d.indices[i] + result[vertexIndex] = index + } + } + + return result +} + /** * Creates a BufferGeometry from given geometry data arrays * @param vertices vertex data with 3 number per vertex (XYZ) * @param indices index data with 3 indices per face * @param vertexColors color data with 3 or 4 number per vertex. RBG or RGBA * @param colorSize specify whether to treat colors as RGB or RGBA + * @param submeshIndices submesh index per vertex for color palette lookup * @returns a BufferGeometry */ export function createGeometryFromArrays ( vertices: Float32Array, indices: Uint32Array, vertexColors: Float32Array | undefined = undefined, - colorSize: number = 3 + colorSize: number = 3, + submeshIndices: Uint16Array | undefined = undefined ): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() @@ -117,5 +153,13 @@ export function createGeometryFromArrays ( ) } + // Submesh indices for color palette lookup + if (submeshIndices) { + geometry.setAttribute( + 'submeshIndex', + new THREE.Uint16BufferAttribute(submeshIndices, 1) + ) + } + return geometry } diff --git a/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts index 85682c564..59ee5f319 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts @@ -55,11 +55,30 @@ export class StandardMaterial { _sectionStrokeFallof: number = 0.75 _sectionStrokeColor: THREE.Color = new THREE.Color(0xf6f6f6) + // NEW: Submesh color palette for indexed color lookup + _submeshColors: Float32Array | undefined + _useSubmeshColors: boolean = false + constructor (material: THREE.Material) { this.material = material this.patchShader(material) } + /** + * Sets the submesh color palette for indexed color lookup. + * Each color is RGB (3 floats). Pass undefined to disable. + */ + setSubmeshColors(colors: Float32Array | undefined) { + this._submeshColors = colors + this._useSubmeshColors = colors !== undefined && colors.length > 0 + if (this.uniforms) { + // Always provide 1024-element array to match shader uniform declaration + // WebGL requires array size to match uniform declaration, even when disabled + this.uniforms.submeshColors.value = colors ?? new Float32Array(1024) + this.uniforms.useSubmeshColors.value = this._useSubmeshColors ? 1.0 : 0.0 + } + } + get color () { if (this.material instanceof THREE.MeshLambertMaterial) { return this.material.color @@ -154,6 +173,9 @@ export class StandardMaterial { this.uniforms.sectionStrokeWidth = { value: this._sectionStrokeWitdh } this.uniforms.sectionStrokeFalloff = { value: this._sectionStrokeFallof } this.uniforms.sectionStrokeColor = { value: this._sectionStrokeColor } + // NEW: Submesh color palette for indexed lookup (must be 1024 elements to match shader uniform) + this.uniforms.submeshColors = { value: this._submeshColors ?? new Float32Array(1024) } + this.uniforms.useSubmeshColors = { value: this._useSubmeshColors ? 1.0 : 0.0 } shader.vertexShader = shader.vertexShader // VERTEX DECLARATIONS @@ -161,7 +183,7 @@ export class StandardMaterial { '#include ', ` #include - + // COLORING // attribute for color override @@ -176,6 +198,11 @@ export class StandardMaterial { attribute vec3 instanceColor; #endif + // NEW: Submesh index for color palette lookup + attribute float submeshIndex; + uniform float submeshColors[1024]; // 341 colors × RGB (1024 floats, WebGL minimum) + uniform float useSubmeshColors; // 1.0 = use palette, 0.0 = use vertex color + // Passed to fragment to ignore phong model varying float vColored; @@ -203,10 +230,16 @@ export class StandardMaterial { vColor = color; vColored = colored; + // NEW: Override with submesh color palette if enabled + if (useSubmeshColors > 0.5) { + int idx = int(submeshIndex) * 3; + vColor.xyz = vec3(submeshColors[idx], submeshColors[idx + 1], submeshColors[idx + 2]); + } + // colored == 1 -> instance color // colored == 0 -> vertex color #ifdef USE_INSTANCING - vColor.xyz = colored * instanceColor.xyz + (1.0f - colored) * color.xyz; + vColor.xyz = colored * instanceColor.xyz + (1.0f - colored) * vColor.xyz; #endif // VISIBILITY diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index b90a96248..d69370b40 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -8,7 +8,8 @@ * Buffer layout (all pre-allocated via G3dMeshOffsets): * - index: Uint32 — triangle indices * - position: Float32x3 — world-space vertices (transforms baked in) - * - color: Float32x3 (opaque) or Float32x4 (transparent) — per-vertex color + * - color: Float32x3 (opaque) or Float32x4 (transparent) — per-vertex color (legacy, kept for compatibility) + * - submeshIndex: Uint16 — per-vertex submesh index for color palette lookup (NEW) * - packedId: Uint32 — per-vertex (vimIndex << 24 | elementIndex) for GPU picking * * Geometry is inserted incrementally via insertFromG3d(), which iterates over @@ -51,6 +52,7 @@ export class InsertableGeometry { private _indexAttribute: THREE.Uint32BufferAttribute private _vertexAttribute: THREE.BufferAttribute private _colorAttribute: THREE.BufferAttribute + private _submeshIndexAttribute: THREE.Uint16BufferAttribute // NEW: submesh index for color lookup private _packedIdAttribute: THREE.Uint32BufferAttribute private _mapping: ElementMapping | undefined private _vimIndex: number @@ -87,6 +89,12 @@ export class InsertableGeometry { colorSize ) + // NEW: Submesh index for color palette lookup (uint16 supports 65k submeshes) + this._submeshIndexAttribute = new THREE.Uint16BufferAttribute( + offsets.counts.vertices, + 1 + ) + // Packed ID attribute for GPU picking: (vimIndex << 24) | elementIndex this._packedIdAttribute = new THREE.Uint32BufferAttribute( offsets.counts.vertices, @@ -97,6 +105,7 @@ export class InsertableGeometry { this.geometry.setIndex(this._indexAttribute) this.geometry.setAttribute('position', this._vertexAttribute) this.geometry.setAttribute('color', this._colorAttribute) + this.geometry.setAttribute('submeshIndex', this._submeshIndexAttribute) // NEW this.geometry.setAttribute('packedId', this._packedIdAttribute) this._computeBoundingBox = true @@ -164,7 +173,11 @@ export class InsertableGeometry { for (let index = indexStart; index < indexEnd; index++) { const v = vertexOffset + mergeOffset + g3d.indices[index] this.setIndex(indexOffset + indexOut, v) - this.setColor(v, color, 0.25) + this.setColor(v, color, 0.25) // Keep for now (backwards compatibility) + + // NEW: Use color index if palette optimization is enabled, otherwise submesh index + const colorIndex = (g3d as any).submeshToColorIndex?.[sub] ?? sub + this.setSubmeshIndex(v, colorIndex) indexOut++ } } @@ -204,6 +217,10 @@ export class InsertableGeometry { } } + private setSubmeshIndex (index: number, submeshIndex: number) { + this._submeshIndexAttribute.setX(index, submeshIndex) + } + private setPackedId (index: number, elementIndex: number) { this._packedIdAttribute.setX(index, packPickingId(this._vimIndex, elementIndex)) } @@ -257,6 +274,10 @@ export class InsertableGeometry { // this._colorAttribute.count = vertexEnd this._colorAttribute.needsUpdate = true + // update submesh indices (itemSize is 1) + this._submeshIndexAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) + this._submeshIndexAttribute.needsUpdate = true + // update packed IDs (itemSize is 1) this._packedIdAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) this._packedIdAttribute.needsUpdate = true diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts index 080a19930..c8124a4b9 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -86,6 +86,43 @@ export class LoadRequest extends BaseLoadRequest { const mappedG3d = createMappedG3d(g3d) const materials = new G3dMaterial(mappedG3d.materialColors) + // NEW: Build unique color palette for shader lookup + const submeshColorCount = mappedG3d.submeshMaterial.length + const maxColors = 341 // Must match shader uniform array size (1024 floats / 3) + + // Build palette of unique colors only + const uniqueColorsMap = new Map() // color key → colorIndex + const colorPaletteArray: number[] = [] + const submeshToColorIndex = new Uint16Array(submeshColorCount) + + for (let i = 0; i < submeshColorCount; i++) { + const color = mappedG3d.getSubmeshColor(i) + const key = `${color[0].toFixed(6)},${color[1].toFixed(6)},${color[2].toFixed(6)}` + + let colorIndex = uniqueColorsMap.get(key) + if (colorIndex === undefined) { + colorIndex = colorPaletteArray.length / 3 + uniqueColorsMap.set(key, colorIndex) + colorPaletteArray.push(color[0], color[1], color[2]) + } + + submeshToColorIndex[i] = colorIndex + } + + const uniqueColorCount = uniqueColorsMap.size + let submeshColorPalette: Float32Array | undefined + + if (uniqueColorCount <= maxColors) { + submeshColorPalette = new Float32Array(colorPaletteArray) + const paletteSizeKB = (submeshColorPalette.length * 4 / 1024).toFixed(1) + console.log(`[Color Optimization] Enabled: ${submeshColorCount} submeshes → ${uniqueColorCount} unique colors, palette size: ${paletteSizeKB} KB`) + + // Store the mapping in mappedG3d for geometry builders to use + ;(mappedG3d as any).submeshToColorIndex = submeshToColorIndex + } else { + console.warn(`[Color Optimization] Disabled: Model has ${uniqueColorCount} unique colors (max ${maxColors}). Using vertex colors.`) + } + // Step 3-4: Parse BIM document and build instance → element mapping const doc = await VimDocument.createFromBfast(bfast) const mapping = await ElementMapping.fromG3d(mappedG3d, doc) @@ -94,6 +131,13 @@ export class LoadRequest extends BaseLoadRequest { const scene = new Scene(fullSettings.matrix) const factory = new VimMeshFactory(mappedG3d, materials, scene, mapping, vimIndex) + // NEW: Set or clear submesh color palette on materials + // Always call this to ensure proper state (enabled or disabled) + const { Materials } = await import('../materials/materials') + const sharedMaterials = Materials.getInstance() + sharedMaterials.opaque.setSubmeshColors(submeshColorPalette) // undefined if disabled + sharedMaterials.transparent.setSubmeshColors(submeshColorPalette) + const header = await requestHeader(bfast) // Step 6: Create Vim — geometry will be built later via loadAll()/loadSubset() diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts index 9505411cb..b74f8b087 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts @@ -7,7 +7,7 @@ * - Meshes with <=5 instances → InsertableMeshFactory (merged, geometry duplicated per instance) * - Meshes with >5 instances → InstancedMeshFactory (GPU instanced, geometry shared) * - * Merged meshes are further chunked at 4M indices to keep buffer sizes manageable. + * Merged meshes are chunked at 16M indices (GPU picking allows larger chunks without raycast penalty). */ import { Scene } from '../scene' @@ -43,8 +43,8 @@ export class VimMeshFactory { // Instanced meshes first (one Three.js InstancedMesh per unique geometry) this.addInstancedMeshes(this._scene, instanced) - // Merged meshes chunked at 4M indices to keep buffer sizes manageable - const chunks = merged.chunks(4_000_000) + // Merged meshes chunked at 16M indices (GPU picking removes raycast traversal constraint) + const chunks = merged.chunks(16_000_000) for(const chunk of chunks) { this.addMergedMesh(this._scene, chunk) } From b51a92ca947a92002f3f4af0a4f21d19b5843c4f Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 19:41:29 -0500 Subject: [PATCH 83/94] color in a teexture --- .../core-viewers/webgl/loader/geometry.ts | 62 +--------- .../webgl/loader/materials/colorPalette.ts | 108 ++++++++++++++++++ .../webgl/loader/materials/materials.ts | 53 +++++++++ .../loader/materials/standardMaterial.ts | 45 +++++--- .../loader/progressive/insertableGeometry.ts | 33 +----- .../progressive/instancedMeshFactory.ts | 3 +- .../webgl/loader/progressive/loadRequest.ts | 48 +------- .../webgl/loader/progressive/mappedG3d.ts | 23 +++- 8 files changed, 224 insertions(+), 151 deletions(-) create mode 100644 src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts diff --git a/src/vim-web/core-viewers/webgl/loader/geometry.ts b/src/vim-web/core-viewers/webgl/loader/geometry.ts index ebb395642..e4ee381a0 100644 --- a/src/vim-web/core-viewers/webgl/loader/geometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/geometry.ts @@ -21,27 +21,18 @@ export namespace Transparency { value ) } - - /** - * Returns true if the transparency mode requires to use RGBA colors - */ - export function requiresAlpha (mode: Mode) { - return mode === 'all' || mode === 'transparentOnly' - } } /** * Creates a BufferGeometry from a given mesh index in the g3d * @param mesh g3d mesh index - * @param transparent specify to use RGB or RGBA for colors */ export function createGeometryFromMesh ( g3d: MappedG3d, mesh: number, - section: MeshSection, - transparent: boolean + section: MeshSection ): THREE.BufferGeometry { - const colors = createVertexColors(g3d, mesh, transparent) + // Colors now come from texture lookup, no need to compute vertex colors const submeshIndices = createSubmeshIndices(g3d, mesh, section) const positions = g3d.positions.subarray( g3d.getMeshVertexStart(mesh) * 3, @@ -51,44 +42,14 @@ export function createGeometryFromMesh ( const start = g3d.getMeshIndexStart(mesh, section) const end = g3d.getMeshIndexEnd(mesh, section) const indices = g3d.indices.subarray(start, end) + + // No color attribute - all colors come from texture lookup return createGeometryFromArrays( positions, indices, - colors, - transparent ? 4 : 3, submeshIndices ) } -/** - * Expands submesh colors into vertex colors as RGB or RGBA - */ -function createVertexColors ( - g3d: MappedG3d, - mesh: number, - useAlpha: boolean -): Float32Array { - const colorSize = useAlpha ? 4 : 3 - const result = new Float32Array(g3d.getMeshVertexCount(mesh) * colorSize) - - const subStart = g3d.getMeshSubmeshStart(mesh) - const subEnd = g3d.getMeshSubmeshEnd(mesh) - - for (let submesh = subStart; submesh < subEnd; submesh++) { - const color = g3d.getSubmeshColor(submesh) - const start = g3d.getSubmeshIndexStart(submesh) - const end = g3d.getSubmeshIndexEnd(submesh) - - for (let i = start; i < end; i++) { - const v = g3d.indices[i] * colorSize - result[v] = color[0] - result[v + 1] = color[1] - result[v + 2] = color[2] - if (useAlpha) result[v + 3] = color[3] - } - } - return result -} - /** * Creates submesh indices for each vertex (for color palette lookup) * Uses color index mapping if available for memory optimization @@ -103,14 +64,13 @@ function createSubmeshIndices ( const subStart = g3d.getMeshSubmeshStart(mesh, section) const subEnd = g3d.getMeshSubmeshEnd(mesh, section) - const colorIndexMap = (g3d as any).submeshToColorIndex // Unique color palette mapping for (let submesh = subStart; submesh < subEnd; submesh++) { const start = g3d.getSubmeshIndexStart(submesh) const end = g3d.getSubmeshIndexEnd(submesh) // Use color index if available, otherwise submesh index - const index = colorIndexMap?.[submesh] ?? submesh + const index = g3d.submeshToColorIndex?.[submesh] ?? submesh for (let i = start; i < end; i++) { const vertexIndex = g3d.indices[i] @@ -125,16 +85,12 @@ function createSubmeshIndices ( * Creates a BufferGeometry from given geometry data arrays * @param vertices vertex data with 3 number per vertex (XYZ) * @param indices index data with 3 indices per face - * @param vertexColors color data with 3 or 4 number per vertex. RBG or RGBA - * @param colorSize specify whether to treat colors as RGB or RGBA * @param submeshIndices submesh index per vertex for color palette lookup * @returns a BufferGeometry */ export function createGeometryFromArrays ( vertices: Float32Array, indices: Uint32Array, - vertexColors: Float32Array | undefined = undefined, - colorSize: number = 3, submeshIndices: Uint16Array | undefined = undefined ): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() @@ -145,14 +101,6 @@ export function createGeometryFromArrays ( // Indices geometry.setIndex(new THREE.Uint32BufferAttribute(indices, 1)) - // Colors with alpha if transparent - if (vertexColors) { - geometry.setAttribute( - 'color', - new THREE.BufferAttribute(vertexColors, colorSize) - ) - } - // Submesh indices for color palette lookup if (submeshIndices) { geometry.setAttribute( diff --git a/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts b/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts new file mode 100644 index 000000000..b9a8e3613 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts @@ -0,0 +1,108 @@ +/** + * @module vim-loader/materials + * + * Color palette optimization for submesh colors. + * Builds a unique color palette from all submeshes to minimize GPU memory usage. + * If the model has too many unique colors, applies quantization to fit within limits. + */ + +import { MappedG3d } from '../progressive/mappedG3d' + +const MAX_COLORS = 16384 // 128×128 texture (RGBA) +const QUANTIZATION_LEVELS = 25 // 25³ = 15,625 max colors + +export type ColorPaletteResult = { + palette: Float32Array | undefined + submeshToColorIndex: Uint16Array + uniqueColorCount: number +} + +/** + * Builds a unique color palette from submesh colors. + * If uniqueColorCount > MAX_COLORS, quantizes colors in-place in mappedG3d.materialColors. + * + * @param mappedG3d - The mapped G3d geometry with material colors + * @param submeshColorCount - Total number of submeshes + * @returns Color palette (undefined if disabled), submesh→colorIndex mapping, and unique color count + */ +export function buildColorPalette( + mappedG3d: MappedG3d, + submeshColorCount: number +): ColorPaletteResult { + // Build unique color palette for shader lookup + const uniqueColorsMap = new Map() // color key → colorIndex + const colorPaletteArray: number[] = [] + const submeshToColorIndex = new Uint16Array(submeshColorCount) + + // First pass: build initial palette + for (let i = 0; i < submeshColorCount; i++) { + const color = mappedG3d.getSubmeshColor(i) + const key = `${color[0].toFixed(6)},${color[1].toFixed(6)},${color[2].toFixed(6)}` + + let colorIndex = uniqueColorsMap.get(key) + if (colorIndex === undefined) { + colorIndex = colorPaletteArray.length / 3 + uniqueColorsMap.set(key, colorIndex) + colorPaletteArray.push(color[0], color[1], color[2]) + } + + submeshToColorIndex[i] = colorIndex + } + + let uniqueColorCount = uniqueColorsMap.size + + // If too many unique colors, quantize them in-place + if (uniqueColorCount > MAX_COLORS) { + console.log(`[Color Optimization] Quantizing: ${uniqueColorCount} unique colors → target ${MAX_COLORS}`) + + quantizeColors(mappedG3d.materialColors, QUANTIZATION_LEVELS) + + // Rebuild palette with quantized colors + uniqueColorsMap.clear() + colorPaletteArray.length = 0 + + for (let i = 0; i < submeshColorCount; i++) { + const color = mappedG3d.getSubmeshColor(i) + const key = `${color[0].toFixed(6)},${color[1].toFixed(6)},${color[2].toFixed(6)}` + + let colorIndex = uniqueColorsMap.get(key) + if (colorIndex === undefined) { + colorIndex = colorPaletteArray.length / 3 + uniqueColorsMap.set(key, colorIndex) + colorPaletteArray.push(color[0], color[1], color[2]) + } + + submeshToColorIndex[i] = colorIndex + } + + uniqueColorCount = uniqueColorsMap.size + console.log(`[Color Optimization] Quantization result: ${uniqueColorCount} unique colors`) + } + + // Return palette if within limits, otherwise undefined (disable optimization) + if (uniqueColorCount <= MAX_COLORS) { + const palette = new Float32Array(colorPaletteArray) + const paletteSizeKB = (palette.length * 4 / 1024).toFixed(1) + console.log(`[Color Optimization] Enabled: ${submeshColorCount} submeshes → ${uniqueColorCount} unique colors, palette size: ${paletteSizeKB} KB`) + + return { palette, submeshToColorIndex, uniqueColorCount } + } else { + console.warn(`[Color Optimization] Disabled: Model has ${uniqueColorCount} unique colors (max ${MAX_COLORS}). Using vertex colors.`) + return { palette: undefined, submeshToColorIndex, uniqueColorCount } + } +} + +/** + * Quantizes colors in-place using uniform quantization. + * Modifies the input array directly to avoid allocations. + * + * @param colors - Float32Array of RGB colors to quantize in-place + * @param levels - Number of quantization levels per channel (e.g., 25 = 15,625 max colors) + */ +function quantizeColors(colors: Float32Array, levels: number): void { + const quantize = (value: number) => Math.round(value * levels) / levels + + for (let i = 0; i < colors.length; i++) { + colors[i] = quantize(colors[i]) + } +} diff --git a/src/vim-web/core-viewers/webgl/loader/materials/materials.ts b/src/vim-web/core-viewers/webgl/loader/materials/materials.ts index bbc154e50..aacfad48b 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/materials.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/materials.ts @@ -121,6 +121,9 @@ export class Materials { private _focusColor: THREE.Color = new THREE.Color(0xffffff) private _onUpdate = new SignalDispatcher() + // Shared color palette texture for both opaque and transparent materials + private _submeshColorTexture: THREE.DataTexture | undefined + constructor ( opaque?: StandardMaterial, transparent?: StandardMaterial, @@ -427,8 +430,58 @@ export class Materials { this._onUpdate.dispatch() } + /** + * Sets the submesh color palette for both opaque and transparent materials. + * Creates a single shared DataTexture from the palette (128×128 RGBA, 16384 colors max). + * Pass undefined to disable palette optimization. + */ + setColorPalette (palette: Float32Array | undefined) { + // Dispose old texture if exists + if (this._submeshColorTexture) { + this._submeshColorTexture.dispose() + this._submeshColorTexture = undefined + } + + // Create shared texture from palette + if (palette && palette.length > 0) { + const textureSize = 128 + const textureData = new Uint8Array(textureSize * textureSize * 4) + + // Convert float colors (0-1) to uint8 (0-255) with alpha = 255 + const colorCount = Math.min(palette.length / 3, textureSize * textureSize) + for (let i = 0; i < colorCount; i++) { + textureData[i * 4] = Math.round(palette[i * 3] * 255) + textureData[i * 4 + 1] = Math.round(palette[i * 3 + 1] * 255) + textureData[i * 4 + 2] = Math.round(palette[i * 3 + 2] * 255) + textureData[i * 4 + 3] = 255 // Alpha + } + + this._submeshColorTexture = new THREE.DataTexture( + textureData, + textureSize, + textureSize, + THREE.RGBAFormat, + THREE.UnsignedByteType + ) + this._submeshColorTexture.needsUpdate = true + this._submeshColorTexture.minFilter = THREE.NearestFilter + this._submeshColorTexture.magFilter = THREE.NearestFilter + } + + // Set the same texture on both materials + this.opaque.setSubmeshColorTexture(this._submeshColorTexture) + this.transparent.setSubmeshColorTexture(this._submeshColorTexture) + + this._onUpdate.dispatch() + } + /** dispose all materials. */ dispose () { + if (this._submeshColorTexture) { + this._submeshColorTexture.dispose() + this._submeshColorTexture = undefined + } + this.opaque.dispose() this.transparent.dispose() this.wireframe.dispose() diff --git a/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts index 59ee5f319..6aaf195d8 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts @@ -37,6 +37,7 @@ export function createBasicOpaque () { export function createBasicTransparent () { const mat = createBasicOpaque() mat.transparent = true + mat.opacity = 0.25 return mat } @@ -55,8 +56,8 @@ export class StandardMaterial { _sectionStrokeFallof: number = 0.75 _sectionStrokeColor: THREE.Color = new THREE.Color(0xf6f6f6) - // NEW: Submesh color palette for indexed color lookup - _submeshColors: Float32Array | undefined + // NEW: Submesh color palette texture (shared, owned by Materials singleton) + _submeshColorTexture: THREE.DataTexture | undefined _useSubmeshColors: boolean = false constructor (material: THREE.Material) { @@ -65,16 +66,17 @@ export class StandardMaterial { } /** - * Sets the submesh color palette for indexed color lookup. - * Each color is RGB (3 floats). Pass undefined to disable. + * Sets the submesh color texture for indexed color lookup. + * The texture is shared between opaque and transparent materials (created in Materials singleton). + * Pass undefined to disable palette optimization. */ - setSubmeshColors(colors: Float32Array | undefined) { - this._submeshColors = colors - this._useSubmeshColors = colors !== undefined && colors.length > 0 + setSubmeshColorTexture(texture: THREE.DataTexture | undefined) { + // Don't dispose - texture is owned by Materials singleton + this._submeshColorTexture = texture + this._useSubmeshColors = texture !== undefined + if (this.uniforms) { - // Always provide 1024-element array to match shader uniform declaration - // WebGL requires array size to match uniform declaration, even when disabled - this.uniforms.submeshColors.value = colors ?? new Float32Array(1024) + this.uniforms.submeshColorTexture.value = texture ?? null this.uniforms.useSubmeshColors.value = this._useSubmeshColors ? 1.0 : 0.0 } } @@ -156,6 +158,7 @@ export class StandardMaterial { } dispose () { + // Don't dispose texture - it's owned by Materials singleton this.material.dispose() } @@ -173,8 +176,8 @@ export class StandardMaterial { this.uniforms.sectionStrokeWidth = { value: this._sectionStrokeWitdh } this.uniforms.sectionStrokeFalloff = { value: this._sectionStrokeFallof } this.uniforms.sectionStrokeColor = { value: this._sectionStrokeColor } - // NEW: Submesh color palette for indexed lookup (must be 1024 elements to match shader uniform) - this.uniforms.submeshColors = { value: this._submeshColors ?? new Float32Array(1024) } + // NEW: Submesh color palette texture (64×64 RGB = 4096 colors max) + this.uniforms.submeshColorTexture = { value: this._submeshColorTexture ?? null } this.uniforms.useSubmeshColors = { value: this._useSubmeshColors ? 1.0 : 0.0 } shader.vertexShader = shader.vertexShader @@ -200,8 +203,8 @@ export class StandardMaterial { // NEW: Submesh index for color palette lookup attribute float submeshIndex; - uniform float submeshColors[1024]; // 341 colors × RGB (1024 floats, WebGL minimum) - uniform float useSubmeshColors; // 1.0 = use palette, 0.0 = use vertex color + uniform sampler2D submeshColorTexture; // 128×128 RGBA texture (16384 colors max) + uniform float useSubmeshColors; // 1.0 = use palette, 0.0 = use vertex color // Passed to fragment to ignore phong model varying float vColored; @@ -227,13 +230,19 @@ export class StandardMaterial { '#include ', ` // COLORING - vColor = color; vColored = colored; - // NEW: Override with submesh color palette if enabled + // NEW: Get color from texture palette if (useSubmeshColors > 0.5) { - int idx = int(submeshIndex) * 3; - vColor.xyz = vec3(submeshColors[idx], submeshColors[idx + 1], submeshColors[idx + 2]); + // Convert color index to texture UV (128×128 texture) + float texSize = 128.0; + float x = mod(submeshIndex, texSize); + float y = floor(submeshIndex / texSize); + vec2 uv = (vec2(x, y) + 0.5) / texSize; // +0.5 for pixel center sampling + vColor.xyz = texture2D(submeshColorTexture, uv).rgb; + } else { + // Fallback to vertex color attribute if palette disabled + vColor = color; } // colored == 1 -> instance color diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index d69370b40..263d0dd14 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -8,8 +8,7 @@ * Buffer layout (all pre-allocated via G3dMeshOffsets): * - index: Uint32 — triangle indices * - position: Float32x3 — world-space vertices (transforms baked in) - * - color: Float32x3 (opaque) or Float32x4 (transparent) — per-vertex color (legacy, kept for compatibility) - * - submeshIndex: Uint16 — per-vertex submesh index for color palette lookup (NEW) + * - submeshIndex: Uint16 — per-vertex color index for 128×128 texture palette lookup * - packedId: Uint32 — per-vertex (vimIndex << 24 | elementIndex) for GPU picking * * Geometry is inserted incrementally via insertFromG3d(), which iterates over @@ -51,7 +50,6 @@ export class InsertableGeometry { private _computeBoundingBox = false private _indexAttribute: THREE.Uint32BufferAttribute private _vertexAttribute: THREE.BufferAttribute - private _colorAttribute: THREE.BufferAttribute private _submeshIndexAttribute: THREE.Uint16BufferAttribute // NEW: submesh index for color lookup private _packedIdAttribute: THREE.Uint32BufferAttribute private _mapping: ElementMapping | undefined @@ -83,11 +81,7 @@ export class InsertableGeometry { G3d.POSITION_SIZE ) - const colorSize = transparent ? 4 : 3 - this._colorAttribute = new THREE.Float32BufferAttribute( - offsets.counts.vertices * colorSize, - colorSize - ) + // No color attribute - all colors from texture lookup via submeshIndex // NEW: Submesh index for color palette lookup (uint16 supports 65k submeshes) this._submeshIndexAttribute = new THREE.Uint16BufferAttribute( @@ -104,7 +98,6 @@ export class InsertableGeometry { this.geometry = new THREE.BufferGeometry() this.geometry.setIndex(this._indexAttribute) this.geometry.setAttribute('position', this._vertexAttribute) - this.geometry.setAttribute('color', this._colorAttribute) this.geometry.setAttribute('submeshIndex', this._submeshIndexAttribute) // NEW this.geometry.setAttribute('packedId', this._packedIdAttribute) @@ -163,20 +156,16 @@ export class InsertableGeometry { const mergeOffset = instance * vertexCount for (let sub = subStart; sub < subEnd; sub++) { - const color = g3d.getSubmeshColor(sub) - const indexStart = g3d.getSubmeshIndexStart(sub) const indexEnd = g3d.getSubmeshIndexEnd(sub) // Merge all indices for this instance - // Color referenced indices according to current submesh for (let index = indexStart; index < indexEnd; index++) { const v = vertexOffset + mergeOffset + g3d.indices[index] this.setIndex(indexOffset + indexOut, v) - this.setColor(v, color, 0.25) // Keep for now (backwards compatibility) - // NEW: Use color index if palette optimization is enabled, otherwise submesh index - const colorIndex = (g3d as any).submeshToColorIndex?.[sub] ?? sub + // Use color index if palette optimization is enabled, otherwise submesh index + const colorIndex = g3d.submeshToColorIndex?.[sub] ?? sub this.setSubmeshIndex(v, colorIndex) indexOut++ } @@ -210,13 +199,6 @@ export class InsertableGeometry { this._vertexAttribute.setXYZ(index, vector.x, vector.y, vector.z) } - private setColor (index: number, color: Float32Array, alpha: number) { - this._colorAttribute.setXYZ(index, color[0], color[1], color[2]) - if (this._colorAttribute.itemSize === 4) { - this._colorAttribute.setW(index, alpha) - } - } - private setSubmeshIndex (index: number, submeshIndex: number) { this._submeshIndexAttribute.setX(index, submeshIndex) } @@ -268,11 +250,8 @@ export class InsertableGeometry { // this._vertexAttribute.count = vertexEnd this._vertexAttribute.needsUpdate = true - // update colors - const cSize = this._colorAttribute.itemSize - this._colorAttribute.addUpdateRange(vertexStart * cSize, (vertexEnd - vertexStart) * cSize) - // this._colorAttribute.count = vertexEnd - this._colorAttribute.needsUpdate = true + // Colors are initialized to white once, no need to update + // Actual colors come from texture lookup via submeshIndex // update submesh indices (itemSize is 1) this._submeshIndexAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 710e8c38c..33f13ef6a 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -57,8 +57,7 @@ export class InstancedMeshFactory { const geometry = Geometry.createGeometryFromMesh( g3d, mesh, - section, - transparent + section ) const material = transparent ? Materials.getInstance().transparent diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts index c8124a4b9..4fd23b322 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -22,6 +22,7 @@ import { } from 'vim-format' import { DefaultLog } from 'vim-format/dist/logging' import { createMappedG3d } from './mappedG3d' +import { Materials } from '../materials/materials' export type RequestSource = { url?: string, @@ -82,47 +83,10 @@ export class LoadRequest extends BaseLoadRequest { const geometry = await bfast.getBfast('geometry') const g3d = await G3d.createFromBfast(geometry) - // Step 2: Augment with pre-computed mesh→instances map (shared by all G3dSubsets) + // Step 2: Augment with pre-computed mesh→instances map and color palette (shared by all G3dSubsets) const mappedG3d = createMappedG3d(g3d) const materials = new G3dMaterial(mappedG3d.materialColors) - // NEW: Build unique color palette for shader lookup - const submeshColorCount = mappedG3d.submeshMaterial.length - const maxColors = 341 // Must match shader uniform array size (1024 floats / 3) - - // Build palette of unique colors only - const uniqueColorsMap = new Map() // color key → colorIndex - const colorPaletteArray: number[] = [] - const submeshToColorIndex = new Uint16Array(submeshColorCount) - - for (let i = 0; i < submeshColorCount; i++) { - const color = mappedG3d.getSubmeshColor(i) - const key = `${color[0].toFixed(6)},${color[1].toFixed(6)},${color[2].toFixed(6)}` - - let colorIndex = uniqueColorsMap.get(key) - if (colorIndex === undefined) { - colorIndex = colorPaletteArray.length / 3 - uniqueColorsMap.set(key, colorIndex) - colorPaletteArray.push(color[0], color[1], color[2]) - } - - submeshToColorIndex[i] = colorIndex - } - - const uniqueColorCount = uniqueColorsMap.size - let submeshColorPalette: Float32Array | undefined - - if (uniqueColorCount <= maxColors) { - submeshColorPalette = new Float32Array(colorPaletteArray) - const paletteSizeKB = (submeshColorPalette.length * 4 / 1024).toFixed(1) - console.log(`[Color Optimization] Enabled: ${submeshColorCount} submeshes → ${uniqueColorCount} unique colors, palette size: ${paletteSizeKB} KB`) - - // Store the mapping in mappedG3d for geometry builders to use - ;(mappedG3d as any).submeshToColorIndex = submeshToColorIndex - } else { - console.warn(`[Color Optimization] Disabled: Model has ${uniqueColorCount} unique colors (max ${maxColors}). Using vertex colors.`) - } - // Step 3-4: Parse BIM document and build instance → element mapping const doc = await VimDocument.createFromBfast(bfast) const mapping = await ElementMapping.fromG3d(mappedG3d, doc) @@ -131,12 +95,8 @@ export class LoadRequest extends BaseLoadRequest { const scene = new Scene(fullSettings.matrix) const factory = new VimMeshFactory(mappedG3d, materials, scene, mapping, vimIndex) - // NEW: Set or clear submesh color palette on materials - // Always call this to ensure proper state (enabled or disabled) - const { Materials } = await import('../materials/materials') - const sharedMaterials = Materials.getInstance() - sharedMaterials.opaque.setSubmeshColors(submeshColorPalette) // undefined if disabled - sharedMaterials.transparent.setSubmeshColors(submeshColorPalette) + // Step 5.5: Set submesh color palette (shared texture for both opaque and transparent) + Materials.getInstance().setColorPalette(mappedG3d.colorPalette) const header = await requestHeader(bfast) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts index ddad51d27..007d7998b 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts @@ -3,23 +3,32 @@ */ import { G3d } from 'vim-format' +import { buildColorPalette } from '../materials/colorPalette' /** - * G3d augmented with a pre-computed mesh→instances map. + * G3d augmented with a pre-computed mesh→instances map and color palette optimization. * The map is computed once during loading and shared by all G3dSubsets, * eliminating O(N) iterations on every subset construction. + * + * Color palette: Unique colors extracted from all submeshes, enabling texture-based + * color lookup instead of per-vertex color attributes (saves 60-80% geometry memory). */ export interface MappedG3d extends G3d { _meshInstances: Map + + // Color palette optimization (undefined if disabled due to too many unique colors) + colorPalette: Float32Array | undefined + submeshToColorIndex: Uint16Array + uniqueColorCount: number } /** - * Augments a G3d instance with the pre-computed mesh→instances map. + * Augments a G3d instance with pre-computed mesh→instances map and color palette. * This should be called once during the loading pipeline, right after * G3d.createFromBfast(). * * @param g3d The G3d instance to augment - * @returns The same G3d instance, now typed as MappedG3d + * @returns The same G3d instance, now typed as MappedG3d with color optimization */ export function createMappedG3d(g3d: G3d): MappedG3d { const mapped = g3d as MappedG3d @@ -37,5 +46,13 @@ export function createMappedG3d(g3d: G3d): MappedG3d { } mapped._meshInstances = map + // Build color palette optimization + const submeshColorCount = mapped.submeshMaterial.length + const { palette, submeshToColorIndex, uniqueColorCount } = buildColorPalette(mapped, submeshColorCount) + + mapped.colorPalette = palette + mapped.submeshToColorIndex = submeshToColorIndex + mapped.uniqueColorCount = uniqueColorCount + return mapped } From 6fdddb38b0b072feb7bafe3cba9b16ad45e14d9d Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 20:46:50 -0500 Subject: [PATCH 84/94] faster eelement mapping --- .../webgl/loader/elementMapping.ts | 67 ++++++++++--------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts index a85082cd7..8cebe8868 100644 --- a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts +++ b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts @@ -32,27 +32,28 @@ export class ElementNoMapping { } export class ElementMapping { - private _instanceToElement: Map + private _instanceToElement: number[] | Int32Array private _instanceMeshes: Int32Array - private _elementToInstances: Map + private _elementToInstances: (number[] | undefined)[] private _elementIds: BigInt64Array private _elementIdToElements: Map constructor ( - instances: number[], - instanceToElement: number[], + instanceToElement: number[] | Int32Array, elementIds: BigInt64Array, instanceMeshes?: Int32Array ) { - this._instanceToElement = new Map() - instances.forEach((i) => - this._instanceToElement.set(i, instanceToElement[i]) - ) - this._elementToInstances = ElementMapping.invertMap( - this._instanceToElement + // Direct reference - no copy needed (read-only) + this._instanceToElement = instanceToElement + + // Build element→instances array (inverted mapping) + this._elementToInstances = ElementMapping.invertToArray( + instanceToElement, + elementIds.length ) + this._elementIds = elementIds - this._elementIdToElements = ElementMapping.invertArray(elementIds) + this._elementIdToElements = ElementMapping.invertToMap(elementIds) this._instanceMeshes = instanceMeshes } @@ -61,8 +62,7 @@ export class ElementMapping { const elementIds = await bim.element.getAllId() return new ElementMapping( - Array.from(g3d.instanceNodes), - instanceToElement, + instanceToElement, // No conversion - use directly to avoid memory duplication elementIds, g3d.instanceMeshes ) @@ -85,7 +85,8 @@ export class ElementMapping { hasMesh (element: number) { if (!this._instanceMeshes) return true - const instances = this._elementToInstances.get(element) + const instances = this._elementToInstances[element] + if (!instances) return false for (const i of instances) { if (this._instanceMeshes[i] >= 0) { return true @@ -107,7 +108,7 @@ export class ElementMapping { */ getInstancesFromElement (element: number): number[] | undefined { if (!this.hasElement(element)) return - return this._elementToInstances.get(element) ?? [] + return this._elementToInstances[element] ?? [] } /** @@ -116,7 +117,7 @@ export class ElementMapping { * @returns element index or undefined if not found */ getElementFromInstance (instance: number) { - return this._instanceToElement.get(instance) + return this._instanceToElement[instance] } /** @@ -129,17 +130,20 @@ export class ElementMapping { } /** - * Returns a map where data[i] -> i + * Builds element→instances array by inverting the instance→element mapping */ - private static invertArray (data: BigInt64Array) { - const result = new Map() - for (let i = 0; i < data.length; i++) { - const value = data[i] - const list = result.get(value) - if (list) { - list.push(i) - } else { - result.set(value, [i]) + private static invertToArray ( + instanceToElement: number[] | Int32Array, + elementCount: number + ): (number[] | undefined)[] { + const result: (number[] | undefined)[] = new Array(elementCount) + for (let instance = 0; instance < instanceToElement.length; instance++) { + const element = instanceToElement[instance] + if (element >= 0) { + if (!result[element]) { + result[element] = [] + } + result[element]!.push(instance) } } return result @@ -148,14 +152,15 @@ export class ElementMapping { /** * Returns a map where data[i] -> i */ - private static invertMap (data: Map) { - const result = new Map() - for (const [key, value] of data.entries()) { + private static invertToMap (data: BigInt64Array) { + const result = new Map() + for (let i = 0; i < data.length; i++) { + const value = data[i] const list = result.get(value) if (list) { - list.push(key) + list.push(i) } else { - result.set(value, [key]) + result.set(value, [i]) } } return result From 67ca0e97c6dcfba833f86bb1cb1dddf813f171f3 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 21:10:20 -0500 Subject: [PATCH 85/94] fasteer geomry --- .../loader/progressive/insertableGeometry.ts | 104 ++++++++++++++---- 1 file changed, 80 insertions(+), 24 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 263d0dd14..3a27d2737 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -108,6 +108,15 @@ export class InsertableGeometry { return this._indexAttribute.count / this._indexAttribute.array.length } + // Cumulative timing (shared across all insertFromVim calls) + private static _cumulativeTiming = { + setup: 0, + indexLoop: 0, + vertexLoop: 0, + cleanup: 0, + calls: 0 + } + /** * Inserts geometry for a single mesh definition, duplicated for each instance. * For each instance: bakes the instance matrix into vertex positions, copies indices @@ -115,6 +124,8 @@ export class InsertableGeometry { * and creates a GeometrySubmesh tracking the index range and bounding box. */ insertFromG3d (g3d: MappedG3d, mesh: number) { + const t0 = performance.now() + const added: number[] = [] const meshG3dIndex = this.offsets.subset.getSourceMesh(mesh) const subStart = g3d.getMeshSubmeshStart(meshG3dIndex, this.offsets.section) @@ -139,6 +150,9 @@ export class InsertableGeometry { const vertexEnd = g3d.getMeshVertexEnd(meshG3dIndex) const vertexCount = vertexEnd - vertexStart + const t1 = performance.now() + InsertableGeometry._cumulativeTiming.setup += t1 - t0 + let indexOut = 0 let vertexOut = 0 // Iterate over all included instances for this mesh. @@ -154,64 +168,106 @@ export class InsertableGeometry { submesh.instance = g3d.instanceNodes[g3dInstance] submesh.start = indexOffset + indexOut + const t2 = performance.now() + + // Direct array access for performance (avoid function call overhead) + const indices = this._indexAttribute.array as Uint32Array + const submeshIndices = this._submeshIndexAttribute.array as Uint16Array + const mergeOffset = instance * vertexCount for (let sub = subStart; sub < subEnd; sub++) { const indexStart = g3d.getSubmeshIndexStart(sub) const indexEnd = g3d.getSubmeshIndexEnd(sub) + // Use color index if palette optimization is enabled, otherwise submesh index + // Hoist out of inner loop - computed once per submesh instead of per index + const colorIndex = g3d.submeshToColorIndex?.[sub] ?? sub + // Merge all indices for this instance for (let index = indexStart; index < indexEnd; index++) { const v = vertexOffset + mergeOffset + g3d.indices[index] - this.setIndex(indexOffset + indexOut, v) - // Use color index if palette optimization is enabled, otherwise submesh index - const colorIndex = g3d.submeshToColorIndex?.[sub] ?? sub - this.setSubmeshIndex(v, colorIndex) + // Direct array writes (no function calls, no bounds checking) + indices[indexOffset + indexOut] = v + submeshIndices[v] = colorIndex indexOut++ } } + const t3 = performance.now() + InsertableGeometry._cumulativeTiming.indexLoop += t3 - t2 + + // Direct array access for performance (avoid function call overhead) + const positions = this._vertexAttribute.array as Float32Array + const packedIds = this._packedIdAttribute.array as Uint32Array + const packedId = packPickingId(this._vimIndex, elementIndex) + + // Matrix elements for inline transform + const e = matrix.elements // Transform and merge vertices for (let vertex = vertexStart; vertex < vertexEnd; vertex++) { - vector.fromArray(g3d.positions, vertex * G3d.POSITION_SIZE) - vector.applyMatrix4(matrix) - this.setVertex(vertexOffset + vertexOut, vector) - this.setPackedId(vertexOffset + vertexOut, elementIndex) + const srcIdx = vertex * G3d.POSITION_SIZE + const x = g3d.positions[srcIdx] + const y = g3d.positions[srcIdx + 1] + const z = g3d.positions[srcIdx + 2] + + // Inline matrix transform (applyMatrix4 logic) + const tx = e[0] * x + e[4] * y + e[8] * z + e[12] + const ty = e[1] * x + e[5] * y + e[9] * z + e[13] + const tz = e[2] * x + e[6] * y + e[10] * z + e[14] + + // Direct array writes + const dstIdx = (vertexOffset + vertexOut) * 3 + positions[dstIdx] = tx + positions[dstIdx + 1] = ty + positions[dstIdx + 2] = tz + + packedIds[vertexOffset + vertexOut] = packedId + + // Expand bounding box + vector.set(tx, ty, tz) submesh.expandBox(vector) vertexOut++ } + const t4 = performance.now() + InsertableGeometry._cumulativeTiming.vertexLoop += t4 - t3 submesh.end = indexOffset + indexOut this.expandBox(submesh.boundingBox) this.submeshes.push(submesh) added.push(this.submeshes.length - 1) + + const t5 = performance.now() + InsertableGeometry._cumulativeTiming.cleanup += t5 - t4 } + InsertableGeometry._cumulativeTiming.calls++ + this._meshToUpdate.add(mesh) return added } - private setIndex (index: number, value: number) { - this._indexAttribute.setX(index, value) - } - - private setVertex (index: number, vector: THREE.Vector3) { - this._vertexAttribute.setXYZ(index, vector.x, vector.y, vector.z) - } - - private setSubmeshIndex (index: number, submeshIndex: number) { - this._submeshIndexAttribute.setX(index, submeshIndex) - } - - private setPackedId (index: number, elementIndex: number) { - this._packedIdAttribute.setX(index, packPickingId(this._vimIndex, elementIndex)) - } - private expandBox (box: THREE.Box3) { if (!box) return this.boundingBox = this.boundingBox?.union(box) ?? box.clone() } + static logAndResetTiming (label: string) { + const t = InsertableGeometry._cumulativeTiming + console.log(`[InsertableGeometry] ${label} breakdown (${t.calls} insertFromG3d calls):`) + console.log(` Setup: ${t.setup.toFixed(2)}ms`) + console.log(` Index loop: ${t.indexLoop.toFixed(2)}ms`) + console.log(` Vertex loop: ${t.vertexLoop.toFixed(2)}ms`) + console.log(` Cleanup: ${t.cleanup.toFixed(2)}ms`) + console.log(` Total: ${(t.setup + t.indexLoop + t.vertexLoop + t.cleanup).toFixed(2)}ms`) + // Reset for next batch + t.setup = 0 + t.indexLoop = 0 + t.vertexLoop = 0 + t.cleanup = 0 + t.calls = 0 + } + flushUpdate () { // Makes sure that the update range has reached the renderer. this._updateStartMesh = this._updateEndMesh From 9ed766dd55003308861302728e5e2b0d805bb6d4 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 21:54:50 -0500 Subject: [PATCH 86/94] fastr bb --- .../loader/progressive/insertableGeometry.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 3a27d2737..ce1c2786f 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -137,9 +137,8 @@ export class InsertableGeometry { return added } - // Reusable matrix and vector3 to avoid allocations + // Reusable matrix to avoid allocations const matrix = new THREE.Matrix4() - const vector = new THREE.Vector3() // Offsets for this mesh and all its instances const indexOffset = this.offsets.getIndexOffset(mesh) @@ -204,6 +203,26 @@ export class InsertableGeometry { // Matrix elements for inline transform const e = matrix.elements + // Initialize bounding box with first vertex (avoids isEmpty() check in loop) + if (vertexCount > 0) { + const firstIdx = vertexStart * G3d.POSITION_SIZE + const firstX = g3d.positions[firstIdx] + const firstY = g3d.positions[firstIdx + 1] + const firstZ = g3d.positions[firstIdx + 2] + const firstTx = e[0] * firstX + e[4] * firstY + e[8] * firstZ + e[12] + const firstTy = e[1] * firstX + e[5] * firstY + e[9] * firstZ + e[13] + const firstTz = e[2] * firstX + e[6] * firstY + e[10] * firstZ + e[14] + + submesh.boundingBox.set( + new THREE.Vector3(firstTx, firstTy, firstTz), + new THREE.Vector3(firstTx, firstTy, firstTz) + ) + } + + // Direct access to bounding box for inline expansion (avoids method calls) + const boxMin = submesh.boundingBox.min + const boxMax = submesh.boundingBox.max + // Transform and merge vertices for (let vertex = vertexStart; vertex < vertexEnd; vertex++) { const srcIdx = vertex * G3d.POSITION_SIZE @@ -224,9 +243,14 @@ export class InsertableGeometry { packedIds[vertexOffset + vertexOut] = packedId - // Expand bounding box - vector.set(tx, ty, tz) - submesh.expandBox(vector) + // Inline bounding box expansion (no method calls, no isEmpty check) + boxMin.x = Math.min(boxMin.x, tx) + boxMin.y = Math.min(boxMin.y, ty) + boxMin.z = Math.min(boxMin.z, tz) + boxMax.x = Math.max(boxMax.x, tx) + boxMax.y = Math.max(boxMax.y, ty) + boxMax.z = Math.max(boxMax.z, tz) + vertexOut++ } const t4 = performance.now() From f601bea1fe570d5f293df9504d24ff86fd7a0f86 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 22:34:30 -0500 Subject: [PATCH 87/94] bb inverted --- .../loader/progressive/insertableGeometry.ts | 78 ++++++++++--------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index ce1c2786f..321fcde84 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -101,6 +101,10 @@ export class InsertableGeometry { this.geometry.setAttribute('submeshIndex', this._submeshIndexAttribute) // NEW this.geometry.setAttribute('packedId', this._packedIdAttribute) + // Initialize with inverted bounds (min = +∞, max = -∞) so any point naturally expands it + this.boundingBox = new THREE.Box3() + this.boundingBox.makeEmpty() + this._computeBoundingBox = true } @@ -110,10 +114,11 @@ export class InsertableGeometry { // Cumulative timing (shared across all insertFromVim calls) private static _cumulativeTiming = { - setup: 0, + meshSetup: 0, // Initial mesh offset/range lookups + instanceSetup: 0, // Per-instance matrix + mapping lookup indexLoop: 0, vertexLoop: 0, - cleanup: 0, + instanceCleanup: 0, // Per-instance expandBox + array push calls: 0 } @@ -150,24 +155,36 @@ export class InsertableGeometry { const vertexCount = vertexEnd - vertexStart const t1 = performance.now() - InsertableGeometry._cumulativeTiming.setup += t1 - t0 + InsertableGeometry._cumulativeTiming.meshSetup += t1 - t0 + + // Cache array references for performance (avoid method call overhead) + const instanceTransforms = g3d.instanceTransforms + const instanceNodes = g3d.instanceNodes let indexOut = 0 let vertexOut = 0 // Iterate over all included instances for this mesh. const instanceCount = this.offsets.subset.getMeshInstanceCount(mesh) for (let instance = 0; instance < instanceCount; instance++) { + const tInstanceStart = performance.now() + const g3dInstance = this.offsets.subset.getMeshInstance(mesh, instance) - matrix.fromArray(g3d.getInstanceMatrix(g3dInstance)) + + // Direct array access for matrix (avoid getInstanceMatrix() call) + const matrixOffset = g3dInstance * 16 + for (let i = 0; i < 16; i++) { + matrix.elements[i] = instanceTransforms[matrixOffset + i] + } // Get element index for this instance (for GPU picking) const elementIndex = this._mapping?.getElementFromInstance(g3dInstance) ?? -1 const submesh = new GeometrySubmesh() - submesh.instance = g3d.instanceNodes[g3dInstance] + submesh.instance = instanceNodes[g3dInstance] submesh.start = indexOffset + indexOut const t2 = performance.now() + InsertableGeometry._cumulativeTiming.instanceSetup += t2 - tInstanceStart // Direct array access for performance (avoid function call overhead) const indices = this._indexAttribute.array as Uint32Array @@ -203,23 +220,9 @@ export class InsertableGeometry { // Matrix elements for inline transform const e = matrix.elements - // Initialize bounding box with first vertex (avoids isEmpty() check in loop) - if (vertexCount > 0) { - const firstIdx = vertexStart * G3d.POSITION_SIZE - const firstX = g3d.positions[firstIdx] - const firstY = g3d.positions[firstIdx + 1] - const firstZ = g3d.positions[firstIdx + 2] - const firstTx = e[0] * firstX + e[4] * firstY + e[8] * firstZ + e[12] - const firstTy = e[1] * firstX + e[5] * firstY + e[9] * firstZ + e[13] - const firstTz = e[2] * firstX + e[6] * firstY + e[10] * firstZ + e[14] - - submesh.boundingBox.set( - new THREE.Vector3(firstTx, firstTy, firstTz), - new THREE.Vector3(firstTx, firstTy, firstTz) - ) - } - - // Direct access to bounding box for inline expansion (avoids method calls) + // Initialize submesh bounding box with inverted bounds (min = +∞, max = -∞) + // Any vertex will naturally expand it via Math.min/max - no special first-vertex handling needed + submesh.boundingBox.makeEmpty() const boxMin = submesh.boundingBox.min const boxMax = submesh.boundingBox.max @@ -257,12 +260,14 @@ export class InsertableGeometry { InsertableGeometry._cumulativeTiming.vertexLoop += t4 - t3 submesh.end = indexOffset + indexOut + this.expandBox(submesh.boundingBox) + this.submeshes.push(submesh) added.push(this.submeshes.length - 1) const t5 = performance.now() - InsertableGeometry._cumulativeTiming.cleanup += t5 - t4 + InsertableGeometry._cumulativeTiming.instanceCleanup += t5 - t4 } InsertableGeometry._cumulativeTiming.calls++ @@ -271,27 +276,30 @@ export class InsertableGeometry { return added } - private expandBox (box: THREE.Box3) { - if (!box) return - this.boundingBox = this.boundingBox?.union(box) ?? box.clone() - } - static logAndResetTiming (label: string) { const t = InsertableGeometry._cumulativeTiming console.log(`[InsertableGeometry] ${label} breakdown (${t.calls} insertFromG3d calls):`) - console.log(` Setup: ${t.setup.toFixed(2)}ms`) - console.log(` Index loop: ${t.indexLoop.toFixed(2)}ms`) - console.log(` Vertex loop: ${t.vertexLoop.toFixed(2)}ms`) - console.log(` Cleanup: ${t.cleanup.toFixed(2)}ms`) - console.log(` Total: ${(t.setup + t.indexLoop + t.vertexLoop + t.cleanup).toFixed(2)}ms`) + console.log(` Mesh setup: ${t.meshSetup.toFixed(2)}ms`) + console.log(` Instance setup: ${t.instanceSetup.toFixed(2)}ms`) + console.log(` Index loop: ${t.indexLoop.toFixed(2)}ms`) + console.log(` Vertex loop: ${t.vertexLoop.toFixed(2)}ms`) + console.log(` Instance cleanup: ${t.instanceCleanup.toFixed(2)}ms`) + console.log(` Total: ${(t.meshSetup + t.instanceSetup + t.indexLoop + t.vertexLoop + t.instanceCleanup).toFixed(2)}ms`) // Reset for next batch - t.setup = 0 + t.meshSetup = 0 + t.instanceSetup = 0 t.indexLoop = 0 t.vertexLoop = 0 - t.cleanup = 0 + t.instanceCleanup = 0 t.calls = 0 } + private expandBox (box: THREE.Box3) { + // Direct min/max expansion (no null checks needed - boundingBox initialized with inverted bounds) + this.boundingBox.min.min(box.min) + this.boundingBox.max.max(box.max) + } + flushUpdate () { // Makes sure that the update range has reached the renderer. this._updateStartMesh = this._updateEndMesh From 1f53b9347897c20a809410d9aef88122733a11c7 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Thu, 12 Feb 2026 23:07:03 -0500 Subject: [PATCH 88/94] optims --- .../core-viewers/webgl/loader/geometry.ts | 25 +++--- .../webgl/loader/materials/colorPalette.ts | 21 ++--- .../loader/progressive/insertableGeometry.ts | 86 ++++--------------- .../loader/progressive/insertableMesh.ts | 1 + .../progressive/instancedMeshFactory.ts | 16 ++-- .../webgl/loader/progressive/loadRequest.ts | 7 -- .../webgl/loader/progressive/mappedG3d.ts | 8 +- .../loader/progressive/vimMeshFactory.ts | 19 ++-- .../core-viewers/webgl/loader/scene.ts | 7 +- src/vim-web/core-viewers/webgl/loader/vim.ts | 10 +-- 10 files changed, 57 insertions(+), 143 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/geometry.ts b/src/vim-web/core-viewers/webgl/loader/geometry.ts index e4ee381a0..e8562bb1a 100644 --- a/src/vim-web/core-viewers/webgl/loader/geometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/geometry.ts @@ -32,8 +32,8 @@ export function createGeometryFromMesh ( mesh: number, section: MeshSection ): THREE.BufferGeometry { - // Colors now come from texture lookup, no need to compute vertex colors - const submeshIndices = createSubmeshIndices(g3d, mesh, section) + // Colors come from texture lookup via color palette indices + const colorPaletteIndex = createColorPaletteIndices(g3d, mesh, section) const positions = g3d.positions.subarray( g3d.getMeshVertexStart(mesh) * 3, g3d.getMeshVertexEnd(mesh) * 3 @@ -43,18 +43,16 @@ export function createGeometryFromMesh ( const end = g3d.getMeshIndexEnd(mesh, section) const indices = g3d.indices.subarray(start, end) - // No color attribute - all colors come from texture lookup return createGeometryFromArrays( positions, indices, - submeshIndices + colorPaletteIndex ) } /** - * Creates submesh indices for each vertex (for color palette lookup) - * Uses color index mapping if available for memory optimization + * Creates color palette indices for each vertex (for texture-based color lookup) */ -function createSubmeshIndices ( +function createColorPaletteIndices ( g3d: MappedG3d, mesh: number, section: MeshSection @@ -69,8 +67,7 @@ function createSubmeshIndices ( const start = g3d.getSubmeshIndexStart(submesh) const end = g3d.getSubmeshIndexEnd(submesh) - // Use color index if available, otherwise submesh index - const index = g3d.submeshToColorIndex?.[submesh] ?? submesh + const index = g3d.submeshColor[submesh] for (let i = start; i < end; i++) { const vertexIndex = g3d.indices[i] @@ -85,13 +82,13 @@ function createSubmeshIndices ( * Creates a BufferGeometry from given geometry data arrays * @param vertices vertex data with 3 number per vertex (XYZ) * @param indices index data with 3 indices per face - * @param submeshIndices submesh index per vertex for color palette lookup + * @param colorPaletteIndex color palette index per vertex for texture-based color lookup * @returns a BufferGeometry */ export function createGeometryFromArrays ( vertices: Float32Array, indices: Uint32Array, - submeshIndices: Uint16Array | undefined = undefined + colorPaletteIndex: Uint16Array | undefined = undefined ): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() @@ -101,11 +98,11 @@ export function createGeometryFromArrays ( // Indices geometry.setIndex(new THREE.Uint32BufferAttribute(indices, 1)) - // Submesh indices for color palette lookup - if (submeshIndices) { + // Color palette indices for texture-based color lookup + if (colorPaletteIndex) { geometry.setAttribute( 'submeshIndex', - new THREE.Uint16BufferAttribute(submeshIndices, 1) + new THREE.Uint16BufferAttribute(colorPaletteIndex, 1) ) } diff --git a/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts b/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts index b9a8e3613..717eaa0c0 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts @@ -13,7 +13,7 @@ const QUANTIZATION_LEVELS = 25 // 25³ = 15,625 max colors export type ColorPaletteResult = { palette: Float32Array | undefined - submeshToColorIndex: Uint16Array + submeshColor: Uint16Array uniqueColorCount: number } @@ -23,7 +23,7 @@ export type ColorPaletteResult = { * * @param mappedG3d - The mapped G3d geometry with material colors * @param submeshColorCount - Total number of submeshes - * @returns Color palette (undefined if disabled), submesh→colorIndex mapping, and unique color count + * @returns Color palette (undefined if too many colors), submesh→colorIndex mapping, and unique color count */ export function buildColorPalette( mappedG3d: MappedG3d, @@ -32,7 +32,7 @@ export function buildColorPalette( // Build unique color palette for shader lookup const uniqueColorsMap = new Map() // color key → colorIndex const colorPaletteArray: number[] = [] - const submeshToColorIndex = new Uint16Array(submeshColorCount) + const submeshColor = new Uint16Array(submeshColorCount) // First pass: build initial palette for (let i = 0; i < submeshColorCount; i++) { @@ -46,15 +46,13 @@ export function buildColorPalette( colorPaletteArray.push(color[0], color[1], color[2]) } - submeshToColorIndex[i] = colorIndex + submeshColor[i] = colorIndex } let uniqueColorCount = uniqueColorsMap.size // If too many unique colors, quantize them in-place if (uniqueColorCount > MAX_COLORS) { - console.log(`[Color Optimization] Quantizing: ${uniqueColorCount} unique colors → target ${MAX_COLORS}`) - quantizeColors(mappedG3d.materialColors, QUANTIZATION_LEVELS) // Rebuild palette with quantized colors @@ -72,23 +70,18 @@ export function buildColorPalette( colorPaletteArray.push(color[0], color[1], color[2]) } - submeshToColorIndex[i] = colorIndex + submeshColor[i] = colorIndex } uniqueColorCount = uniqueColorsMap.size - console.log(`[Color Optimization] Quantization result: ${uniqueColorCount} unique colors`) } // Return palette if within limits, otherwise undefined (disable optimization) if (uniqueColorCount <= MAX_COLORS) { const palette = new Float32Array(colorPaletteArray) - const paletteSizeKB = (palette.length * 4 / 1024).toFixed(1) - console.log(`[Color Optimization] Enabled: ${submeshColorCount} submeshes → ${uniqueColorCount} unique colors, palette size: ${paletteSizeKB} KB`) - - return { palette, submeshToColorIndex, uniqueColorCount } + return { palette, submeshColor, uniqueColorCount } } else { - console.warn(`[Color Optimization] Disabled: Model has ${uniqueColorCount} unique colors (max ${MAX_COLORS}). Using vertex colors.`) - return { palette: undefined, submeshToColorIndex, uniqueColorCount } + return { palette: undefined, submeshColor, uniqueColorCount } } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 321fcde84..9469c45a4 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -8,7 +8,7 @@ * Buffer layout (all pre-allocated via G3dMeshOffsets): * - index: Uint32 — triangle indices * - position: Float32x3 — world-space vertices (transforms baked in) - * - submeshIndex: Uint16 — per-vertex color index for 128×128 texture palette lookup + * - submeshIndex: Uint16 — per-vertex color palette index for texture-based color lookup * - packedId: Uint32 — per-vertex (vimIndex << 24 | elementIndex) for GPU picking * * Geometry is inserted incrementally via insertFromG3d(), which iterates over @@ -50,7 +50,7 @@ export class InsertableGeometry { private _computeBoundingBox = false private _indexAttribute: THREE.Uint32BufferAttribute private _vertexAttribute: THREE.BufferAttribute - private _submeshIndexAttribute: THREE.Uint16BufferAttribute // NEW: submesh index for color lookup + private _submeshIndexAttribute: THREE.Uint16BufferAttribute // Color palette index for texture-based color lookup private _packedIdAttribute: THREE.Uint32BufferAttribute private _mapping: ElementMapping | undefined private _vimIndex: number @@ -81,9 +81,9 @@ export class InsertableGeometry { G3d.POSITION_SIZE ) - // No color attribute - all colors from texture lookup via submeshIndex + // No color attribute - all colors from texture lookup via color palette index - // NEW: Submesh index for color palette lookup (uint16 supports 65k submeshes) + // Color palette index for texture-based color lookup (uint16 supports 65k unique colors) this._submeshIndexAttribute = new THREE.Uint16BufferAttribute( offsets.counts.vertices, 1 @@ -98,7 +98,7 @@ export class InsertableGeometry { this.geometry = new THREE.BufferGeometry() this.geometry.setIndex(this._indexAttribute) this.geometry.setAttribute('position', this._vertexAttribute) - this.geometry.setAttribute('submeshIndex', this._submeshIndexAttribute) // NEW + this.geometry.setAttribute('submeshIndex', this._submeshIndexAttribute) this.geometry.setAttribute('packedId', this._packedIdAttribute) // Initialize with inverted bounds (min = +∞, max = -∞) so any point naturally expands it @@ -112,16 +112,6 @@ export class InsertableGeometry { return this._indexAttribute.count / this._indexAttribute.array.length } - // Cumulative timing (shared across all insertFromVim calls) - private static _cumulativeTiming = { - meshSetup: 0, // Initial mesh offset/range lookups - instanceSetup: 0, // Per-instance matrix + mapping lookup - indexLoop: 0, - vertexLoop: 0, - instanceCleanup: 0, // Per-instance expandBox + array push - calls: 0 - } - /** * Inserts geometry for a single mesh definition, duplicated for each instance. * For each instance: bakes the instance matrix into vertex positions, copies indices @@ -129,8 +119,6 @@ export class InsertableGeometry { * and creates a GeometrySubmesh tracking the index range and bounding box. */ insertFromG3d (g3d: MappedG3d, mesh: number) { - const t0 = performance.now() - const added: number[] = [] const meshG3dIndex = this.offsets.subset.getSourceMesh(mesh) const subStart = g3d.getMeshSubmeshStart(meshG3dIndex, this.offsets.section) @@ -142,9 +130,6 @@ export class InsertableGeometry { return added } - // Reusable matrix to avoid allocations - const matrix = new THREE.Matrix4() - // Offsets for this mesh and all its instances const indexOffset = this.offsets.getIndexOffset(mesh) const vertexOffset = this.offsets.getVertexOffset(mesh) @@ -154,26 +139,25 @@ export class InsertableGeometry { const vertexEnd = g3d.getMeshVertexEnd(meshG3dIndex) const vertexCount = vertexEnd - vertexStart - const t1 = performance.now() - InsertableGeometry._cumulativeTiming.meshSetup += t1 - t0 - // Cache array references for performance (avoid method call overhead) const instanceTransforms = g3d.instanceTransforms const instanceNodes = g3d.instanceNodes let indexOut = 0 let vertexOut = 0 + // Reusable 16-element array for matrix (better cache locality than direct array access) + const matrixElements = new Float32Array(16) // Iterate over all included instances for this mesh. const instanceCount = this.offsets.subset.getMeshInstanceCount(mesh) for (let instance = 0; instance < instanceCount; instance++) { - const tInstanceStart = performance.now() - const g3dInstance = this.offsets.subset.getMeshInstance(mesh, instance) - // Direct array access for matrix (avoid getInstanceMatrix() call) + // Compute matrix offset for direct indexed access const matrixOffset = g3dInstance * 16 + + // Copy matrix elements to local array (better cache locality in hot vertex loop) for (let i = 0; i < 16; i++) { - matrix.elements[i] = instanceTransforms[matrixOffset + i] + matrixElements[i] = instanceTransforms[matrixOffset + i] } // Get element index for this instance (for GPU picking) @@ -183,9 +167,6 @@ export class InsertableGeometry { submesh.instance = instanceNodes[g3dInstance] submesh.start = indexOffset + indexOut - const t2 = performance.now() - InsertableGeometry._cumulativeTiming.instanceSetup += t2 - tInstanceStart - // Direct array access for performance (avoid function call overhead) const indices = this._indexAttribute.array as Uint32Array const submeshIndices = this._submeshIndexAttribute.array as Uint16Array @@ -195,9 +176,8 @@ export class InsertableGeometry { const indexStart = g3d.getSubmeshIndexStart(sub) const indexEnd = g3d.getSubmeshIndexEnd(sub) - // Use color index if palette optimization is enabled, otherwise submesh index - // Hoist out of inner loop - computed once per submesh instead of per index - const colorIndex = g3d.submeshToColorIndex?.[sub] ?? sub + // Hoist color index lookup out of inner loop - computed once per submesh instead of per index + const colorIndex = g3d.submeshColor[sub] // Merge all indices for this instance for (let index = indexStart; index < indexEnd; index++) { @@ -209,16 +189,14 @@ export class InsertableGeometry { indexOut++ } } - const t3 = performance.now() - InsertableGeometry._cumulativeTiming.indexLoop += t3 - t2 // Direct array access for performance (avoid function call overhead) const positions = this._vertexAttribute.array as Float32Array const packedIds = this._packedIdAttribute.array as Uint32Array const packedId = packPickingId(this._vimIndex, elementIndex) - // Matrix elements for inline transform - const e = matrix.elements + // Short alias for matrix elements - local copy improves cache locality + const e = matrixElements // Initialize submesh bounding box with inverted bounds (min = +∞, max = -∞) // Any vertex will naturally expand it via Math.min/max - no special first-vertex handling needed @@ -233,7 +211,7 @@ export class InsertableGeometry { const y = g3d.positions[srcIdx + 1] const z = g3d.positions[srcIdx + 2] - // Inline matrix transform (applyMatrix4 logic) + // Inline matrix transform using local matrix copy (better cache locality) const tx = e[0] * x + e[4] * y + e[8] * z + e[12] const ty = e[1] * x + e[5] * y + e[9] * z + e[13] const tz = e[2] * x + e[6] * y + e[10] * z + e[14] @@ -256,44 +234,17 @@ export class InsertableGeometry { vertexOut++ } - const t4 = performance.now() - InsertableGeometry._cumulativeTiming.vertexLoop += t4 - t3 submesh.end = indexOffset + indexOut - this.expandBox(submesh.boundingBox) - this.submeshes.push(submesh) added.push(this.submeshes.length - 1) - - const t5 = performance.now() - InsertableGeometry._cumulativeTiming.instanceCleanup += t5 - t4 } - InsertableGeometry._cumulativeTiming.calls++ - this._meshToUpdate.add(mesh) return added } - static logAndResetTiming (label: string) { - const t = InsertableGeometry._cumulativeTiming - console.log(`[InsertableGeometry] ${label} breakdown (${t.calls} insertFromG3d calls):`) - console.log(` Mesh setup: ${t.meshSetup.toFixed(2)}ms`) - console.log(` Instance setup: ${t.instanceSetup.toFixed(2)}ms`) - console.log(` Index loop: ${t.indexLoop.toFixed(2)}ms`) - console.log(` Vertex loop: ${t.vertexLoop.toFixed(2)}ms`) - console.log(` Instance cleanup: ${t.instanceCleanup.toFixed(2)}ms`) - console.log(` Total: ${(t.meshSetup + t.instanceSetup + t.indexLoop + t.vertexLoop + t.instanceCleanup).toFixed(2)}ms`) - // Reset for next batch - t.meshSetup = 0 - t.instanceSetup = 0 - t.indexLoop = 0 - t.vertexLoop = 0 - t.instanceCleanup = 0 - t.calls = 0 - } - private expandBox (box: THREE.Box3) { // Direct min/max expansion (no null checks needed - boundingBox initialized with inverted bounds) this.boundingBox.min.min(box.min) @@ -338,10 +289,9 @@ export class InsertableGeometry { // this._vertexAttribute.count = vertexEnd this._vertexAttribute.needsUpdate = true - // Colors are initialized to white once, no need to update - // Actual colors come from texture lookup via submeshIndex + // Colors come from texture lookup via color palette index - // update submesh indices (itemSize is 1) + // update color palette indices (itemSize is 1) this._submeshIndexAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) this._submeshIndexAttribute.needsUpdate = true diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts index 8998214b2..c0875e194 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts @@ -126,4 +126,5 @@ export class InsertableMesh { setMaterial(value: ModelMaterial) { applyMaterial(this.mesh, value, this._material, this.ignoreSceneMaterial) } + } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 33f13ef6a..5bab06421 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -54,11 +54,8 @@ export class InstancedMeshFactory { section: MeshSection, transparent: boolean ) { - const geometry = Geometry.createGeometryFromMesh( - g3d, - mesh, - section - ) + const geometry = Geometry.createGeometryFromMesh(g3d, mesh, section) + const material = transparent ? Materials.getInstance().transparent : Materials.getInstance().opaque @@ -69,10 +66,11 @@ export class InstancedMeshFactory { instances?.length ?? g3d.getMeshInstanceCount(mesh) ) - this.setMatrices(threeMesh, g3d, instances) - this.setPackedIds(threeMesh, instances ?? g3d.meshInstances[mesh]) - const result = new InstancedMesh(threeMesh, instances) - return result + const instanceArray = instances ?? g3d.meshInstances[mesh] + this.setMatrices(threeMesh, g3d, instanceArray) + this.setPackedIds(threeMesh, instanceArray) + + return new InstancedMesh(threeMesh, instanceArray) } private setMatrices ( diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts index 4fd23b322..77074543b 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -79,23 +79,16 @@ export class LoadRequest extends BaseLoadRequest { } } - // Step 1: Parse G3d geometry const geometry = await bfast.getBfast('geometry') const g3d = await G3d.createFromBfast(geometry) - - // Step 2: Augment with pre-computed mesh→instances map and color palette (shared by all G3dSubsets) const mappedG3d = createMappedG3d(g3d) const materials = new G3dMaterial(mappedG3d.materialColors) - // Step 3-4: Parse BIM document and build instance → element mapping const doc = await VimDocument.createFromBfast(bfast) const mapping = await ElementMapping.fromG3d(mappedG3d, doc) - // Step 5: Create scene and factory (factory needs mapping for GPU picking IDs) const scene = new Scene(fullSettings.matrix) const factory = new VimMeshFactory(mappedG3d, materials, scene, mapping, vimIndex) - - // Step 5.5: Set submesh color palette (shared texture for both opaque and transparent) Materials.getInstance().setColorPalette(mappedG3d.colorPalette) const header = await requestHeader(bfast) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts index 007d7998b..ded7f6267 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts @@ -16,9 +16,9 @@ import { buildColorPalette } from '../materials/colorPalette' export interface MappedG3d extends G3d { _meshInstances: Map - // Color palette optimization (undefined if disabled due to too many unique colors) + // Color palette optimization (palette undefined if too many unique colors, but submeshColor always present) colorPalette: Float32Array | undefined - submeshToColorIndex: Uint16Array + submeshColor: Uint16Array uniqueColorCount: number } @@ -48,10 +48,10 @@ export function createMappedG3d(g3d: G3d): MappedG3d { // Build color palette optimization const submeshColorCount = mapped.submeshMaterial.length - const { palette, submeshToColorIndex, uniqueColorCount } = buildColorPalette(mapped, submeshColorCount) + const { palette, submeshColor, uniqueColorCount } = buildColorPalette(mapped, submeshColorCount) mapped.colorPalette = palette - mapped.submeshToColorIndex = submeshToColorIndex + mapped.submeshColor = submeshColor mapped.uniqueColorCount = uniqueColorCount return mapped diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts index b74f8b087..1e262c7f7 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts @@ -38,14 +38,14 @@ export class VimMeshFactory { * - >5 instances per mesh → GPU instanced (geometry shared, one mesh per unique geometry) */ public add (subset: G3dSubset) { - // Split in single pass instead of two filterByCount calls const [merged, instanced] = subset.splitByCount(5) // Instanced meshes first (one Three.js InstancedMesh per unique geometry) this.addInstancedMeshes(this._scene, instanced) + // Merged meshes chunked at 16M indices (GPU picking removes raycast traversal constraint) const chunks = merged.chunks(16_000_000) - for(const chunk of chunks) { + for (const chunk of chunks) { this.addMergedMesh(this._scene, chunk) } } @@ -62,21 +62,12 @@ export class VimMeshFactory { const count = subset.getMeshCount() for (let m = 0; m < count; m++) { const mesh = subset.getSourceMesh(m) - const instances = - subset.getMeshInstances(m) ?? this.g3d.meshInstances[mesh] + const instances = subset.getMeshInstances(m) ?? this.g3d.meshInstances[mesh] - const opaque = this._instancedFactory.createOpaqueFromVim( - this.g3d, - mesh, - instances - ) + const opaque = this._instancedFactory.createOpaqueFromVim(this.g3d, mesh, instances) if (opaque) scene.addMesh(opaque) - const transparent = this._instancedFactory.createTransparentFromVim( - this.g3d, - mesh, - instances - ) + const transparent = this._instancedFactory.createTransparentFromVim(this.g3d, mesh, instances) if (transparent) scene.addMesh(transparent) } } diff --git a/src/vim-web/core-viewers/webgl/loader/scene.ts b/src/vim-web/core-viewers/webgl/loader/scene.ts index 6d243ec5f..4d322b7c4 100644 --- a/src/vim-web/core-viewers/webgl/loader/scene.ts +++ b/src/vim-web/core-viewers/webgl/loader/scene.ts @@ -147,8 +147,7 @@ export class Scene { /** * Registers a submesh in the instance → submesh map. - * If a Vim is attached, also wires the submesh to its Element3D - * so that visibility/color/outline changes propagate to the right geometry. + * Element3D objects will be created lazily when accessed via getElement(). */ addSubmesh (submesh: Submesh) { let meshes = this._instanceToMeshes[submesh.instance] @@ -158,10 +157,6 @@ export class Scene { } meshes.push(submesh) this.setDirty() - if (this.vim) { - const obj = this.vim.getElement(submesh.instance) - obj._addMesh(submesh) - } } /** diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index bbe9d3c5b..57a581c19 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -218,7 +218,7 @@ export class Vim implements IVim { * Asynchronously loads all geometry. */ async loadAll () { - return this.loadSubset(this.getFullSet()) + await this.loadSubset(this.getFullSet()) } /** @@ -230,18 +230,14 @@ export class Vim implements IVim { * @param {G3dSubset} subset - The subset to load resources for. */ async loadSubset (subset: G3dSubset) { - // Exclude instances that have already been loaded subset = subset.except('instance', this._loadedInstances) const count = subset.getInstanceCount() for (let i = 0; i < count; i++) { this._loadedInstances.add(subset.getVimInstance(i)) } - if (subset.getInstanceCount() === 0) { - console.log('Empty subset. Ignoring') - return - } - // Build meshes and add to scene + if (subset.getInstanceCount() === 0) return + this._factory.add(subset) this._onUpdate.dispatch({ type: 'percent', From 2ce41e95e0c45c79ba3301f309c1f25f4c1aefe3 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Fri, 13 Feb 2026 10:49:58 -0500 Subject: [PATCH 89/94] numeric color key --- .../webgl/loader/materials/colorPalette.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts b/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts index 717eaa0c0..a6135de00 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts @@ -30,14 +30,15 @@ export function buildColorPalette( submeshColorCount: number ): ColorPaletteResult { // Build unique color palette for shader lookup - const uniqueColorsMap = new Map() // color key → colorIndex + // Numeric keys avoid string allocation per submesh (Map is faster than Map) + const uniqueColorsMap = new Map() const colorPaletteArray: number[] = [] const submeshColor = new Uint16Array(submeshColorCount) // First pass: build initial palette for (let i = 0; i < submeshColorCount; i++) { const color = mappedG3d.getSubmeshColor(i) - const key = `${color[0].toFixed(6)},${color[1].toFixed(6)},${color[2].toFixed(6)}` + const key = packColorKey(color[0], color[1], color[2]) let colorIndex = uniqueColorsMap.get(key) if (colorIndex === undefined) { @@ -61,7 +62,7 @@ export function buildColorPalette( for (let i = 0; i < submeshColorCount; i++) { const color = mappedG3d.getSubmeshColor(i) - const key = `${color[0].toFixed(6)},${color[1].toFixed(6)},${color[2].toFixed(6)}` + const key = packColorKey(color[0], color[1], color[2]) let colorIndex = uniqueColorsMap.get(key) if (colorIndex === undefined) { @@ -92,6 +93,11 @@ export function buildColorPalette( * @param colors - Float32Array of RGB colors to quantize in-place * @param levels - Number of quantization levels per channel (e.g., 25 = 15,625 max colors) */ +/** Packs RGB floats [0,1] into a single number key (16 bits per channel, 48 bits total). */ +function packColorKey(r: number, g: number, b: number): number { + return (Math.round(r * 65535) * 65536 + Math.round(g * 65535)) * 65536 + Math.round(b * 65535) +} + function quantizeColors(colors: Float32Array, levels: number): void { const quantize = (value: number) => Math.round(value * levels) / levels From a3debbed15373f53fc25593216aac119ad511c83 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Fri, 13 Feb 2026 10:54:55 -0500 Subject: [PATCH 90/94] g3dsubset and offseet --- .../webgl/loader/progressive/g3dOffsets.ts | 40 +-- .../webgl/loader/progressive/g3dSubset.ts | 253 +++++++++--------- .../webgl/loader/progressive/mappedG3d.ts | 22 +- src/vim-web/core-viewers/webgl/loader/vim.ts | 20 +- 4 files changed, 172 insertions(+), 163 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts index 21f7c430f..822b79a1f 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts @@ -33,7 +33,7 @@ export class G3dMeshOffsets { private readonly _vertexOffsets: Int32Array /** - * Computes geometry offsets for given subset and section + * Computes geometry offsets for given subset and section in a single pass. * @param subset subset for which to compute offsets * @param section 'opaque' | 'transparent' | 'all' */ @@ -41,23 +41,33 @@ export class G3dMeshOffsets { this.subset = subset this.section = section - this.counts = subset.getAttributeCounts(section) - this._indexOffsets = this.computeOffsets(subset, (m) => - subset.getMeshIndexCount(m, section) - ) - this._vertexOffsets = this.computeOffsets(subset, (m) => - subset.getMeshVertexCount(m, section) - ) - } - - private computeOffsets (subset: G3dSubset, getter: (mesh: number) => number) { const meshCount = subset.getMeshCount() - const offsets = new Int32Array(meshCount) + const indexOffsets = new Int32Array(meshCount) + const vertexOffsets = new Int32Array(meshCount) + const counts = new G3dMeshCounts() + + let indexOffset = 0 + let vertexOffset = 0 + + for (let i = 0; i < meshCount; i++) { + indexOffsets[i] = indexOffset + vertexOffsets[i] = vertexOffset - for (let i = 1; i < meshCount; i++) { - offsets[i] = offsets[i - 1] + getter(i - 1) + const indices = subset.getMeshIndexCount(i, section) + const vertices = subset.getMeshVertexCount(i, section) + + indexOffset += indices + vertexOffset += vertices + counts.instances += subset.getMeshInstanceCount(i) } - return offsets + + counts.indices = indexOffset + counts.vertices = vertexOffset + counts.meshes = meshCount + + this.counts = counts + this._indexOffsets = indexOffsets + this._vertexOffsets = vertexOffsets } /** diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts index da1938e1b..4111036b6 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts @@ -3,7 +3,7 @@ */ import { MeshSection, FilterMode } from 'vim-format' -import { G3dMeshOffsets, G3dMeshCounts } from './g3dOffsets' +import { G3dMeshOffsets } from './g3dOffsets' import { MappedG3d } from './mappedG3d' /** @@ -12,8 +12,11 @@ import { MappedG3d } from './mappedG3d' */ export class G3dSubset { private _source: MappedG3d - // source-based indices of included instanced - private _instances: number[] + + /** Lazy flat instance list — only materialized when filter/getVimInstance needs it */ + private _flatInstances: number[] | null = null + /** Eagerly computed instance count (sum of mesh instance lengths) */ + private _instanceCount: number /** Source-based mesh indices */ private _meshes: Array @@ -21,41 +24,32 @@ export class G3dSubset { private _meshInstances: Array> /** - * @param source Underlying data source for the subset (must be a MappedG3d with pre-computed map) - * @param instances source-based instance indices of included instances. + * Creates a full set containing all instances from the source. */ - constructor ( - source: MappedG3d, - // source-based indices of included instanced - instances?: number[] - ) { + constructor (source: MappedG3d) { this._source = source + this._meshes = source._meshKeys + this._meshInstances = source._meshValues + this._instanceCount = source._totalInstanceCount + } - // Build full instance list if not provided - if (!instances) { - instances = [] - for (let i = 0; i < source.instanceMeshes.length; i++) { - if (source.instanceMeshes[i] >= 0) { - instances.push(i) - } - } - } - this._instances = instances - - // Build mesh data from pre-computed G3d map (shared by all subsets) - const g3dMap = this._source._meshInstances - const instanceSet = new Set(this._instances) - - this._meshes = [] - this._meshInstances = [] - - for (const [mesh, allInstances] of g3dMap) { - const filteredInstances = allInstances.filter((i: number) => instanceSet.has(i)) - if (filteredInstances.length > 0) { - this._meshes.push(mesh) - this._meshInstances.push(filteredInstances) - } - } + /** + * Creates a G3dSubset directly from pre-computed mesh arrays, + * bypassing the constructor's Set+filter reconstruction. + */ + private static _fromPrebuilt ( + source: MappedG3d, + instanceCount: number, + meshes: number[], + meshInstances: number[][] + ): G3dSubset { + const subset = Object.create(G3dSubset.prototype) as G3dSubset + subset._source = source + subset._flatInstances = null + subset._instanceCount = instanceCount + subset._meshes = meshes + subset._meshInstances = meshInstances + return subset } /** @@ -64,49 +58,53 @@ export class G3dSubset { * matching the 4M index chunking limit used by VimMeshFactory. */ chunks(count: number): G3dSubset[] { - const chunks: G3dSubset[] = [] + const result: G3dSubset[] = [] let currentSize = 0 - let currentInstances: number[] = [] - for(let i = 0; i < this._meshes.length; i++) { + let currentInstanceCount = 0 + let currentMeshes: number[] = [] + let currentMeshInstances: number[][] = [] - // Get mesh size and instances + for (let i = 0; i < this._meshes.length; i++) { const meshSize = this.getMeshIndexCount(i, 'all') const instances = this._meshInstances[i] currentSize += meshSize - // Avoid spread operator - it creates temporary arrays - for (const instance of instances) { - currentInstances.push(instance) - } - - // Push chunk if size is reached - if(currentSize > count) { - chunks.push(new G3dSubset(this._source, currentInstances)) - currentInstances = [] + currentMeshes.push(this._meshes[i]) + currentMeshInstances.push(instances) + currentInstanceCount += instances.length + + if (currentSize > count) { + result.push(G3dSubset._fromPrebuilt( + this._source, currentInstanceCount, currentMeshes, currentMeshInstances + )) + currentMeshes = [] + currentMeshInstances = [] + currentInstanceCount = 0 currentSize = 0 } } - // Don't forget remaining instances - if (currentInstances.length > 0) { - chunks.push(new G3dSubset(this._source, currentInstances)) + if (currentInstanceCount > 0) { + result.push(G3dSubset._fromPrebuilt( + this._source, currentInstanceCount, currentMeshes, currentMeshInstances + )) } - return chunks + return result } /** * Returns total instance count in subset. */ getInstanceCount () { - return this._instances.length + return this._instanceCount } /** * Returns the vim-based instance (node) for given subset-based instance. */ getVimInstance (subsetIndex: number) { - const vimIndex = this._instances[subsetIndex] + const vimIndex = this._getFlatInstances()[subsetIndex] return this._source.instanceNodes[vimIndex] } @@ -183,55 +181,51 @@ export class G3dSubset { * - instanced meshes (>5 instances) via filterByCount(c => c > 5) */ filterByCount (predicate: (i: number) => boolean) { - const set = new Set() - this._meshInstances.forEach((instances, i) => { - if (predicate(instances.length)) { - set.add(this._meshes[i]) + const meshes: number[] = [] + const meshInstances: number[][] = [] + let instanceCount = 0 + + for (let i = 0; i < this._meshInstances.length; i++) { + if (predicate(this._meshInstances[i].length)) { + meshes.push(this._meshes[i]) + meshInstances.push(this._meshInstances[i]) + instanceCount += this._meshInstances[i].length } - }) - const instances = this._instances.filter((instance) => - set.has(this._source.instanceMeshes[instance]) - ) + } - return new G3dSubset(this._source, instances) + return G3dSubset._fromPrebuilt(this._source, instanceCount, meshes, meshInstances) } /** * Splits subset into two based on instance count threshold. - * More efficient than calling filterByCount twice - does single pass. + * Builds mesh arrays directly in a single pass — no Set construction or re-filtering. * @param threshold Instance count threshold * @returns [low (<=threshold), high (>threshold)] */ splitByCount (threshold: number): [G3dSubset, G3dSubset] { - const lowMeshes = new Set() - const highMeshes = new Set() - - // Single pass through mesh instances - this._meshInstances.forEach((instances, i) => { - const mesh = this._meshes[i] + const lowMeshes: number[] = [] + const lowMeshInstances: number[][] = [] + let lowCount = 0 + const highMeshes: number[] = [] + const highMeshInstances: number[][] = [] + let highCount = 0 + + for (let i = 0; i < this._meshes.length; i++) { + const instances = this._meshInstances[i] if (instances.length <= threshold) { - lowMeshes.add(mesh) + lowMeshes.push(this._meshes[i]) + lowMeshInstances.push(instances) + lowCount += instances.length } else { - highMeshes.add(mesh) - } - }) - - // Single pass through instances to split them - const lowInstances: number[] = [] - const highInstances: number[] = [] - - for (const instance of this._instances) { - const mesh = this._source.instanceMeshes[instance] - if (lowMeshes.has(mesh)) { - lowInstances.push(instance) - } else if (highMeshes.has(mesh)) { - highInstances.push(instance) + highMeshes.push(this._meshes[i]) + highMeshInstances.push(instances) + highCount += instances.length } } return [ - new G3dSubset(this._source, lowInstances), - new G3dSubset(this._source, highInstances) + G3dSubset._fromPrebuilt(this._source, lowCount, lowMeshes, lowMeshInstances), + G3dSubset._fromPrebuilt(this._source, highCount, highMeshes, highMeshInstances) ] } @@ -242,21 +236,6 @@ export class G3dSubset { return new G3dMeshOffsets(this, section) } - /** - * Returns the count of each mesh attribute. - */ - getAttributeCounts (section: MeshSection = 'all') { - const result = new G3dMeshCounts() - const count = this._meshes.length - for (let i = 0; i < count; i++) { - result.instances += this._meshInstances[i].length - result.indices += this.getMeshIndexCount(i, section) - result.vertices += this.getMeshVertexCount(i, section) - } - result.meshes = count - - return result - } /** * Returns a new subset with instances NOT matching the filter. @@ -284,46 +263,58 @@ export class G3dSubset { has: boolean ): G3dSubset { if (filter === undefined || mode === undefined) { - return new G3dSubset(this._source, undefined) + return new G3dSubset(this._source) } - if (mode === 'instance') { - const instances = this.filterOnArray( - filter, - this._source.instanceNodes, - has - ) - return new G3dSubset(this._source, instances) - } - - if (mode === 'mesh') { - const instances = this.filterOnArray( - filter, - this._source.instanceMeshes, - has - ) - return new G3dSubset(this._source, instances) + // Short-circuit: empty filter + const filterSize = filter instanceof Set ? filter.size : filter.length + if (filterSize === 0) { + return has + ? G3dSubset._fromPrebuilt(this._source, 0, [], []) + : this } if (mode === 'tag' || mode === 'group') { throw new Error('Filter Mode Not implemented') } - } - private filterOnArray ( - filter: number[] | Set, - array: Int32Array, - has: boolean = true - ) { + // Filter per-mesh directly — no flat instance list needed const set = filter instanceof Set ? filter : new Set(filter) - const result: number[] = [] - for (const i of this._instances) { - const value = array[i] - if (set.has(value) === has && this._source.instanceMeshes[i] >= 0) { - result.push(i) + const array = mode === 'instance' + ? this._source.instanceNodes + : this._source.instanceMeshes + + const meshes: number[] = [] + const meshInstances: number[][] = [] + let instanceCount = 0 + + for (let i = 0; i < this._meshes.length; i++) { + const filtered = this._meshInstances[i].filter( + inst => set.has(array[inst]) === has + ) + if (filtered.length > 0) { + meshes.push(this._meshes[i]) + meshInstances.push(filtered) + instanceCount += filtered.length } } - return result + + if (instanceCount === this._instanceCount) return this + return G3dSubset._fromPrebuilt(this._source, instanceCount, meshes, meshInstances) + } + + /** Lazily materializes the flat instance array from mesh-grouped data. */ + private _getFlatInstances (): number[] { + if (!this._flatInstances) { + const result: number[] = [] + for (const instances of this._meshInstances) { + for (const inst of instances) { + result.push(inst) + } + } + this._flatInstances = result + } + return this._flatInstances } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts index ded7f6267..9ad29e05f 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts @@ -14,7 +14,12 @@ import { buildColorPalette } from '../materials/colorPalette' * color lookup instead of per-vertex color attributes (saves 60-80% geometry memory). */ export interface MappedG3d extends G3d { - _meshInstances: Map + /** Source-based mesh indices (parallel with _meshInstanceArrays) */ + _meshKeys: number[] + /** Instances per mesh (parallel with _meshKeys) */ + _meshValues: number[][] + /** Pre-computed total instance count across all meshes */ + _totalInstanceCount: number // Color palette optimization (palette undefined if too many unique colors, but submeshColor always present) colorPalette: Float32Array | undefined @@ -33,7 +38,7 @@ export interface MappedG3d extends G3d { export function createMappedG3d(g3d: G3d): MappedG3d { const mapped = g3d as MappedG3d - // Build the mesh→instances map + // Build the mesh→instances map, then extract to parallel arrays const map = new Map() for (let i = 0; i < g3d.instanceMeshes.length; i++) { const mesh = g3d.instanceMeshes[i] @@ -44,7 +49,18 @@ export function createMappedG3d(g3d: G3d): MappedG3d { map.get(mesh)!.push(i) } } - mapped._meshInstances = map + + const meshKeys: number[] = [] + const meshValues: number[][] = [] + let totalCount = 0 + for (const [mesh, instances] of map) { + meshKeys.push(mesh) + meshValues.push(instances) + totalCount += instances.length + } + mapped._meshKeys = meshKeys + mapped._meshValues = meshValues + mapped._totalInstanceCount = totalCount // Build color palette optimization const submeshColorCount = mapped.submeshMaterial.length diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index 57a581c19..747aec529 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -72,8 +72,8 @@ export class Vim implements IVim { readonly map: ElementMapping | ElementNoMapping private readonly _factory: VimMeshFactory - private readonly _loadedInstances = new Set() private readonly _elementToObject = new Map() + private _loadedInstanceCount = 0 private _onUpdate = new SimpleEventDispatcher() /** @@ -222,26 +222,18 @@ export class Vim implements IVim { } /** - * Core progressive loading method. Steps: - * 1. Exclude already-loaded instances via subset.except() to avoid duplicates - * 2. Record new instances in _loadedInstances set - * 3. Delegate to VimMeshFactory.add() which splits into merged/instanced - * 4. Dispatch onUpdate signal (consumed by UI for loading progress) + * Loads geometry for the given subset. + * Caller is responsible for not loading the same subset twice. * @param {G3dSubset} subset - The subset to load resources for. */ async loadSubset (subset: G3dSubset) { - subset = subset.except('instance', this._loadedInstances) - const count = subset.getInstanceCount() - for (let i = 0; i < count; i++) { - this._loadedInstances.add(subset.getVimInstance(i)) - } - if (subset.getInstanceCount() === 0) return this._factory.add(subset) + this._loadedInstanceCount += subset.getInstanceCount() this._onUpdate.dispatch({ type: 'percent', - current: this._loadedInstances.size, + current: this._loadedInstanceCount, total: this.getFullSet().getInstanceCount() }) } @@ -264,7 +256,7 @@ export class Vim implements IVim { */ clear () { this._elementToObject.clear() - this._loadedInstances.clear() + this._loadedInstanceCount = 0 this.scene.clear() this._onUpdate.dispatch({ type: 'percent', From d33b08c6ad2f75d9f44ef7a8b1f25b8cff229ec9 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Fri, 13 Feb 2026 11:34:54 -0500 Subject: [PATCH 91/94] optims --- .../webgl/loader/elementMapping.ts | 6 +++-- .../loader/progressive/insertableMesh.ts | 7 +++++- .../progressive/insertableMeshFactory.ts | 25 ++++++++----------- .../webgl/loader/progressive/instancedMesh.ts | 8 +++++- .../progressive/instancedMeshFactory.ts | 11 +++++--- .../core-viewers/webgl/loader/scene.ts | 8 +++--- 6 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts index 8cebe8868..284184576 100644 --- a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts +++ b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts @@ -36,7 +36,7 @@ export class ElementMapping { private _instanceMeshes: Int32Array private _elementToInstances: (number[] | undefined)[] private _elementIds: BigInt64Array - private _elementIdToElements: Map + private _elementIdToElements: Map | null = null constructor ( instanceToElement: number[] | Int32Array, @@ -53,7 +53,6 @@ export class ElementMapping { ) this._elementIds = elementIds - this._elementIdToElements = ElementMapping.invertToMap(elementIds) this._instanceMeshes = instanceMeshes } @@ -73,6 +72,9 @@ export class ElementMapping { * @param id element id */ getElementsFromElementId (id: number | bigint) { + if (!this._elementIdToElements) { + this._elementIdToElements = ElementMapping.invertToMap(this._elementIds) + } return this._elementIdToElements.get(BigInt(id)) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts index c0875e194..341898680 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts @@ -100,7 +100,6 @@ export class InsertableMesh { } /** - * * @returns Returns all submeshes */ getSubmeshes () { @@ -113,6 +112,12 @@ export class InsertableMesh { return submeshes } + forEachSubmesh (callback: (submesh: InsertableSubmesh) => void) { + for (let i = 0; i < this.geometry.submeshes.length; i++) { + callback(new InsertableSubmesh(this, i)) + } + } + /** * * @returns Returns submesh for given index. diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts index fa89d266f..b0f036edb 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts @@ -14,8 +14,9 @@ * 4. Finalize with update() to upload buffer ranges to GPU */ -import { G3dMaterial, MeshSection } from 'vim-format' +import { G3dMaterial } from 'vim-format' import { InsertableMesh } from './insertableMesh' +import { G3dMeshOffsets } from './g3dOffsets' import { G3dSubset } from './g3dSubset' import { ElementMapping } from '../elementMapping' import { MappedG3d } from './mappedG3d' @@ -32,19 +33,15 @@ export class InsertableMeshFactory { } createOpaqueFromVim (g3d: MappedG3d, subset: G3dSubset) { - // Skip if no opaque geometry - if (!subset.getOffsets('opaque').any()) { - return undefined - } - return this.createFromVim(g3d, subset, 'opaque', false) + const offsets = subset.getOffsets('opaque') + if (!offsets.any()) return undefined + return this.createFromVim(g3d, offsets, false) } createTransparentFromVim (g3d: MappedG3d, subset: G3dSubset) { - // Skip if no transparent geometry - if (!subset.getOffsets('transparent').any()) { - return undefined - } - return this.createFromVim(g3d, subset, 'transparent', true) + const offsets = subset.getOffsets('transparent') + if (!offsets.any()) return undefined + return this.createFromVim(g3d, offsets, true) } /** @@ -55,14 +52,12 @@ export class InsertableMeshFactory { */ private createFromVim ( g3d: MappedG3d, - subset: G3dSubset, - section: MeshSection, + offsets: G3dMeshOffsets, transparent: boolean ) { - const offsets = subset.getOffsets(section) const mesh = new InsertableMesh(offsets, this._materials, transparent, this._mapping, this._vimIndex) - const count = subset.getMeshCount() + const count = offsets.subset.getMeshCount() for (let m = 0; m < count; m++) { mesh.insertFromVim(g3d, m) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts index 7f3d0b47c..5b1eb7308 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts @@ -60,7 +60,7 @@ export class InstancedMesh { } /** - * Returns all submeshes for given index. + * Returns all submeshes. */ getSubmeshes () { const submeshes = new Array(this.instances.length) @@ -70,6 +70,12 @@ export class InstancedMesh { return submeshes } + forEachSubmesh (callback: (submesh: InstancedSubmesh) => void) { + for (let i = 0; i < this.instances.length; i++) { + callback(new InstancedSubmesh(this, i)) + } + } + setMaterial(value: ModelMaterial) { applyMaterial(this.mesh, value, this._material, this.ignoreSceneMaterial) } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 5bab06421..1319dbe43 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -78,11 +78,14 @@ export class InstancedMeshFactory { source: MappedG3d, instances: number[] ) { - const matrix = new THREE.Matrix4() + const dst = three.instanceMatrix.array as Float32Array + const src = source.instanceTransforms for (let i = 0; i < instances.length; i++) { - const array = source.getInstanceMatrix(instances[i]) - matrix.fromArray(array) - three.setMatrixAt(i, matrix) + const srcOffset = instances[i] * 16 + const dstOffset = i * 16 + for (let j = 0; j < 16; j++) { + dst[dstOffset + j] = src[srcOffset + j] + } } } diff --git a/src/vim-web/core-viewers/webgl/loader/scene.ts b/src/vim-web/core-viewers/webgl/loader/scene.ts index 4d322b7c4..e9fc49e6a 100644 --- a/src/vim-web/core-viewers/webgl/loader/scene.ts +++ b/src/vim-web/core-viewers/webgl/loader/scene.ts @@ -147,16 +147,14 @@ export class Scene { /** * Registers a submesh in the instance → submesh map. - * Element3D objects will be created lazily when accessed via getElement(). */ - addSubmesh (submesh: Submesh) { + private registerSubmesh (submesh: Submesh) { let meshes = this._instanceToMeshes[submesh.instance] if (!meshes) { meshes = [] this._instanceToMeshes[submesh.instance] = meshes } meshes.push(submesh) - this.setDirty() } /** @@ -164,7 +162,7 @@ export class Scene { * 1. Add Three.js mesh to renderer * 2. Apply scene transform matrix (position/rotation/scale from VimSettings) * 3. Expand scene bounding box - * 4. Register all submeshes (maps instance → submesh, wires to Element3D) + * 4. Register all submeshes (maps instance → submesh) * 5. Apply current material override if any */ addMesh (mesh: InsertableMesh | InstancedMesh) { @@ -175,7 +173,7 @@ export class Scene { mesh.mesh.matrix.copy(this._matrix) this.updateBox(mesh.boundingBox) - mesh.getSubmeshes().forEach((s) => this.addSubmesh(s)) + mesh.forEachSubmesh((s) => this.registerSubmesh(s)) mesh.setMaterial(this.material) this.meshes.push(mesh) From 8c27098ba04b44ddb67ee2871ab21ccf164aa7c6 Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Fri, 13 Feb 2026 12:05:25 -0500 Subject: [PATCH 92/94] optims --- .../loader/progressive/insertableGeometry.ts | 11 ++++------- .../webgl/loader/progressive/insertableMesh.ts | 2 +- .../loader/progressive/insertableMeshFactory.ts | 4 ++-- .../webgl/loader/progressive/instancedMesh.ts | 17 ++++++----------- .../loader/progressive/instancedMeshFactory.ts | 6 +++--- 5 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts index 9469c45a4..4fbfc03db 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -52,7 +52,7 @@ export class InsertableGeometry { private _vertexAttribute: THREE.BufferAttribute private _submeshIndexAttribute: THREE.Uint16BufferAttribute // Color palette index for texture-based color lookup private _packedIdAttribute: THREE.Uint32BufferAttribute - private _mapping: ElementMapping | undefined + private _mapping: ElementMapping private _vimIndex: number private _updateStartMesh = 0 @@ -63,7 +63,7 @@ export class InsertableGeometry { offsets: G3dMeshOffsets, materials: G3dMaterial, transparent: boolean, - mapping?: ElementMapping, + mapping: ElementMapping, vimIndex: number = 0 ) { this.offsets = offsets @@ -119,7 +119,6 @@ export class InsertableGeometry { * and creates a GeometrySubmesh tracking the index range and bounding box. */ insertFromG3d (g3d: MappedG3d, mesh: number) { - const added: number[] = [] const meshG3dIndex = this.offsets.subset.getSourceMesh(mesh) const subStart = g3d.getMeshSubmeshStart(meshG3dIndex, this.offsets.section) const subEnd = g3d.getMeshSubmeshEnd(meshG3dIndex, this.offsets.section) @@ -127,7 +126,7 @@ export class InsertableGeometry { // Skip empty mesh if (subStart === subEnd) { this._meshToUpdate.add(mesh) - return added + return } // Offsets for this mesh and all its instances @@ -161,7 +160,7 @@ export class InsertableGeometry { } // Get element index for this instance (for GPU picking) - const elementIndex = this._mapping?.getElementFromInstance(g3dInstance) ?? -1 + const elementIndex = this._mapping.getElementFromInstance(g3dInstance) ?? -1 const submesh = new GeometrySubmesh() submesh.instance = instanceNodes[g3dInstance] @@ -238,11 +237,9 @@ export class InsertableGeometry { submesh.end = indexOffset + indexOut this.expandBox(submesh.boundingBox) this.submeshes.push(submesh) - added.push(this.submeshes.length - 1) } this._meshToUpdate.add(mesh) - return added } private expandBox (box: THREE.Box3) { diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts index 341898680..5c7fb6f3b 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMesh.ts @@ -52,7 +52,7 @@ export class InsertableMesh { offsets: G3dMeshOffsets, materials: G3dMaterial, transparent: boolean, - mapping?: ElementMapping, + mapping: ElementMapping, vimIndex: number = 0 ) { this.offsets = offsets diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts index b0f036edb..244860ef6 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts @@ -23,10 +23,10 @@ import { MappedG3d } from './mappedG3d' export class InsertableMeshFactory { private _materials: G3dMaterial - private _mapping: ElementMapping | undefined + private _mapping: ElementMapping private _vimIndex: number - constructor (materials: G3dMaterial, mapping?: ElementMapping, vimIndex: number = 0) { + constructor (materials: G3dMaterial, mapping: ElementMapping, vimIndex: number = 0) { this._materials = materials this._mapping = mapping this._vimIndex = vimIndex diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts index 5b1eb7308..349f2f4b3 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts @@ -95,24 +95,19 @@ export class InstancedMesh { /** * Computes overall bounding box without allocating per-instance boxes. - * This is more efficient than computing all boxes upfront when only the - * overall bounds are needed. */ private computeBoundingBox (): THREE.Box3 { - // Geometry bounding box already computed in constructor + const geoBBox = this.mesh.geometry.boundingBox const matrix = new THREE.Matrix4() - let result: THREE.Box3 | undefined + const tempBox = new THREE.Box3() + const result = new THREE.Box3().makeEmpty() for (let i = 0; i < this.mesh.count; i++) { this.mesh.getMatrixAt(i, matrix) - const box = this.mesh.geometry.boundingBox.clone().applyMatrix4(matrix) - if (result) { - result.union(box) - } else { - result = box - } + tempBox.copy(geoBBox).applyMatrix4(matrix) + result.union(tempBox) } - return result ?? new THREE.Box3() + return result } } diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts index 1319dbe43..74bdc5d05 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -19,10 +19,10 @@ import { packPickingId } from '../../viewer/rendering/gpuPicker' import { MappedG3d } from './mappedG3d' export class InstancedMeshFactory { - private _mapping: ElementMapping | undefined + private _mapping: ElementMapping private _vimIndex: number - constructor (mapping?: ElementMapping, vimIndex: number = 0) { + constructor (mapping: ElementMapping, vimIndex: number = 0) { this._mapping = mapping this._vimIndex = vimIndex } @@ -101,7 +101,7 @@ export class InstancedMeshFactory { ) { const packedIds = new Uint32Array(instances.length) for (let i = 0; i < instances.length; i++) { - const elementIndex = this._mapping?.getElementFromInstance(instances[i]) ?? -1 + const elementIndex = this._mapping.getElementFromInstance(instances[i]) ?? -1 packedIds[i] = packPickingId(this._vimIndex, elementIndex) } three.geometry.setAttribute( From f90ea3451315a134381c53e2f390a1d3eb63a2db Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Fri, 13 Feb 2026 14:04:54 -0500 Subject: [PATCH 93/94] simple matertial updat --- .../webgl/loader/materials/index.ts | 1 + .../webgl/loader/materials/materialSet.ts | 86 +++++++++++ .../webgl/loader/materials/materials.ts | 61 +++++++- .../webgl/loader/materials/simpleMaterial.ts | 139 ++++++++++++++++-- .../viewer/settings/viewerDefaultSettings.ts | 1 + .../webgl/viewer/settings/viewerSettings.ts | 8 + 6 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 src/vim-web/core-viewers/webgl/loader/materials/materialSet.ts diff --git a/src/vim-web/core-viewers/webgl/loader/materials/index.ts b/src/vim-web/core-viewers/webgl/loader/materials/index.ts index eeb671937..009f2babc 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/index.ts @@ -1,5 +1,6 @@ export * from './ghostMaterial'; export * from './maskMaterial'; +export * from './materialSet'; export * from './materials'; export * from './mergeMaterial'; export * from './outlineMaterial'; diff --git a/src/vim-web/core-viewers/webgl/loader/materials/materialSet.ts b/src/vim-web/core-viewers/webgl/loader/materials/materialSet.ts new file mode 100644 index 000000000..f90a0edf5 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/materials/materialSet.ts @@ -0,0 +1,86 @@ +/** + * @module vim-loader/materials + * + * MaterialSet provides a cleaner API for managing material overrides. + * Instead of confusing arrays [visible, hidden], we explicitly name each material type. + */ + +import * as THREE from 'three' + +/** + * A set of materials for different geometry types and visibility states. + * + * This replaces the confusing array-based system where [material0, material1] + * was ambiguous (opaque/transparent? visible/hidden?). + * + * Now we explicitly name each material: + * - opaque: For solid geometry + * - transparent: For see-through geometry + * - hidden: For ghosted/hidden objects (optional) + */ +export class MaterialSet { + readonly opaque: THREE.Material + readonly transparent: THREE.Material + readonly hidden?: THREE.Material + + // Cached arrays to avoid allocating thousands of [A, B] arrays + private _cachedArray?: THREE.Material[] + + constructor( + opaque: THREE.Material, + transparent: THREE.Material, + hidden?: THREE.Material + ) { + this.opaque = opaque + this.transparent = transparent + this.hidden = hidden + } + + /** + * Get material for a specific mesh based on its properties. + * + * @param transparent Whether the mesh has transparent geometry + * @param isHidden Whether the mesh should be rendered as hidden/ghosted + */ + getMaterial(transparent: boolean, isHidden: boolean = false): THREE.Material { + if (isHidden && this.hidden) { + return this.hidden + } + return transparent ? this.transparent : this.opaque + } + + /** + * Get cached array of [opaque, transparent] for Three.js multi-material support. + * + * This is used when setting mesh.material to an array, where geometry groups + * with materialIndex=0 use opaque, materialIndex=1 use transparent. + * + * The array is cached to avoid allocating thousands of identical arrays. + */ + getArray(): THREE.Material[] { + if (!this._cachedArray) { + this._cachedArray = [this.opaque, this.transparent] + } + return this._cachedArray + } + + /** + * Create a MaterialSet from a single material (used for both opaque and transparent). + */ + static fromSingle(material: THREE.Material): MaterialSet { + return new MaterialSet(material, material) + } + + /** + * Check if this MaterialSet is equivalent to another. + * Used to avoid unnecessary material updates. + */ + equals(other: MaterialSet | undefined): boolean { + if (!other) return false + return ( + this.opaque === other.opaque && + this.transparent === other.transparent && + this.hidden === other.hidden + ) + } +} diff --git a/src/vim-web/core-viewers/webgl/loader/materials/materials.ts b/src/vim-web/core-viewers/webgl/loader/materials/materials.ts index aacfad48b..a628ee798 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/materials.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/materials.ts @@ -83,7 +83,18 @@ export class Materials { */ readonly transparent: StandardMaterial /** - * Material used for maximum performance. + * Fast opaque material for maximum performance. + * Use for isolation mode or performance-critical rendering. + */ + readonly simpleOpaque: THREE.ShaderMaterial + /** + * Fast transparent material with alpha blending support. + * ⚠️ Note: May have rendering issues on Windows with ghost materials. + */ + readonly simpleTransparent: THREE.ShaderMaterial + /** + * Legacy simple material (opaque). Use simpleOpaque for new code. + * @deprecated Use simpleOpaque instead */ readonly simple: THREE.Material /** @@ -137,7 +148,14 @@ export class Materials { ) { this.opaque = opaque ?? createOpaque() this.transparent = transparent ?? createTransparent() - this.simple = simple ?? createSimpleMaterial() + + // Initialize new simple material variants + this.simpleOpaque = createSimpleOpaqueMaterial() + this.simpleTransparent = createSimpleTransparentMaterial(0.5) + + // Legacy simple material (backward compatibility) + this.simple = simple ?? this.simpleOpaque + this.wireframe = wireframe ?? createWireframe() this.ghost = ghost ?? createGhostMaterial() this.mask = mask ?? createMaskMaterial() @@ -171,6 +189,32 @@ export class Materials { // outline.antialias is applied in the rendering composer } + /** + * Sets the opacity for the simple transparent material. + * + * This allows dynamic control of transparency without recreating the material, + * which is much faster than creating a new material instance. + * + * Key concepts: + * - Changes the uniform value directly (no shader recompilation) + * - Automatically triggers render update via needsUpdate signal + * - Value is clamped to valid range [0, 1] + * + * @param opacity Opacity value where 0 = fully transparent, 1 = fully opaque + */ + setSimpleTransparentOpacity (opacity: number) { + this.simpleTransparent.uniforms.opacity.value = THREE.MathUtils.clamp(opacity, 0, 1) + this._onUpdate.dispatch() + } + + /** + * Gets the current opacity of the simple transparent material. + * @returns Current opacity value (0-1) + */ + getSimpleTransparentOpacity (): number { + return this.simpleTransparent.uniforms.opacity.value + } + /** * A signal dispatched whenever a material is modified. */ @@ -289,6 +333,8 @@ export class Materials { // THREE Materials will break if assigned undefined this._clippingPlanes = value this.simple.clippingPlanes = value ?? null + this.simpleOpaque.clippingPlanes = value ?? null + this.simpleTransparent.clippingPlanes = value ?? null this.opaque.clippingPlanes = value ?? null this.transparent.clippingPlanes = value ?? null this.wireframe.clippingPlanes = value ?? null @@ -468,10 +514,19 @@ export class Materials { this._submeshColorTexture.magFilter = THREE.NearestFilter } - // Set the same texture on both materials + // Set the same texture on all materials (standard and simple) this.opaque.setSubmeshColorTexture(this._submeshColorTexture) this.transparent.setSubmeshColorTexture(this._submeshColorTexture) + // Also set on simple materials for color palette support + this.simpleOpaque.uniforms.submeshColorTexture.value = this._submeshColorTexture + this.simpleOpaque.uniforms.useSubmeshColors.value = this._submeshColorTexture ? 1.0 : 0.0 + this.simpleOpaque.uniformsNeedUpdate = true + + this.simpleTransparent.uniforms.submeshColorTexture.value = this._submeshColorTexture + this.simpleTransparent.uniforms.useSubmeshColors.value = this._submeshColorTexture ? 1.0 : 0.0 + this.simpleTransparent.uniformsNeedUpdate = true + this._onUpdate.dispatch() } diff --git a/src/vim-web/core-viewers/webgl/loader/materials/simpleMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/simpleMaterial.ts index 05b3dd09e..f4ced482f 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/simpleMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/simpleMaterial.ts @@ -6,21 +6,41 @@ import * as THREE from 'three' /** - * Creates a material for isolation mode. - * - * - **Non-visible items**: Completely excluded from rendering by pushing them out of view. - * - **Visible items**: Rendered with flat shading and basic pseudo-lighting. - * - **Object coloring**: Supports both instance-based and vertex-based coloring for visible objects. + * Options for creating a simple material variant + */ +interface SimpleMaterialOptions { + /** Enable transparency with alpha blending */ + transparent: boolean + /** Initial opacity value (0 = fully transparent, 1 = fully opaque) */ + opacity?: number +} + +/** + * Base factory function for creating simple material variants. * - * This material is optimized for both instanced and merged meshes, with support for clipping planes. + * Key concepts: + * - `transparent: true` enables WebGL alpha blending (allows see-through rendering) + * - `depthWrite: false` for transparent materials prevents blocking objects behind them + * - `opacity` uniform allows runtime control of transparency * - * @returns {THREE.ShaderMaterial} A custom shader material for isolation mode. + * @private Internal factory - use createSimpleOpaqueMaterial() or createSimpleTransparentMaterial() instead */ -export function createSimpleMaterial () { +function createSimpleMaterialBase (options: SimpleMaterialOptions) { return new THREE.ShaderMaterial({ side: THREE.DoubleSide, - // No uniforms are needed for this shader. - uniforms: {}, + + // Transparency configuration + transparent: options.transparent, + depthWrite: !options.transparent, // Opaque writes depth, transparent doesn't + + // Uniforms for shader control + uniforms: { + opacity: { value: options.opacity ?? 1.0 }, + // Color palette support (same as StandardMaterial) + submeshColorTexture: { value: null }, + useSubmeshColors: { value: 0.0 } + }, + // Enable vertex colors for both instanced and merged meshes. vertexColors: true, // Enable support for clipping planes. @@ -43,6 +63,18 @@ export function createSimpleMaterial () { // Passes the color of the vertex or instance to the fragment shader. varying vec3 vColor; + // COLOR PALETTE SUPPORT + // Submesh index for color palette lookup (same as StandardMaterial) + attribute float submeshIndex; + uniform sampler2D submeshColorTexture; + uniform float useSubmeshColors; + + // TRANSPARENCY + // Uniform opacity value controlled by the material + uniform float opacity; + // Pass opacity from vertex to fragment shader + varying float vAlpha; + // Determines whether to use instance color (1.0) or vertex color (0.0). // For merged meshes, this is used as a vertex attribute. // For instanced meshes, this is used as an instance attribute. @@ -67,19 +99,33 @@ export function createSimpleMaterial () { } // COLORING - // Default to the vertex color. - vColor = color.xyz; + // Get color from palette texture (same as StandardMaterial) + if (useSubmeshColors > 0.5) { + // Convert color index to texture UV (128×128 texture) + float texSize = 128.0; + float x = mod(submeshIndex, texSize); + float y = floor(submeshIndex / texSize); + vec2 uv = (vec2(x, y) + 0.5) / texSize; + vColor.xyz = texture2D(submeshColorTexture, uv).rgb; + } else { + // Fallback to vertex color if palette disabled + vColor = color; + } // Blend instance and vertex colors based on the colored attribute. // colored == 1.0 -> use instance color. // colored == 0.0 -> use vertex color. #ifdef USE_INSTANCING - vColor.xyz = colored * instanceColor.xyz + (1.0 - colored) * color.xyz; + vColor.xyz = colored * instanceColor.xyz + (1.0 - colored) * vColor.xyz; #endif // LIGHTING // Pass the model-view position to the fragment shader for lighting calculations. vPosition = vec3(mvPosition) / mvPosition.w; + + // TRANSPARENCY + // Pass opacity uniform to fragment shader + vAlpha = opacity; } `, fragmentShader: /* glsl */ ` @@ -92,12 +138,17 @@ export function createSimpleMaterial () { varying vec3 vPosition; varying vec3 vColor; + // TRANSPARENCY + // Opacity value passed from vertex shader + varying float vAlpha; + void main() { #include #include - // Set the fragment color to the interpolated vertex or instance color. - gl_FragColor = vec4(vColor, 1.0); + // Set the fragment color with variable alpha + // vAlpha comes from the opacity uniform (controlled by material) + gl_FragColor = vec4(vColor, vAlpha); // LIGHTING // Compute a pseudo-normal using screen-space derivatives of the vertex position. @@ -114,3 +165,61 @@ export function createSimpleMaterial () { ` }) } + +/** + * Creates an opaque simple material for fast rendering. + * + * Use this for: + * - Isolation mode with solid objects + * - Performance-critical rendering where transparency isn't needed + * - Default fast material + * + * Features: + * - No transparency (alpha = 1.0) + * - Depth writing enabled (objects occlude what's behind them) + * - Fast pseudo-lighting using screen-space derivatives + * + * @returns {THREE.ShaderMaterial} Opaque shader material + */ +export function createSimpleOpaqueMaterial () { + return createSimpleMaterialBase({ transparent: false }) +} + +/** + * Creates a transparent simple material for fast rendering with alpha blending. + * + * Use this for: + * - Transparent objects in isolation mode + * - Ghost-like rendering effects (when Windows rendering issues aren't a problem) + * - Semi-transparent overlays + * + * Features: + * - Transparency enabled with alpha blending + * - Depth writing disabled (allows proper layering) + * - Configurable opacity + * + * ⚠️ Warning: Transparency on Windows with ghost materials may have rendering issues. + * Test thoroughly in your target environment. + * + * @param opacity Initial opacity (0 = fully transparent, 1 = fully opaque). Default: 0.5 + * @returns {THREE.ShaderMaterial} Transparent shader material + */ +export function createSimpleTransparentMaterial (opacity = 0.5) { + return createSimpleMaterialBase({ transparent: true, opacity }) +} + +/** + * Creates a simple material for isolation mode (legacy export). + * + * This function maintains backward compatibility with existing code. + * Returns an opaque material by default. + * + * For new code, prefer: + * - `createSimpleOpaqueMaterial()` for solid objects + * - `createSimpleTransparentMaterial()` for transparent objects + * + * @returns {THREE.ShaderMaterial} Opaque shader material + */ +export function createSimpleMaterial () { + return createSimpleOpaqueMaterial() +} diff --git a/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts b/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts index 49f1d0452..376a4c6b6 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts @@ -65,6 +65,7 @@ export function getDefaultViewerSettings(): ViewerSettings { } ], materials: { + useFastMaterials: false, standard: { color: new THREE.Color(0xcccccc) }, diff --git a/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts b/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts index f7416237c..2f5bd1d39 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts @@ -196,6 +196,14 @@ export type ViewerSettings = { * Object highlight on click options */ materials: { + /** + * Use fast simple materials instead of standard Lambert materials + * - Enables: Significantly faster rendering (no Lambert lighting calculations) + * - Trade-off: Simpler pseudo-lighting using screen-space derivatives + * - Useful for: Performance-critical scenarios, large models, lower-end hardware + * Default: false + */ + useFastMaterials: boolean /** * Default color of standard material */ From 08fb49686a93f0ecacf35e1135da99731418ed8f Mon Sep 17 00:00:00 2001 From: vim-sroberge Date: Fri, 13 Feb 2026 15:13:06 -0500 Subject: [PATCH 94/94] rename transparency to qualiry --- src/vim-web/react-viewers/panels/isolationPanel.tsx | 12 ++++++------ src/vim-web/react-viewers/state/sharedIsolation.ts | 10 +++++----- src/vim-web/react-viewers/ultra/isolation.ts | 4 ++-- src/vim-web/react-viewers/ultra/viewer.tsx | 2 +- src/vim-web/react-viewers/webgl/isolation.ts | 8 ++++---- src/vim-web/react-viewers/webgl/viewer.tsx | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/vim-web/react-viewers/panels/isolationPanel.tsx b/src/vim-web/react-viewers/panels/isolationPanel.tsx index 1f877b782..a44710359 100644 --- a/src/vim-web/react-viewers/panels/isolationPanel.tsx +++ b/src/vim-web/react-viewers/panels/isolationPanel.tsx @@ -5,10 +5,10 @@ import { GenericPanel, GenericPanelHandle } from "../generic/genericPanel"; export const Ids = { showGhost: "isolationPanel.showGhost", ghostOpacity: "isolationPanel.ghostOpacity", - transparency: "isolationPanel.transparency", + quality: "isolationPanel.quality", } -export const IsolationPanel = forwardRef( +export const IsolationPanel = forwardRef( (props, ref) => { return ( props.transparency, - id: Ids.transparency, - label: "Transparency", - state: props.state.transparency + visible: () => props.quality, + id: Ids.quality, + label: "Quality", + state: props.state.quality }, ]} /> diff --git a/src/vim-web/react-viewers/state/sharedIsolation.ts b/src/vim-web/react-viewers/state/sharedIsolation.ts index f1647bfa8..f00176297 100644 --- a/src/vim-web/react-viewers/state/sharedIsolation.ts +++ b/src/vim-web/react-viewers/state/sharedIsolation.ts @@ -11,7 +11,7 @@ export interface IsolationRef { showPanel: StateRef; showGhost: StateRef; ghostOpacity: StateRef; - transparency: StateRef; + quality: StateRef; showRooms: StateRef; onAutoIsolate: FuncRef; onVisibilityChange: FuncRef; @@ -43,7 +43,7 @@ export interface IsolationAdapter{ getGhostOpacity(): number; setGhostOpacity(opacity: number): void; - enableTransparency(enable: boolean): void; + enableQuality(enable: boolean): void; getShowRooms(): boolean; setShowRooms(show: boolean): void; @@ -57,7 +57,7 @@ export function useSharedIsolation(adapter : IsolationAdapter){ const showRooms = useStateRef(false); const showGhost = useStateRef(false); const ghostOpacity = useStateRef(() => adapter.getGhostOpacity(), true); - const transparency = useStateRef(true); + const quality = useStateRef(false); // Start in fast mode const onAutoIsolate = useFuncRef(() => { if(adapter.hasSelection()){ @@ -89,7 +89,7 @@ export function useSharedIsolation(adapter : IsolationAdapter){ showGhost.useOnChange((v) => adapter.showGhost(v)); showRooms.useOnChange((v) => adapter.setShowRooms(v)); - transparency.useOnChange((v) => adapter.enableTransparency(v)); + quality.useOnChange((v) => adapter.enableQuality(v)); ghostOpacity.useValidate((next, current) => { return next <= 0 ? current : next @@ -106,6 +106,6 @@ export function useSharedIsolation(adapter : IsolationAdapter){ ghostOpacity, onAutoIsolate, onVisibilityChange, - transparency, + quality, } as IsolationRef } \ No newline at end of file diff --git a/src/vim-web/react-viewers/ultra/isolation.ts b/src/vim-web/react-viewers/ultra/isolation.ts index 70c2b1b30..f72105033 100644 --- a/src/vim-web/react-viewers/ultra/isolation.ts +++ b/src/vim-web/react-viewers/ultra/isolation.ts @@ -111,8 +111,8 @@ function createAdapter(viewer: Viewer): IsolationAdapter { } } }, - enableTransparency: (enable: boolean) => { - console.log("enableTransparency not implemented") + enableQuality: (enable: boolean) => { + console.log("enableQuality not implemented") }, getGhostOpacity: () => viewer.renderer.ghostOpacity, diff --git a/src/vim-web/react-viewers/ultra/viewer.tsx b/src/vim-web/react-viewers/ultra/viewer.tsx index bbd018998..ff47dc0f9 100644 --- a/src/vim-web/react-viewers/ultra/viewer.tsx +++ b/src/vim-web/react-viewers/ultra/viewer.tsx @@ -181,7 +181,7 @@ export function Viewer (props: { show={isTrue(settings.value.ui.panelControlBar)} /> - + }}/> diff --git a/src/vim-web/react-viewers/webgl/isolation.ts b/src/vim-web/react-viewers/webgl/isolation.ts index 6b322d64f..9fb7cbed2 100644 --- a/src/vim-web/react-viewers/webgl/isolation.ts +++ b/src/vim-web/react-viewers/webgl/isolation.ts @@ -8,7 +8,7 @@ export function useWebglIsolation(viewer: Core.Webgl.Viewer){ } function createWebglIsolationAdapter(viewer: Core.Webgl.Viewer): IsolationAdapter { - var transparency: boolean = true; + var quality: boolean = false; // Start in fast mode var ghost: boolean = false; var rooms: boolean = false; @@ -84,9 +84,9 @@ function createWebglIsolationAdapter(viewer: Core.Webgl.Viewer): IsolationAdapte } }, - enableTransparency: (enable: boolean) => { - if(transparency !== enable){ - transparency = enable; + enableQuality: (enable: boolean) => { + if(quality !== enable){ + quality = enable; updateMaterials(); }; }, diff --git a/src/vim-web/react-viewers/webgl/viewer.tsx b/src/vim-web/react-viewers/webgl/viewer.tsx index f9986782f..5355ceea5 100644 --- a/src/vim-web/react-viewers/webgl/viewer.tsx +++ b/src/vim-web/react-viewers/webgl/viewer.tsx @@ -250,7 +250,7 @@ export function Viewer (props: { show={isTrue(settings.value.ui.panelControlBar)} /> - +