diff --git a/.claude/docs/INPUT.md b/.claude/docs/INPUT.md new file mode 100644 index 000000000..7a608df67 --- /dev/null +++ b/.claude/docs/INPUT.md @@ -0,0 +1,720 @@ +# 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**: Override key down/up callbacks via `override()` + +**Override API**: + +The `override()` method registers a handler for a key code (or array of codes) on either `'down'` or `'up'` events. The handler receives the previous handler as `original`, enabling chaining. Returns a restore function. + +```typescript +// Override R key press +const restore = viewer.core.inputs.keyboard.override('KeyR', 'down', () => { + console.log('R key pressed') +}) +// Later: restore() + +// Chain with previous handler +viewer.core.inputs.keyboard.override('KeyR', 'up', (original) => { + original?.() + console.log('This runs after the previous handler') +}) + +// Override multiple keys at once +viewer.core.inputs.keyboard.override(['Equal', 'NumpadAdd'], 'up', () => { + viewer.core.inputs.moveSpeed++ +}) +``` + +--- + +## Pointer Mode System + +Two-tier mode management for flexible interaction: + +### 1. pointerMode (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 or application default +Used for: Left-click dragging + +```typescript +viewer.core.inputs.pointerMode = 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 + +**Priority**: `override > pointerMode` + +```typescript +// Listen for mode changes (fires for both pointerMode and pointerOverride changes) +viewer.core.inputs.onPointerModeChanged.subscribe(() => { + console.log('Mode:', viewer.core.inputs.pointerMode) + 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 +const restore = viewer.core.inputs.mouse.override({ + onClick: (original, pos, ctrl) => { + console.log(pos) // THREE.Vector2(0.5, 0.5) = center + original(pos, ctrl) + } +}) +``` + +### 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 // WRONG: 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. It is `@internal` -- consumers interact through `IInputHandler` (the public API on `viewer.inputs`). + +### 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 + resetCamera: () => void + frameCamera: () => void | Promise + + // Interaction + selectAtPointer: (pos: THREE.Vector2, add: boolean) => void | Promise + frameAtPointer: (pos: THREE.Vector2) => void | Promise + zoom: (value: number, screenPos?: THREE.Vector2) => void | Promise + + // Touch + pinchStart: (screenPos: THREE.Vector2) => void | Promise + pinchZoom: (totalRatio: number) => void + + // Selection + clearSelection: () => void + + // Raw events (for custom handling) + keyDown: (keyCode: string) => boolean + keyUp: (keyCode: string) => boolean + pointerDown: (pos: THREE.Vector2, button: number) => void + pointerMove: (pos: THREE.Vector2) => void + pointerUp: (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) +``` + +Note: `rotateSpeed` (LOOK mode) and `orbitSpeed` (ORBIT mode) are internal to `InputHandler` and not exposed on the public `IInputHandler` interface. + +--- + +## 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.lockRotation = new VIM.THREE.Vector2(0, 0) + +// Enable orthographic projection +viewer.camera.orthographic = true + +// Switch to pan mode +viewer.core.inputs.pointerMode = VIM.Core.PointerMode.PAN +``` + +### Custom Tool Mode + +Implement a custom rectangle selection tool using the `override()` pattern: + +```typescript +const inputs = viewer.core.inputs +const originalMode = inputs.pointerMode + +// Enter tool mode +inputs.pointerMode = VIM.Core.PointerMode.RECT +const restoreMouse = inputs.mouse.override({ + onClick: (original, pos, ctrl) => { + // Custom rectangle selection logic + startRectangle(pos) + } +}) + +// Exit tool mode +const exitTool = () => { + inputs.pointerMode = originalMode + restoreMouse() +} +``` + +### Multi-Key Bindings + +Register the same action for multiple keys: + +```typescript +// Speed controls +const restoreSpeedUp = viewer.core.inputs.keyboard.override( + ['Equal', 'NumpadAdd'], + 'up', + () => viewer.core.inputs.moveSpeed++ +) + +const restoreSpeedDown = viewer.core.inputs.keyboard.override( + ['Minus', 'NumpadSubtract'], + 'up', + () => viewer.core.inputs.moveSpeed-- +) +``` + +### Custom Touch Gestures + +Override default pinch behavior: + +```typescript +const restoreTouch = viewer.core.inputs.touch.override({ + onPinchOrSpread: (original, ratio) => { + // Custom zoom logic + const zoomAmount = Math.log2(ratio) * 0.5 + viewer.camera.snap().zoom(1 + zoomAmount) + }, + onDoubleTap: (original, pos) => { + // Custom double-tap action + original(pos) // or replace entirely + } +}) +// Later: restoreTouch() +``` + +### Disable Specific Inputs + +```typescript +// Disable keyboard +viewer.core.inputs.keyboard.active = false + +// Disable mouse +viewer.core.inputs.mouse.active = false + +// Disable touch +viewer.core.inputs.touch.active = false + +// Re-enable +viewer.core.inputs.keyboard.active = true +viewer.core.inputs.mouse.active = true +viewer.core.inputs.touch.active = true +``` + +--- + +## Extension Points + +### Custom Key Handlers + +```typescript +// Override a key (returns restore function) +const restore = viewer.core.inputs.keyboard.override('KeyR', 'down', () => { + console.log('R key pressed') +}) + +// Chain with the previous handler +viewer.core.inputs.keyboard.override('KeyR', 'down', (original) => { + original?.() + console.log('This runs after the previous handler') +}) + +// Run before the previous handler +viewer.core.inputs.keyboard.override('KeyR', 'down', (original) => { + console.log('This runs before the previous handler') + original?.() +}) +``` + +### Custom Mouse Callbacks + +All callbacks receive canvas-relative positions [0-1]. Use `override()` which returns a restore function: + +```typescript +const restore = viewer.core.inputs.mouse.override({ + // Override click behavior + onClick: (original, pos, ctrl) => { + if (ctrl) { + // Custom Ctrl+Click action + } else { + original(pos, ctrl) // Fall through to default + } + }, + + // Override drag behavior + onDrag: (original, delta, button) => { + if (button === 0) { + console.log('Drag delta:', delta) + } + original(delta, button) + }, + + // Override pointer down/up + onPointerDown: (original, pos, button) => { + console.log('Pointer down:', button, 'at', pos) + original(pos, button) + }, + + onPointerUp: (original, pos, button) => { + console.log('Pointer up:', button, 'at', pos) + original(pos, button) + } +}) + +// Later: restore() +``` + +### Custom Pointer Modes + +Use pointer modes with mouse override for custom tools: + +```typescript +// Save current mode +const originalMode = viewer.core.inputs.pointerMode + +// Enter custom mode +viewer.core.inputs.pointerMode = VIM.Core.PointerMode.RECT + +// Override drag behavior for this mode +const restoreDrag = viewer.core.inputs.mouse.override({ + onDrag: (original, delta, button) => { + if (viewer.core.inputs.pointerMode === VIM.Core.PointerMode.RECT) { + updateRectangle(delta) + } else { + original(delta, button) + } + } +}) + +// Exit custom mode +viewer.core.inputs.pointerMode = originalMode +restoreDrag() +``` + +--- + +## Debugging + +### Input State Inspection + +```typescript +const inputs = viewer.core.inputs + +// Check current mode +console.log('Active mode:', inputs.pointerMode) +console.log('Override:', inputs.pointerOverride) + +// Check speeds +console.log('Move speed:', inputs.moveSpeed) +console.log('Scroll speed:', inputs.scrollSpeed) +``` + +### Event Logging + +```typescript +// Log all pointer mode changes +viewer.core.inputs.onPointerModeChanged.subscribe(() => { + console.log('Mode changed:', viewer.core.inputs.pointerMode) +}) + +// Log all clicks via override +const restore = viewer.core.inputs.mouse.override({ + onClick: (original, pos, ctrl) => { + console.log('Click at:', pos, 'Ctrl:', ctrl) + original(pos, ctrl) + } +}) +``` + +--- + +## Constants Reference + +From `inputConstants.ts`: + +```typescript +// Click detection +CLICK_MOVEMENT_THRESHOLD = 0.003 // Canvas-relative units [0-1] + +// Double-click detection +DOUBLE_CLICK_DISTANCE_THRESHOLD = 0.005 // Canvas-relative units [0-1] (~5px on 1000px canvas) +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 // WRONG: Stores reference + ``` + +2. **Never store references to callback vectors** + ```typescript + // In an override handler: + onClick: (original, pos) => { + this.clickPos = pos.clone() // Clone if storing + this.clickPos = pos // WRONG: 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.pointerMode + + // Change mode + viewer.core.inputs.pointerMode = VIM.Core.PointerMode.PAN + + // Restore on exit + viewer.core.inputs.pointerMode = original + ``` + +5. **Always restore overrides** + ```typescript + // Override returns a restore function + const restore = viewer.core.inputs.mouse.override({ + onClick: (original, pos, ctrl) => { /* custom */ } + }) + + // Restore on dispose + restore() + ``` diff --git a/.claude/docs/RENDERING_OPTIMIZATIONS.md b/.claude/docs/RENDERING_OPTIMIZATIONS.md new file mode 100644 index 000000000..f1a821560 --- /dev/null +++ b/.claude/docs/RENDERING_OPTIMIZATIONS.md @@ -0,0 +1,203 @@ +# WebGL Shader Materials & Rendering Patterns + +This document describes the shader material architecture and rendering patterns used in the WebGL viewer. All materials use GLSL ES 3.0 (WebGL 2). + +## Material Architecture + +The `Materials` singleton (`materials.ts`) owns and manages all material instances. It exposes a public `IMaterials` interface for property configuration and keeps system materials (mask, outline, merge) internal. + +### Material Inventory + +| Material | File | Purpose | GLSL Version | +|----------|------|---------|--------------| +| **StandardMaterial** | `standardMaterial.ts` | Default opaque/transparent rendering. Patches Three.js Lambert shader via `onBeforeCompile` to inject palette coloring, visibility, and section strokes. | GLSL1 (Three.js managed) | +| **ModelMaterial** | `modelMaterial.ts` | Fast rendering mode. Custom shader using screen-space derivative normals and pre-normalized lighting. | GLSL3 | +| **GhostMaterial** | `ghostMaterial.ts` | Transparent fill for hidden/ghosted elements in isolation mode. | GLSL3 | +| **PickingMaterial** | `pickingMaterial.ts` | GPU object picking. Outputs packed element ID, depth, and surface normal to Float32 render target. | GLSL3 | +| **MaskMaterial** | `maskMaterial.ts` | Selection mask pass. Writes depth only for selected elements; non-selected vertices are clipped. | GLSL3 | +| **OutlineMaterial** | `outlineMaterial.ts` | Post-process edge detection on depth buffer. Outputs outline intensity to RedFormat texture. | GLSL3 | +| **MergeMaterial** | `mergeMaterial.ts` | Final compositing pass. Blends scene texture with outline texture using configurable color. | GLSL3 | +| **TransferMaterial** | `transferMaterial.ts` | Simple texture passthrough (blit). | GLSL3 | + +### MaterialSet + +`MaterialSet` (`materialSet.ts`) groups materials by role: `opaque`, `transparent`, and `hidden` (ghost). The `applyMaterial()` helper in `materials.ts` resolves a `MaterialSet` into the correct `THREE.Material` or `[visible, hidden]` array for a given mesh based on its `userData.transparent` flag. + +### Color Palette System + +All scene materials (StandardMaterial, ModelMaterial) use a shared color palette texture for submesh coloring. The `Materials` singleton owns a single 128x128 RGBA `DataTexture` (16,384 colors max) and distributes it to all materials via `setSubmeshColorTexture()`. + +The palette is built by `colorPalette.ts`: +- Extracts unique colors from submesh material data +- If unique colors exceed 16,384, applies uniform quantization (25 levels per channel = 15,625 max) +- Packs into a `Float32Array` for texture upload + +Shaders look up colors using `texelFetch` with integer coordinates derived from a per-vertex `submeshIndex` attribute: + +```glsl +int x = int(submeshIndex) % 128; +int y = int(submeshIndex) / 128; +vColor = texelFetch(submeshColorTexture, ivec2(x, y), 0).rgb; +``` + +Instance color overrides are blended using the `colored` attribute (1 = instance color, 0 = palette color): + +```glsl +#ifdef USE_INSTANCING + vColor = colored * instanceColor + (1.0 - colored) * vColor; +#endif +``` + +--- + +## Shader Patterns + +### Pre-Normalized Light Direction (ModelMaterial) + +The light direction is a compile-time constant, avoiding per-fragment `normalize()`: + +```glsl +// (sqrt(2), sqrt(3), sqrt(5)) normalized: magnitude = sqrt(10) +const vec3 LIGHT_DIR = vec3(0.447214, 0.547723, 0.707107); +float light = dot(normal, LIGHT_DIR); +light = 0.5 + (light * 0.5); // Remap to [0.5, 1.0] +``` + +### Pre-Divided Opacity (GhostMaterial) + +The ghost opacity uniform stores the final shader value directly (`7/255 = 0.0275`), so the fragment shader uses it as-is without per-fragment division: + +```glsl +fragColor = vec4(fillColor, opacity); +``` + +The `GhostMaterial` class getter/setter expose the raw value without conversion. + +### Vertex Shader Early Culling + +All visibility-aware materials (Ghost, Model, Mask, Picking) use the same pattern to cull invisible geometry in the vertex shader by placing vertices behind the near plane: + +```glsl +if (ignore > 0.0) { + gl_Position = vec4(0.0, 0.0, -2.0, 1.0); + return; +} +``` + +This is faster than fragment `discard` because no fragments are generated for clipped triangles. + +### Static Temp Vector Reuse (PickingMaterial) + +The `PickingMaterial` class uses a static `THREE.Vector3` for camera direction updates, avoiding per-frame allocations: + +```typescript +private static _tempDir = new THREE.Vector3() + +updateCamera(camera: THREE.Camera): void { + camera.getWorldDirection(PickingMaterial._tempDir) + this.three.uniforms.uCameraPos.value.copy(camera.position) + this.three.uniforms.uCameraDir.value.copy(PickingMaterial._tempDir) +} +``` + +### Picking Output Format + +The picking material encodes four values into a Float32 RGBA render target: + +| Channel | Value | Encoding | +|---------|-------|----------| +| R | Element ID | `uintBitsToFloat(packedId)` where `packedId = (vimIndex << 24) \| elementIndex` | +| G | Depth | Distance along camera direction (0 = miss) | +| B | Normal X | Screen-space derivative normal | +| A | Normal Y | Screen-space derivative normal | + +Normal Z is reconstructed as `sqrt(1 - x^2 - y^2)`, always positive since the normal faces the camera. + +### Section Stroke Rendering (StandardMaterial) + +The standard material renders colored strokes where geometry intersects clipping planes. The stroke width scales with fragment depth using a configurable falloff exponent: + +```glsl +float thick = pow(vFragDepth, sectionStrokeFalloff) * sectionStrokeWidth; +``` + +Perpendicular surfaces are excluded by comparing the surface normal against the clipping plane normal. + +--- + +## Shader Optimization Principles + +### General Rules + +1. **Move computations out of shaders** whenever possible: + - Constants: Pre-compute in JavaScript or as shader `const` + - Per-frame: Compute in uniforms + - Per-vertex: Keep in vertex shader + - Per-fragment: Only when necessary + +2. **Avoid per-fragment operations:** + - `normalize()` on constants + - Divisions (use pre-multiplied reciprocals) + - Expensive math functions (`sqrt`, `pow`, `sin`, `cos`) + - Prefer simple arithmetic (`+`, `-`, `*`) + - Prefer dot products, cross products + - Texture lookups are GPU-cached and relatively cheap + +3. **Memory access patterns:** + - `texelFetch()` for indexed access (faster than `texture()` when no filtering needed) + - `uniform` reads are GPU-cached + - `in` (varying) interpolation cost depends on geometry complexity + +4. **Branching:** + - Early returns in vertex shader skip all subsequent work + - Fragment shader branches may execute both paths on GPU (warp divergence) + +### Relative Cost of Operations + +| Operation | Cost | Example | +|-----------|------|---------| +| Uniform read | 1x | `uniform float value` | +| Texture fetch | 2-4x | `texture(sampler, uv)` | +| Addition/Subtraction | 1x | `a + b` | +| Multiplication | 1x | `a * b` | +| Division | 3-5x | `a / b` | +| `normalize()` | 10-15x | `sqrt` + 3 divides | +| `sin()`, `cos()` | 8-12x | Approximated in hardware | + +--- + +## GLSL3 Syntax Reference + +All custom shader materials use `glslVersion: THREE.GLSL3`. The StandardMaterial uses Three.js managed GLSL1 (via `onBeforeCompile` patching). + +| GLSL1 | GLSL3 | +|-------|-------| +| `attribute` | `in` (vertex shader) | +| `varying` | `out` (vertex), `in` (fragment) | +| `gl_FragColor` | `out vec4 fragColor` | +| `texture2D()` | `texture()` | +| N/A | `texelFetch()` for indexed access | + +--- + +## References + +### Material Files + +- `src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/modelMaterial.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/ghostMaterial.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/maskMaterial.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/outlineMaterial.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/mergeMaterial.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/transferMaterial.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/materials.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/materialSet.ts` +- `src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts` + +### Related Documentation + +- [CLAUDE.md](../../CLAUDE.md) - Main project documentation +- [INPUT.md](./INPUT.md) - Input system architecture +- [optimization.md](./optimization.md) - Loading pipeline performance diff --git a/.claude/docs/optimization.md b/.claude/docs/optimization.md new file mode 100644 index 000000000..3ddfa0224 --- /dev/null +++ b/.claude/docs/optimization.md @@ -0,0 +1,143 @@ +# VIM Loading Performance + +How the WebGL loading pipeline works and how to profile it. + +## Loading Phases + +VIM file loading consists of four sequential phases: + +1. **Network/Parsing** - Fetch and parse BFast container (G3d geometry, VimDocument, ElementMapping) +2. **Geometry Building** - Create Three.js meshes from G3d data (primary optimization target) +3. **GPU Upload** - Transfer geometry buffers to GPU +4. **Rendering** - First frame render + +## Mesh Building Pipeline + +``` +VimMeshFactory.add(G3dSubset) + |-- Split by instance count: <=5 instances -> merged, >5 -> instanced + |-- Merged path (InsertableMeshFactory) + | |-- G3dSubset.chunks(16M indices) -- chunk large meshes + | |-- Create InsertableMesh per chunk + | |-- insertFromG3d() -- bake matrices, build geometry + | +-- scene.addMesh() -- register submeshes + +-- Instanced path (InstancedMeshFactory) + |-- Create THREE.InstancedMesh per unique geometry + |-- setMatrices() -- GPU instancing matrices + +-- scene.addMesh() -- register submeshes +``` + +**Chunk Size**: 16M indices (not vertices) per merged mesh. This threshold was chosen because GPU picking eliminates the need for CPU raycast traversal, removing the constraint that kept chunk sizes small. + +## Current Performance Patterns + +### Lazy Element3D Creation + +Element3D objects are **not** created during mesh loading. When `scene.addMesh()` is called, it only builds an `instance -> submesh[]` map. Element3D objects are created lazily on first access via `vim.getElement()` or `vim.getElementFromIndex()`. + +This avoids constructing thousands of full Element3D objects during the loading hot path when most will never be accessed. + +**Key file**: `src/vim-web/core-viewers/webgl/loader/scene.ts` -- `registerSubmesh()` only populates `_instanceToMeshes`. + +### Buffer Reuse in Hot Loops + +In `InsertableGeometry.insertFromG3d()`, which iterates over every vertex and index in merged meshes: + +- **Matrix buffer**: A single `Float32Array(16)` is allocated once outside the per-instance loop, then reused for each instance's transform matrix. This avoids per-instance allocation in a loop that runs thousands of times. +- **Color index hoisting**: The `submeshColor` lookup (`g3d.submeshColor[sub]`) is hoisted out of the inner per-index loop and computed once per submesh. +- **Direct array access**: Typed array references (`indices`, `submeshIndices`) are assigned once outside loops to avoid repeated property lookups. + +**Key file**: `src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts` + +### Color Palette System + +The color palette optimization reduces GPU memory by replacing per-vertex color attributes with a texture-based lookup. It is **always enabled**. + +Two components: +1. **`submeshColor: Uint16Array`** - Always present. Maps each submesh index to a color palette index. +2. **`colorPalette: Float32Array | undefined`** - A flat RGB array of unique colors, used as a texture. Set to `undefined` only if the model exceeds 16,384 unique colors (128x128 texture limit). + +If a model has too many unique colors, quantization is applied (25 levels per channel, yielding up to 15,625 distinct colors) to bring the palette within limits. If quantization still exceeds the limit, the palette is disabled (`undefined`) and the shader falls back to per-vertex colors. + +**Key files**: +- `src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts` - Palette building with quantization +- `src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts` - `MappedG3d` type definition +- `src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts` - `submeshColor` lookup in geometry building + +## How to Profile + +### Timing Instrumentation Pattern + +Add cumulative timing to identify hotspots. This pattern collects timing across many calls (e.g., thousands of mesh builds) rather than measuring a single call: + +```typescript +class SomeFactory { + private static _cumulativeTiming = { + phase1: 0, + phase2: 0, + calls: 0 + } + + someMethod() { + const t0 = performance.now() + // ... phase 1 work + const t1 = performance.now() + SomeFactory._cumulativeTiming.phase1 += t1 - t0 + + // ... phase 2 work + const t2 = performance.now() + SomeFactory._cumulativeTiming.phase2 += t2 - t1 + + SomeFactory._cumulativeTiming.calls++ + } + + static logAndResetTiming(label: string) { + const t = SomeFactory._cumulativeTiming + console.log(`[SomeFactory] ${label} breakdown (${t.calls} calls):`) + console.log(` Phase 1: ${t.phase1.toFixed(2)}ms`) + console.log(` Phase 2: ${t.phase2.toFixed(2)}ms`) + // Reset for next batch + t.phase1 = 0 + t.phase2 = 0 + t.calls = 0 + } +} +``` + +### Profiling Steps + +1. **Add timing instrumentation** using the cumulative pattern above +2. **Look for gaps** - If outer timing is much larger than the sum of inner phases, the overhead between phases is the real bottleneck +3. **Use Chrome DevTools** - Performance tab, look for long tasks and GC pauses +4. **Test on real models** - Performance characteristics vary significantly by model size and complexity +5. **Compare before/after** - Always measure the impact of changes + +## Optimization Principles + +### What to Optimize + +1. **Eliminate unnecessary work** - Deferring or skipping work entirely yields the largest gains (e.g., lazy Element3D creation) +2. **Hot loops** - Code executed millions of times (vertex/index loops in geometry building) +3. **Allocations in loops** - Reuse buffers, minimize GC pressure +4. **Cache locality** - Copy data to local arrays before tight loops + +### What NOT to Optimize (Diminishing Returns) + +1. **Three.js internals** - `BufferGeometry` creation, `computeBoundingBox()` are already well-optimized +2. **One-time operations** - Setup code executed once per mesh type +3. **Already-fast code** - Operations under a few milliseconds with no clear improvement path +4. **Micro-optimizations without measurement** - Always measure before and after + +### Red Flags to Investigate + +- **Outer timing >> inner timing** - Indicates hidden overhead between instrumented phases +- **Per-item overhead > 0.1ms** - For operations on thousands of items, even small overhead compounds +- **Unexpected allocations** - Check Chrome DevTools Performance tab for GC pauses during geometry building + +## References + +- **Loading Pipeline**: [CLAUDE.md -- Loading Pipeline](../../CLAUDE.md) +- **Mesh Building**: `src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts` +- **Scene Management**: `src/vim-web/core-viewers/webgl/loader/scene.ts` +- **Element3D**: `src/vim-web/core-viewers/webgl/loader/element3d.ts` +- **Color Palette**: `src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts` diff --git a/.claude/skills/auto-refactor/skill.md b/.claude/skills/auto-refactor/skill.md new file mode 100644 index 000000000..3a92b7100 --- /dev/null +++ b/.claude/skills/auto-refactor/skill.md @@ -0,0 +1,370 @@ +--- +name: auto-refactor +description: Autonomous modernization loop. Refactors code to modern React/TypeScript patterns, tightens APIs, improves architecture, and cleans up. Runs without human intervention. Pass a directory or file glob as argument. +--- + +# Autonomous Refactor & Modernize Loop + +Actively improve and modernize code — not just bug fixes, but structural improvements. Runs autonomously without human intervention. + +**Argument**: Directory or file pattern to refactor (default: `src/vim-web/react-viewers/`) + +## Rules — READ THESE FIRST + +1. **NEVER use tools that require user approval** — no `Bash` subagent type, no destructive git commands +2. **Use `general-purpose` subagent type** for all Task agents that edit code +3. **Always run `npm run build`** after each round to verify nothing broke +4. **Stop after 4 hours** maximum +5. **Stop early** if a round finds nothing worth changing +6. **One theme per round** — don't mix React modernization with API tightening in the same round +7. **Read before edit** — always read a file before modifying it +8. **Be aggressive** — this is REFACTORING, not linting. Structural changes, pattern rewrites, and architectural improvements are the goal. Removing a commented-out line is not refactoring. Rewriting a setState-in-render to useMemo IS refactoring. Extracting a 400-line component into focused hooks IS refactoring. Replacing a manual sync pattern with derived state IS refactoring. + +## Aggressiveness Guidelines + +The user expects DEEP structural improvements, not surface-level cleanup. Prioritize by impact: + +**HIGH IMPACT (always do these):** +- Rewrite anti-patterns: setState in render body → useMemo/derived state +- Replace manual state sync (two useOnChange keeping states in sync) → computed/derived values +- Extract custom hooks from components with multiple independent state+effect bundles +- Fix incorrect return types (function returns null but type says T[]) +- Restructure component files > 300 lines into hook + thin render layer +- Replace callback patterns with proper React idioms + +**MEDIUM IMPACT (do in later rounds):** +- Extract inline objects to constants +- Fix hook naming violations +- Add cleanup to useEffect +- Replace `as` casts with `satisfies` +- Remove `any` types + +**LOW IMPACT (do last or skip):** +- Remove commented-out code +- Remove unused imports +- Remove unused parameters + +Do NOT spend an entire round on low-impact changes. Combine them with medium-impact work or handle them as quick fixes between structural rounds. + +## Loop Structure + +``` +Round N: + 1. SCAN — Read files, identify improvement opportunities + 2. PLAN — Pick ONE theme for this round, list all changes + 3. APPLY — Make changes (parallel agents for independent files) + 4. BUILD — Run `npm run build` + 5. REVIEW — Spawn independent review agent (see below) + 6. ADAPT — Use review score + feedback to decide next round's focus + 7. DECIDE — If score < 85 and improvements remain, go to Round N+1 +``` + +### Independent Review Agent (Step 5) + +After each round's build passes, spawn a **separate** `general-purpose` Task agent to evaluate the current state of the modified files. This agent has NO context of what you just changed — it evaluates the code fresh. + +**Review agent prompt template:** +``` +You are a code quality reviewer. Read the following files and score them 1-100 +on overall code quality. You have NO context about recent changes — evaluate +the code as-is. + +Files to review: [list of files modified this round + any related files] + +Score on these dimensions (each 1-100): +1. **Structural quality** — Are components focused? Are hooks extracted? + Is derived state computed, not synced? Are anti-patterns present? +2. **Correctness** — Are effects cleaned up? Are types honest? + Are race conditions guarded? Are resources released? +3. **API design** — Are interfaces tight? Are internals hidden? + Is the public surface minimal and consistent? +4. **Code hygiene** — Dead code? Unused imports? `any` types? + Commented-out code? Inconsistent patterns? + +For each dimension: +- Give the score +- List the TOP 3 most impactful issues still remaining (with file:line) +- Suggest the specific change that would improve the score most + +Final output: +- Overall score: (weighted average: structural 40%, correctness 30%, API 20%, hygiene 10%) +- Top 3 issues to fix next (ranked by impact) +- What theme should the next round focus on? +``` + +**How to use the review:** +- If overall score >= 85 and no structural issues remain → stop, the code is clean +- If score < 85 → the review's "top 3 issues" and "next theme" recommendation + OVERRIDE the default theme order. Fix what the reviewer says is worst. +- Track scores across rounds. If a round doesn't improve the score by at least 5 points, + you're making low-impact changes — switch to a higher-impact theme or stop. + +## Round Themes (in priority order) + +Work through these themes in order. Each round picks ONE theme and applies it across all relevant files. **Themes 1-4 are structural and HIGH PRIORITY.** Themes 5-8 are mechanical and can be combined or done quickly. + +--- + +### Theme 1: Structural React Rewrites + +**Goal**: Fix anti-patterns that affect correctness and architecture. These are the meaty changes. + +**setState in render body → derived state:** +```typescript +// BEFORE: setState during render causes double-render +if (!ArrayEquals(props.objects, objects)) { + setObjects(props.objects) // ❌ setState in render body + setExpandedItems([...new Set()]) // ❌ multiple setStates in render +} + +// AFTER: derive from props, no setState needed +const expandedItems = useMemo(() => computeExpanded(props.objects, tree), [props.objects, tree]) +``` + +**Manual state sync → computed values:** +```typescript +// BEFORE: two states kept in sync via onChange handlers +const allElements = useStateRef([]) +const filteredElements = useStateRef([]) +const filter = useStateRef('') +filter.useOnChange(() => applyFilter()) +allElements.useOnChange(() => applyFilter()) + +// AFTER: filteredElements is derived, not stored +const filteredElements = useMemo(() => filterElements(allElements, filter), [allElements, filter]) +// Or with StateRef pattern: +const filteredElements = allElements.useMemo((all) => filterElements(all, filter.get()), [filter.get()]) +``` + +**Incorrect return types → fix the contract:** +```typescript +// BEFORE: lies to the type system +function getBody(): Promise { + if (!data) return null // ❌ null is not Section[] +} +// AFTER: honest type +function getBody(): Promise { + if (!data) return null // ✅ +} +``` + +**Extract hooks from bloated components:** +```typescript +// BEFORE: 400-line component with 5 independent state+effect bundles +function BigComponent() { + const [a, setA] = useState() + useEffect(() => { /* concern A */ }, [a]) + const [b, setB] = useState() + useEffect(() => { /* concern B */ }, [b]) + // ... 300 more lines +} + +// AFTER: thin component + focused hooks +function useConcernA() { ... } +function useConcernB() { ... } +function BigComponent() { + const a = useConcernA() + const b = useConcernB() + return
...
+} +``` + +--- + +### Theme 2: Subscription & Resource Cleanup + +**Goal**: Every resource acquired in a component is properly released. + +**Pattern — subscription cleanup:** +```typescript +// ✅ Modern pattern +useEffect(() => { + const unsub = signal.onChange.subscribe(handler) + return unsub // or return () => { unsub1(); unsub2() } +}, []) +``` + +**Checklist:** +- Every `.subscribe()` and `.sub()` returns its unsubscribe function from useEffect +- `ResizeObserver` → `.disconnect()` in cleanup +- `setTimeout` / `setInterval` → `clearTimeout` / `clearInterval` in cleanup +- `addEventListener` → `removeEventListener` in cleanup +- `MutationObserver` → `.disconnect()` in cleanup +- Polling patterns → replace with event listeners where possible (`fullscreenchange`, `resize`, etc.) + +--- + +### Theme 3: TypeScript Strictness + +**Goal**: Reduce `any` usage, tighten types, improve type inference. + +| Before | After | Why | +|--------|-------|-----| +| `any` type | Proper type or `unknown` | Type safety | +| `as Type` where inferred | Remove assertion | Redundant | +| `object` type | Specific interface | Better autocomplete | +| `Function` type | `() => void` or specific signature | Callable type safety | +| Optional chain where value is always present | Remove `?` | Documents certainty | +| `!` non-null assertions | Proper null check or redesign | Safer | +| `enum` for simple unions | `type Foo = 'a' \| 'b'` | Smaller bundle, better inference | +| `interface` with single implementation | Consider removing if only used internally | Less indirection | + +--- + +### Theme 4: API Surface Tightening + +**Goal**: Public APIs expose only what consumers need. + +**Process per class/module:** +1. Read the class — list ALL public members +2. Categorize: user-facing vs framework-managed vs internal +3. Create/update interface with only user-facing members +4. Update public getters to return interface type +5. Change `@internal readonly` to `private` +6. Verify `.d.ts` output + +**Patterns:** +```typescript +// Gizmo pattern — interface + private concrete +export interface IGizmoFoo { + enabled: boolean + doThing(): void +} +// Getter returns interface +get foo(): IGizmoFoo { return this._foo } + +// Delegation pattern — hide adapter +export interface FooApi { + doThing(): void // ✅ delegates internally + // adapter: Ref // ❌ never expose adapter +} + +// Private over @internal +private readonly _renderer: Renderer // ✅ hidden from .d.ts +/** @internal */ readonly _r: Renderer // ❌ visible in .d.ts +``` + +--- + +### Theme 5: Import & Module Organization + +**Goal**: Clean import graph, proper barrel usage, no circular dependencies. + +**Rules:** +- **React layer** (`react-viewers/`): Import through barrel files (`import * as Core from '../../core-viewers'`) +- **Core layer** (`core-viewers/`): Import directly from source files, NEVER through barrels +- No cross-viewer imports (ultra importing from `../webgl` is wrong) +- Shared types go in `state/` or `helpers/`, not in viewer-specific directories +- Remove unused imports +- Sort/group imports: external → core → relative + +--- + +### Theme 6: Component Decomposition + +**Goal**: Break large components into focused, reusable pieces. + +**Signals to decompose:** +- Component file > 300 lines +- Multiple `useState` + `useEffect` blocks managing independent concerns +- Deeply nested JSX (>4 levels) +- Multiple responsibilities in one component + +**How to decompose:** +- Extract custom hooks for state + effect bundles (`useIsolation`, `useSectionBox`) +- Extract sub-components for repeated JSX patterns +- Move business logic to hooks, keep components as thin render layers + +--- + +### Theme 7: Performance Patterns + +**Goal**: Apply performance best practices without premature optimization. + +| Before | After | Why | +|--------|-------|-----| +| `new Vector3()` in render/frame loop | Static reusable vector | GC pressure | +| Inline `style={{}}` in JSX | Module-level constant or `useMemo` | Re-render stability | +| Re-creating objects every render | `useMemo` with proper deps | Reference stability | +| Large component re-rendering for small changes | `React.memo` on leaf components | Reduce re-renders | +| Expensive computation in render | `useMemo` | Avoid redundant work | + +**Do NOT:** +- Add `React.memo` everywhere — only where profiling shows benefit +- Add `useCallback` to every handler — only when passed as props to memoized children +- Over-memoize — the cure can be worse than the disease + +--- + +### Theme 8: Dead Code & Cruft Removal + +**Goal**: Clean slate. Remove everything that isn't earning its keep. + +- Unused imports, functions, variables, types +- `console.log` statements (keep `console.error` in catch blocks) +- Commented-out code (git has history) +- `eslint-disable` comments hiding dead code +- Empty files with no meaningful content +- `var` declarations → `const` or `let` +- Redundant type assertions +- Backwards-compatibility shims nobody uses + +--- + +## How to Apply Changes + +### Parallel Agents +Spawn multiple `general-purpose` Task agents for independent file groups: + +``` +Agent 1: Modernize hooks in files A, B, C +Agent 2: Modernize hooks in files D, E, F +``` + +Each agent prompt MUST include: +- Exact file paths +- The theme and what to look for +- Specific examples of before/after patterns +- Instruction to Read before Edit + +### Build Verification +After ALL changes in a round: +```bash +npm run build +``` +If build fails → fix → rebuild. Never proceed with broken build. + +## Output Format + +After each round: + +``` +## Round N: [Theme Name] +- Files modified: [list] +- Build: PASS/FAIL +- Notable changes: + - file.ts: description of change + - file.ts: description of change + +### Review Score +- Structural: XX/100 +- Correctness: XX/100 +- API design: XX/100 +- Hygiene: XX/100 +- **Overall: XX/100** (delta from previous: +/-N) +- Top issues remaining: [from reviewer] +- Next round focus: [from reviewer] +``` + +Final summary: + +``` +## Refactoring Complete +- Total rounds: N +- Score progression: [R1: XX → R2: XX → R3: XX → ...] +- Final score: XX/100 +- Themes applied: [list] +- Total files modified: X +- Build status: PASS +- Remaining opportunities: [anything the reviewer still flagged] +``` diff --git a/.claude/skills/auto-review/SKILL.md b/.claude/skills/auto-review/SKILL.md new file mode 100644 index 000000000..b41cbd03a --- /dev/null +++ b/.claude/skills/auto-review/SKILL.md @@ -0,0 +1,120 @@ +--- +name: auto-review +description: Autonomous review-fix-verify loop. Scans code for issues, fixes them, builds, and repeats until clean. Runs without human intervention. Pass a directory or file glob as argument. +--- + +# Autonomous Review-Fix-Verify Loop + +Run an autonomous loop that reviews code, fixes issues, verifies the build, and repeats until no issues remain. No human intervention required. + +**Argument**: Directory or file pattern to review (default: `src/vim-web/react-viewers/`) + +## Rules — READ THESE FIRST + +1. **NEVER use tools that require user approval** — no `Bash` subagent type, no destructive git commands +2. **Use `general-purpose` subagent type** for all Task agents that edit code +3. **Always run `npm run build`** after fixes to verify nothing broke +4. **Stop after 8 rounds** maximum to prevent infinite loops +5. **Stop early** if a round finds 0 issues +6. **Track everything** — round number, issues found, issues fixed, build status + +## Loop Structure + +``` +Round N: + 1. REVIEW — Read files, identify issues using the checklists below + 2. REPORT — List all issues with file:line, category, severity, fix + 3. FIX — Apply fixes (spawn parallel agents for independent fixes) + 4. BUILD — Run `npm run build` + 5. DECIDE — If build fails, fix build errors and rebuild + If no issues were found, STOP + Otherwise, go to Round N+1 +``` + +## Review Checklist + +Scan for these categories (in priority order): + +### High Priority +- **Subscription leaks**: `.subscribe()` / `.sub()` in useEffect without cleanup return +- **Resource leaks**: ResizeObserver, setTimeout, addEventListener without cleanup +- **Bugs**: Inverted conditions, copy-paste errors, double assignments, stale closures +- **Rules of Hooks**: Functions calling hooks without `use` prefix + +### Medium Priority +- **StateRef misuse**: Calling `useOnChange()` on a `StateRef` typed variable (only exists on concrete `useStateRef()` return) +- **useEffect without deps**: State updates that should use `onChange.subscribe` pattern +- **Import discipline**: React layer must use barrels; core layer must use direct imports +- **Leaked internals**: `@internal readonly` fields that should be `private` + +### Low Priority +- **Dead code**: Unused imports, functions, variables +- **console.log**: Remove from production code (`console.error` in catch blocks is OK) +- **Commented-out code**: Delete entirely (git has history) +- **eslint-disable comments**: Remove along with the dead code they hide + +## How to Fix + +### Parallel Agents +Spawn multiple `general-purpose` Task agents in parallel for independent fixes: + +``` +Agent 1: Fix subscription leaks in files A, B, C +Agent 2: Fix dead code in files D, E, F +Agent 3: Fix copy-paste bugs in files G, H +``` + +Each agent prompt should include: +- The exact file paths to modify +- The exact issue description and line numbers +- The exact fix to apply +- Instruction to use Read tool before Edit tool + +### Direct Fixes +For simple 1-2 line fixes, edit directly without spawning agents. + +### Build Verification +After ALL fixes in a round are applied: +```bash +npm run build +``` +If build fails, read the error, fix it, rebuild. Do NOT move to the next round with a broken build. + +## Output Format + +After each round, output a summary: + +``` +## Round N Summary +- Issues found: X +- Issues fixed: Y +- Build: PASS/FAIL +- Categories: [list of issue categories found] +- Files modified: [list] +``` + +After the final round: + +``` +## Final Report +- Total rounds: N +- Total issues fixed: X +- All files modified: [list] +- Build status: PASS +``` + +## Known Patterns to Watch For + +These are real bugs found in this codebase — check for similar patterns: + +| Pattern | Example | Fix | +|---------|---------|-----| +| Missing subscription cleanup | `useEffect(() => { x.onChange.subscribe(fn) }, [])` | `return x.onChange.subscribe(fn)` | +| Stale closure in useState | `setRefresh(!refresh)` | `setRefresh(prev => !prev)` | +| Copy-paste field bug | `value: info.familyName` on familyTypeName row | Use correct field name | +| Inverted condition | `enabled: vis === 'onlySelection'` | `enabled: vis !== 'onlySelection'` | +| Double assignment | `n = n = current.parent` | `n = current.parent` | +| Unused destructured var | `const [_, setState]` | `const [, setState]` | +| Hook in non-hook function | `createFoo() { useState() }` | Rename to `useFoo()` or use closure | +| Control bar double-apply | `controlBarCustom(useControlBar(..., controlBarCustom))` | Pass custom directly to hook | +| Polling instead of events | `setInterval(() => check())` | `addEventListener('change', handler)` | diff --git a/.claude/skills/build-check/SKILL.md b/.claude/skills/build-check/SKILL.md new file mode 100644 index 000000000..d431829ef --- /dev/null +++ b/.claude/skills/build-check/SKILL.md @@ -0,0 +1,27 @@ +--- +name: build-check +description: Build the project and verify the output. Use after making changes to confirm everything compiles and the .d.ts declarations are correct. +disable-model-invocation: true +--- + +# Build and Verify + +Run the full build pipeline and verify the output. + +## Steps + +1. **Build**: Run `npm run build` which executes: + - `vite build` — production bundle + - `tsc -p tsconfig.types.json` — TypeScript declarations + +2. **Check for errors**: If the build fails, identify the error, fix it, and rebuild. + +3. **Verify .d.ts output** (if $ARGUMENTS specifies a type to check): + - Look in `dist/` for the declaration files + - Verify that internal/private members do NOT appear in the public API surface + - Verify that expected public types ARE exported + +4. **Report results**: + - Build pass/fail + - Bundle size changes (if notable) + - Any declaration issues found diff --git a/.claude/skills/cleanup/SKILL.md b/.claude/skills/cleanup/SKILL.md new file mode 100644 index 000000000..57845805a --- /dev/null +++ b/.claude/skills/cleanup/SKILL.md @@ -0,0 +1,39 @@ +--- +name: cleanup +description: Remove dead code, unused imports, console.log statements, and other cruft from the specified files or directory. Use for routine code hygiene. +--- + +# Code Cleanup + +Clean up the specified files (or directory) by removing cruft. Always read files before editing. + +## What to Remove + +1. **Unused imports** — imports that are not referenced anywhere in the file +2. **console.log** — remove from production code; `console.error` in catch blocks is OK +3. **Dead functions** — defined but never called or exported +4. **Dead variables** — assigned but never read +5. **eslint-disable comments** — remove along with the dead code they were hiding +6. **Commented-out code blocks** — delete entirely (git has history) +7. **Empty files** — files with no meaningful content +8. **Redundant type assertions** — `as Type` where the type is already inferred +9. **`var` declarations** — change to `let` or `const` + +## What NOT to Remove + +- `console.error` in error handling +- Intentionally empty implementations with `_`-prefixed params (e.g., `(_enabled: boolean) => {}`) +- Re-exports in barrel files (even if they look unused locally) +- Type-only exports (`export type *`) + +## Process + +1. Read each target file +2. Identify items to remove from the list above +3. Make edits +4. Run `npm run build` to verify nothing broke +5. Report what was removed + +## Verification + +Always run `npm run build` after cleanup to catch any accidental removal of used code. diff --git a/.claude/skills/optimize/SKILL.md b/.claude/skills/optimize/SKILL.md new file mode 100644 index 000000000..329bc46b3 --- /dev/null +++ b/.claude/skills/optimize/SKILL.md @@ -0,0 +1,172 @@ +--- +name: optimize +description: Profile and optimize loading pipeline or rendering performance. Use when investigating performance bottlenecks, optimizing shaders, or improving load times. +allowed-tools: Read, Grep, Glob +--- + +# Performance Optimization + +Guide for profiling and optimizing the VIM loading pipeline and WebGL rendering. + +## Loading Pipeline Phases + +``` +Network/Parsing → Geometry Building (~400ms) → GPU Upload → First Render +``` + +### Geometry Building Pipeline + +``` +VimMeshFactory.add(G3dSubset) + ├─ Split by instance count: ≤5 → merged, >5 → instanced + ├─ Merged path (InsertableMeshFactory) + │ ├─ G3dSubset.chunks(16M indices) + │ ├─ Create InsertableMesh per chunk + │ ├─ insertFromG3d() — bake matrices, build geometry + │ └─ scene.addMesh() — register submeshes + └─ Instanced path (InstancedMeshFactory) + ├─ Create THREE.InstancedMesh per unique geometry + ├─ setMatrices() — GPU instancing matrices + └─ scene.addMesh() — register submeshes +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `loader/progressive/vimMeshFactory.ts` | Entry point, splits merged vs instanced | +| `loader/progressive/insertableMeshFactory.ts` | Merged mesh creation | +| `loader/progressive/insertableGeometry.ts` | Geometry building with per-vertex attributes | +| `loader/progressive/instancedMeshFactory.ts` | GPU instanced mesh creation | +| `loader/scene.ts` | Scene management, submesh registration | +| `loader/element3d.ts` | Element3D (lazy creation pattern) | +| `loader/materials/` | All shader materials | + +## Profiling Technique + +Use cumulative timing to identify hotspots: + +```typescript +class SomeFactory { + private static _timing = { phase1: 0, phase2: 0, calls: 0 } + + someMethod() { + const t0 = performance.now() + // phase 1 work + const t1 = performance.now() + SomeFactory._timing.phase1 += t1 - t0 + // phase 2 work + SomeFactory._timing.phase2 += performance.now() - t1 + SomeFactory._timing.calls++ + } + + static logTiming(label: string) { + const t = SomeFactory._timing + console.log(`[${label}] ${t.calls} calls: phase1=${t.phase1.toFixed(2)}ms, phase2=${t.phase2.toFixed(2)}ms`) + t.phase1 = 0; t.phase2 = 0; t.calls = 0 + } +} +``` + +### Red Flags + +- **Outer timing >> sum of inner phases** — hidden overhead between instrumented code +- **Per-item overhead > 0.1ms** — compounds across thousands of items +- **GC pauses** — check Chrome DevTools Performance tab for unexpected allocations + +## Loading Optimization Principles + +### What to Optimize + +1. **Eliminate unnecessary work** — e.g., lazy Element3D creation saved 45% +2. **Hot loops** — code executed millions of times (vertex/index loops) +3. **Allocations in loops** — reuse buffers, minimize GC pressure +4. **Cache locality** — copy matrix to local array before tight loops + +### What NOT to Optimize + +1. **Three.js internals** — `BufferGeometry` creation, `computeBoundingBox()` already optimized +2. **One-time operations** — setup code executed once per mesh +3. **Already-fast code** — <5ms operations with no clear improvement path +4. **Micro-optimizations without measurement** — always measure before/after + +## Color Palette System + +Always-enabled optimization for color storage: + +1. **`submeshColor: Uint16Array`** — ALWAYS present, maps submesh→colorIndex +2. **`colorPalette: Float32Array | undefined`** — texture with unique colors, undefined if >16,384 unique colors + +Key files: `colorPalette.ts`, `geometry.ts:55-79`, `insertableGeometry.ts:200` + +## Shader Optimization Rules + +### Move Computations Up the Pipeline + +``` +Constants → Pre-compute in JavaScript (BEST) +Per-frame → Compute in uniforms +Per-vertex → Vertex shader +Per-fragment → Only when necessary (MOST EXPENSIVE) +``` + +### Avoid in Fragment Shaders + +| Operation | Cost | Alternative | +|-----------|------|-------------| +| `normalize()` on constants | 10-15x | Pre-normalize in JS | +| Division (`a / b`) | 3-5x | Pre-multiply reciprocal | +| `sqrt()`, `sin()`, `cos()` | 8-12x | Use approximations or pre-compute | +| Conditional branching | GPU may execute both paths | Use `step()`, `mix()`, `clamp()` | + +### Good in Shaders + +- Uniform reads (1x), texture fetches (2-4x) +- Add/subtract/multiply (1x) +- Dot/cross products +- `texelFetch()` for indexed access (WebGL 2) +- Early return in vertex shader (skips remaining work) + +### GLSL3 Syntax (All Materials Use This) + +| GLSL1 | GLSL3 | +|-------|-------| +| `attribute` | `in` (vertex shader) | +| `varying` | `out` (vertex), `in` (fragment) | +| `gl_FragColor` | `out vec4 fragColor` | +| `texture2D()` | `texture()` | +| N/A | `texelFetch()` for indexed access | + +## Applied Optimizations Reference + +| Optimization | File | Impact | +|--------------|------|--------| +| Lazy Element3D creation | `scene.ts` | 45% reduction in addMesh() | +| Matrix buffer reuse in hot loop | `insertableGeometry.ts` | Minor, better cache locality | +| Pre-normalized light direction | `simpleMaterial.ts` | 10-15% fragment shader time | +| Pre-divided ghost opacity | `ghostMaterial.ts` | 2-5% fragment shader time | +| Color palette enforcement | `standardMaterial.ts` | 1-3% rendering time | +| Temp vector reuse | `pickingMaterial.ts` | Smoother frame times | +| GLSL3 consistency | All materials | Driver-level optimizations | + +## Future Opportunities + +### High Impact +- Merge simple + ghost materials (reduce shader switching) +- Instance color packing (RGB → uint, 66% less vertex data) +- LOD system (simpler shaders for distant objects) + +### Medium Impact +- CPU frustum culling, occlusion culling +- Shader variants (hasClipping vs noClipping) +- Parallel geometry building with Web Workers +- Streaming GPU upload + +## Process + +1. **Identify bottleneck** — add cumulative timing instrumentation +2. **Measure baseline** — run with real models, record numbers +3. **Make ONE change** — isolate the variable +4. **Measure again** — compare before/after +5. **Run `npm run build`** — verify nothing broke +6. **Report results** — file, change, before/after numbers diff --git a/.claude/skills/review-api/SKILL.md b/.claude/skills/review-api/SKILL.md new file mode 100644 index 000000000..e1f6bd389 --- /dev/null +++ b/.claude/skills/review-api/SKILL.md @@ -0,0 +1,53 @@ +--- +name: review-api +description: Review public API surfaces for consistency between WebGL and Ultra viewers, leaked internals, and missing fields. Use when modifying ViewerApi types or shared state. +allowed-tools: Read, Grep, Glob +--- + +# Review Public API Surfaces + +Review the public API types for consistency and correctness. Focus on these files: + +- `src/vim-web/react-viewers/webgl/viewerApi.ts` — WebGL ViewerApi +- `src/vim-web/react-viewers/ultra/viewerApi.ts` — Ultra ViewerApi +- `src/vim-web/react-viewers/state/sharedIsolation.ts` — IsolationApi +- `src/vim-web/react-viewers/state/sectionBoxState.ts` — SectionBoxApi +- `src/vim-web/react-viewers/state/cameraState.ts` — CameraApi +- `src/vim-web/react-viewers/state/settingsApi.ts` — SettingsApi +- `src/vim-web/react-viewers/controlbar/controlBar.tsx` — ControlBarApi + +Also check the barrel exports: +- `src/vim-web/react-viewers/webgl/index.ts` +- `src/vim-web/react-viewers/ultra/index.ts` +- `src/vim-web/react-viewers/index.ts` + +## Checks + +### 1. WebGL / Ultra Consistency +Both ViewerApi types should have matching fields where applicable: +- `type`, `container`, `core`, `isolation`, `sectionBox`, `camera`, `settings`, `modal`, `controlBar`, `dispose` +- `isolationPanel`, `sectionBoxPanel` (GenericPanelHandle) +- JSDoc comments should match for shared fields + +### 2. No Internal Leaks +- Interfaces should NOT expose adapter refs, internal state, or implementation details +- `IsolationApi` delegates to adapter internally — adapter must not appear in the interface +- Gizmo interfaces should only expose user-facing methods (no `show()`/`setPosition()` that are auto-managed) + +### 3. Shared Types in Shared Locations +- Types used by both viewers belong in `state/` or `helpers/`, not in `webgl/` or `ultra/` +- Cross-viewer imports (ultra importing from `../webgl`) are wrong + +### 4. JSDoc Completeness +- Every field on ViewerApi should have a JSDoc comment +- Comments must match the actual field (watch for copy-paste errors like "isolation panel" on sectionBoxPanel) + +### 5. Barrel Export Discipline +- React layer imports through barrels (`import * as Core from '../../core-viewers'`) +- Core files import directly from source files +- Check that public types are properly exported through index.ts barrels +- Check that internal types are NOT exported + +## Output Format + +For each issue: **File:line** | **Category** | **Description** | **Fix** diff --git a/.claude/skills/review-core/SKILL.md b/.claude/skills/review-core/SKILL.md new file mode 100644 index 000000000..415d7332d --- /dev/null +++ b/.claude/skills/review-core/SKILL.md @@ -0,0 +1,75 @@ +--- +name: review-core +description: Review core-viewers layer code for issues like leaked internals, incorrect imports, and architectural violations. Use when reviewing core-viewers/ files. +allowed-tools: Read, Grep, Glob +--- + +# Review Core Viewers Layer + +Review the specified files (or scan `src/vim-web/core-viewers/` if none specified) for these issues: + +## 1. Import Discipline +- Core files MUST import directly from source files, NEVER through barrel files (index.ts) +- This is the opposite of the React layer rule + +## 2. Leaked Internals +- Check what appears in `.d.ts` output for public classes +- `@internal` tagged members still appear in `.d.ts` — prefer `private` keyword +- `readonly` fields with `@internal` are visible to consumers — make them `private` + +## 3. Interface-Based API Design +- Gizmo classes should expose interfaces, not concrete types +- Interfaces should only include user-facing methods +- Auto-managed properties (e.g., camera-subscription-driven `show()`/`setPosition()`) should NOT be on interfaces + +## 4. Key Architectural Rules + +### Viewer +- `Viewer` class fields like `_renderer` and `_viewport` should be `private` +- Gizmos that need renderer/viewport should receive them via constructor injection, not by reaching into `viewer._renderer` + +### Rendering +- On-demand rendering via `renderer.needsUpdate` flag +- `renderer.requestRender()` sets the flag; cleared after each frame + +### Mesh Building +- ≤5 instances → merged `InsertableMesh` (chunked at 16M indices) +- >5 instances → GPU instanced `InstancedMesh` +- Both delegate material application to shared `applyMaterial()` helper +- Chunk size is 16M **indices** (not vertices) — GPU picking removes raycast traversal constraints + +### Color Palette System +- `submeshColor: Uint16Array` — ALWAYS present, maps submesh→colorIndex +- `colorPalette: Float32Array | undefined` — texture with unique colors, undefined if >16,384 unique +- Key files: `colorPalette.ts`, `geometry.ts`, `insertableGeometry.ts` +- No vertex color fallback — palette-only coloring enforced + +### Loading Pipeline +- Element3D objects are created **lazily** when first accessed via `vim.getElement()` — not during mesh loading +- `scene.addMesh()` only builds instance→submesh map, does NOT create Element3D +- `Submesh = InsertableSubmesh | InstancedSubmesh` — polymorphic, used by Element3D +- `MergedSubmesh = InsertableSubmesh` (type alias) + +### Temp Vector Reuse +- Input handlers and picking material use reusable temp vectors to avoid per-frame GC +- `private static _tempDir = new THREE.Vector3()` pattern — never store references +- Use `.copy()` when storing from a temp vector, never assign the reference + +### GPU Picking +- ID packing: `(vimIndex << 24) | elementIndex` as uint32 +- `packPickingId()` and `unpackPickingId()` utilities in `gpuPicker.ts` +- Float32 render target: R=packedId, G=depth, B=normal.x, A=normal.y + +### Selection +- `ISelectable` is the shared interface for selectable objects +- `IElement3D` extends `ISelectable` with BIM-specific properties +- Selection fires `onSelectionChanged` signal + +## 5. Dead Code +- Unused imports, functions, classes +- Console.log statements +- Commented-out code blocks + +## Output Format + +For each issue: **File:line** | **Category** | **Severity** | **Description** | **Fix** diff --git a/.claude/skills/review-input/SKILL.md b/.claude/skills/review-input/SKILL.md new file mode 100644 index 000000000..2ea215fd8 --- /dev/null +++ b/.claude/skills/review-input/SKILL.md @@ -0,0 +1,150 @@ +--- +name: review-input +description: Review input system code for vector reference bugs, cleanup issues, coordinate misuse, and adapter violations. Use when modifying input handlers, adapters, or touch/mouse/keyboard code. +allowed-tools: Read, Grep, Glob +--- + +# Review Input System + +Review the specified files (or scan `src/vim-web/core-viewers/shared/` and `src/vim-web/core-viewers/*/` input files) for these issues: + +## Key Files + +| File | Purpose | +|------|---------| +| `core-viewers/shared/inputHandler.ts` | Main coordinator, manages modes and routing | +| `core-viewers/shared/mouseHandler.ts` | Mouse/pointer event handling | +| `core-viewers/shared/touchHandler.ts` | Touch gesture recognition | +| `core-viewers/shared/keyboardHandler.ts` | Keyboard input with WASD movement | +| `core-viewers/shared/inputAdapter.ts` | IInputAdapter interface definition | +| `core-viewers/webgl/viewer/inputAdapter.ts` | WebGL adapter implementation | +| `core-viewers/ultra/inputAdapter.ts` | Ultra/RPC adapter implementation | +| `core-viewers/shared/inputConstants.ts` | Shared constants | + +## 1. Temp Vector Reference Bugs (HIGH PRIORITY) + +The input system uses reusable temp vectors for performance (avoiding per-frame GC). Storing a reference instead of copying values causes silent corruption. + +```typescript +// ❌ WRONG — stores reference to temp vector +this._lastPosition = pos +// Next frame, pos points to new values, _lastPosition sees them too + +// ✅ CORRECT — copies values +this._lastPosition.copy(pos) + +// ✅ CORRECT — clone if storing outside handler +const savedPos = pos.clone() +``` + +**Check for:** +- Any assignment of a handler callback parameter to a stored field without `.copy()` +- `this._someVector = someParam` (should be `this._someVector.copy(someParam)`) +- Storing references from `onClick`, `onDrag`, `onPointerDown` callbacks + +## 2. Coordinate System Correctness + +Three coordinate systems exist — using the wrong one causes subtle positioning bugs. + +| System | Range | Used For | +|--------|-------|----------| +| Canvas-relative | [0,1] x [0,1] | Raycasting, clicks, drags, internal logic | +| Client pixels | Screen coords | UI positioning (context menus, tooltips) | +| World space | THREE.Vector3 | 3D operations, camera movement | + +**Check for:** +- Passing pixel coordinates where canvas-relative is expected +- Missing `/ rect.width` or `/ rect.height` normalization +- Using canvas-relative positions for UI overlay positioning + +## 3. Handler Cleanup + +Every registered handler must be cleaned up on dispose: + +```typescript +// ✅ CORRECT — saves and restores +const original = inputs.mouse.onClick +inputs.mouse.onClick = customHandler +// On dispose: +inputs.mouse.onClick = original + +// ❌ WRONG — leaks custom handler +inputs.mouse.onClick = customHandler +// Never restored +``` + +**Check for:** +- Custom key handlers registered without corresponding unregister +- `addEventListener` without matching `removeEventListener` +- Pointer/touch event listeners not cleaned up on dispose + +## 4. Pointer Mode System + +Two-tier mode management — `pointerActive` (user preference) and `pointerOverride` (temporary): + +- `pointerActive`: Set by user/app. Used for left-click drag. Values: ORBIT, LOOK, PAN, ZOOM, RECT +- `pointerOverride`: Set by middle/right click. Cleared on mouse up. Takes priority over active. + +**Check for:** +- Setting `pointerOverride` without clearing it (gets stuck) +- Not checking both modes: `override ?? active` +- Missing `onPointerModeChanged` event fire after mode change + +## 5. IInputAdapter Compliance + +Adapter implementations must match the interface: + +```typescript +interface IInputAdapter { + orbitCamera(rotation: Vector2): void + rotateCamera(rotation: Vector2): void + panCamera(delta: Vector2): void + dollyCamera(delta: Vector2): void + moveCamera(velocity: Vector3): void + selectAtPointer(pos: Vector2, add: boolean): Promise + frameAtPointer(pos: Vector2): Promise + zoom(value: number, screenPos?: Vector2): Promise + pinchStart(screenPos: Vector2): Promise + pinchZoom(totalRatio: number): void + clearSelection(): void + keyDown(keyCode: string): boolean + keyUp(keyCode: string): boolean + pointerDown(pos: Vector2, button: number): void + pointerMove(pos: Vector2): void + pointerUp(pos: Vector2, button: number): void +} +``` + +**Check for:** +- Missing adapter methods (both WebGL and Ultra must implement all) +- Inconsistent parameter handling between WebGL and Ultra adapters +- WebGL adapter directly manipulating camera vs Ultra adapter sending RPC + +## 6. Touch Gesture Issues + +Touch handling uses explicit boolean flags for state: + +**Check for:** +- Missing `touchcancel` handling (causes stuck gestures) +- Not resetting touch state flags on cancel/end +- Pinch ratio calculations without bounds checking +- Missing `preventDefault()` on handled touch events + +## 7. Constants and Thresholds + +From `inputConstants.ts`: +- `CLICK_MOVEMENT_THRESHOLD = 0.003` (canvas-relative) +- `DOUBLE_CLICK_DISTANCE_THRESHOLD = 5` (pixels) +- `DOUBLE_CLICK_TIME_THRESHOLD = 300` (ms) +- `TAP_DURATION_MS = 500` +- `TAP_MOVEMENT_THRESHOLD = 5` (pixels) +- `MIN_MOVE_SPEED = -10`, `MAX_MOVE_SPEED = 10` + +**Check for:** +- Hardcoded thresholds that should use constants +- Speed values outside the valid range +- Mixed units (pixels vs canvas-relative) in comparisons + +## Output Format + +For each issue: **File:line** | **Category** | **Severity** | **Description** | **Fix** diff --git a/.claude/skills/review-react/SKILL.md b/.claude/skills/review-react/SKILL.md new file mode 100644 index 000000000..163e6ab95 --- /dev/null +++ b/.claude/skills/review-react/SKILL.md @@ -0,0 +1,57 @@ +--- +name: review-react +description: Review React layer code for common issues like subscription leaks, hooks violations, and pattern inconsistencies. Use when reviewing react-viewers/ files or after making React layer changes. +allowed-tools: Read, Grep, Glob +--- + +# Review React Layer + +Review the specified files (or all of `src/vim-web/react-viewers/` if none specified) for these categories of issues: + +## 1. Subscription Leaks +- Every `.subscribe()` and `.sub()` call inside `useEffect` MUST have its return value captured and returned as cleanup +- ste-signals `.sub()` and `.subscribe()` both return unsubscribe functions +- Reference correct pattern from CLAUDE.md "Subscription Cleanup" section + +## 2. Resource Leaks +- `ResizeObserver` — must call `.disconnect()` in cleanup +- `setTimeout` — must call `clearTimeout()` in cleanup +- Event listeners — must call `removeEventListener()` in cleanup + +## 3. Rules of Hooks Violations +- Any function calling React hooks (useState, useEffect, useRef, useStateRef, etc.) MUST be prefixed with `use` +- Non-hook helper functions must NOT call hooks — use closure variables instead +- The ultra isolation adapter uses closure variables (`let ghost = false`), NOT useStateRef + +## 4. StateRef Misuse +- `StateRef` interface only has: `get()`, `set()`, `confirm()`, `onChange` +- `useOnChange()`, `useValidate()`, `useMemo()` are only on the concrete `useStateRef()` return — NOT available through `StateRef` typed variables +- If you need onChange in a useEffect with a `StateRef` typed variable, use `stateRef.onChange.subscribe()` + +## 5. useEffect Without Dependencies +- Intentionally depless (OK): `ReactTooltip.rebuild()`, `resizeGfx()` in sidePanel +- NOT OK: State updates like `side.setHasBim(...)` — must use `onChange.subscribe` pattern + +## 6. Console.log in Production +- No `console.log` in production code +- `console.error` for actual error handling in catch blocks is OK + +## 7. Dead Code +- Unused functions, variables, imports +- eslint-disable comments hiding unused code +- Functions defined but not included in return values + +## 8. Copy-paste Bugs +- Duplicate field values (e.g., familyTypeName showing familyName) +- Inverted boolean conditions +- Double assignments (`n = n = value`) +- Stale closures in useState callbacks — use functional updater (`prev => !prev`) + +## Output Format + +For each issue found, report: +- **File:line** — exact location +- **Category** — which category above +- **Severity** — High/Medium/Low +- **Description** — what's wrong +- **Fix** — how to fix it diff --git a/.claude/skills/tighten-types/SKILL.md b/.claude/skills/tighten-types/SKILL.md new file mode 100644 index 000000000..bddc5afac --- /dev/null +++ b/.claude/skills/tighten-types/SKILL.md @@ -0,0 +1,74 @@ +--- +name: tighten-types +description: Create or tighten TypeScript interfaces to hide implementation details. Use when a class exposes too many internals through its public API, or when you need to create an interface that only shows user-facing methods. +allowed-tools: Read, Grep, Glob +--- + +# Tighten TypeScript Interfaces + +Create tight interfaces that hide implementation details from public API surfaces. The goal is that `.d.ts` output only shows what consumers need. + +## Process + +1. **Read the concrete class** — identify ALL public members +2. **Categorize each member**: + - **User-facing**: Methods/properties consumers actually call (keep) + - **Framework-managed**: Methods called automatically by internal systems (hide — e.g., `show()`/`setPosition()` on gizmos that are auto-managed by camera subscriptions) + - **Internal plumbing**: Implementation details (hide) +3. **Create the interface** with only user-facing members +4. **Update the public getter** to return the interface type instead of the concrete class +5. **Verify** `.d.ts` output no longer exposes internals + +## Patterns Used in This Codebase + +### Gizmo Interfaces +Concrete gizmo classes store on private fields; public getter returns interface type: + +```typescript +// In the class file +export interface IGizmoOrbit { + enabled: boolean + setSize(size: number): void + setColors(color: THREE.Color, colorHorizontal: THREE.Color): void + setOpacity(opacity: number, opacityAlways: number): void +} + +// In gizmos.ts +get orbit(): IGizmoOrbit { return this._orbit } +``` + +### Adapter + Delegation Pattern +For React state, use delegation methods instead of exposing adapters: + +```typescript +// GOOD — delegates internally +export interface IsolationApi { + hasSelection(): boolean + isolateSelection(): void + showAll(): void + // ... +} + +// BAD — leaks adapter +export interface IsolationApi { + adapter: RefObject // exposes internal +} +``` + +### Private Fields with @internal +When you can't use interfaces (e.g., fields accessed by siblings in same package), use `private` instead of `@internal readonly`: + +```typescript +// GOOD +private readonly _renderer: Renderer + +// BAD — shows in .d.ts +/** @internal */ readonly _renderer: Renderer +``` + +## Verification + +After tightening, always: +1. Run `npm run build` — must pass +2. Check the `.d.ts` output — tightened internals should not appear +3. Search for any external code that relied on the now-hidden members diff --git a/.gitignore b/.gitignore index 5d168fff0..b1499e893 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules dist docs docs2 -src/pages/*.vim \ No newline at end of file +src/pages/*.vim +.claude/settings.local.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..670d8cd8c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,840 @@ +# 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** | `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.core.camera.frame(element)` | +| **Set visibility** | `element.visible = false` | `element.visible = false` | +| **Set color** | `element.color = new THREE.Color(0xff0000)` | `element.color = new RGBA32(0xff0000ff)` | +| **Section box** | `viewer.sectionBox.active.set(true)` | `viewer.sectionBox.active.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/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` | +| 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 + +```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 +│ │ ├── 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 + ├── webgl/ # Full UI (BIM tree, context menu, gizmos) + ├── ultra/ # Minimal UI + └── helpers/ # StateRef, hooks, utilities +``` + +### ViewerApi (React-to-Core API) + +```typescript +// WebGL ViewerApi +type WebglViewerApi = { + type: 'webgl' + container: Container // HTML structure + core: Core.Webgl.Viewer // Direct core access + load: (source, settings?) => IWebglLoadRequest // Load with geometry + UI + open: (source, settings?) => IWebglLoadRequest // Load without geometry + unload: (vim) => void // Remove and dispose a vim + framing: FramingApi // Framing controls (frame selection/scene) + sectionBox: SectionBoxApi // Section box + isolation: IsolationApi // Isolation mode + controlBar: ControlBarApi // Toolbar customization + contextMenu: ContextMenuApi + bimInfo: BimInfoPanelApi + modal: ModalApi + settings: SettingsApi + isolationPanel: GenericPanelApi // Isolation render settings + sectionBoxPanel: GenericPanelApi // Section box offset settings + dispose: () => void +} + +// Ultra ViewerApi (similar but with RPC-based core, no contextMenu/bimInfo) +``` + +--- + +## 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.visible = false // Hide (same as WebGL) +element.outline = true // Highlight (same as WebGL) +element.state = VisibilityState.GHOSTED // Advanced: ghosted appearance +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.lerp(1).zoomTo(point, 0.8) // Zoom toward point (becomes new orbit target) +camera.snap().set(position, target) // Set position/target + +// State +camera.position // Current position +camera.target // Look-at target +camera.orthographic = true // Ortho projection +camera.lockRotation = new THREE.Vector2(0, 0) // Lock rotation + +// Plan view setup +camera.snap().orbitTowards(new THREE.Vector3(0, 0, -1)) +camera.lockRotation = new THREE.Vector2(0, 0) +camera.orthographic = true +viewer.core.inputs.pointerMode = VIM.Core.PointerMode.PAN +``` + +### Gizmos (WebGL) + +```typescript +// Section Box +viewer.gizmos.sectionBox.active = 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 + +// FuncRef - Callable function reference +const action: FuncRef // No-arg, sync +const query: FuncRef> // No-arg, async +const setter: FuncRef // With arg +action.call() // Execute +action.update(prev => () => { prev(); doAfter() }) // Wrap with middleware + +// In React components +state.useOnChange((v) => ...) // Hook subscription +state.useMemo((v) => compute(v)) +``` + +--- + +## Input System + +> **📖 Full Documentation**: See [INPUT.md](./.claude/docs/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 | +| Click | Select | +| Shift+Click | Add to selection | +| Double-Click | Frame | +| WASD / Arrows | Move camera | +| E / Q | Move up / down | +| Shift | 3x speed boost | +| F | Frame selection | +| Escape | Clear selection | +| P | Toggle orthographic | +| Home | Reset camera | +| +/- | Adjust move speed | + +### Quick API Reference + +```typescript +const inputs = viewer.core.inputs + +// Pointer modes: ORBIT | LOOK | PAN | ZOOM | RECT +inputs.pointerMode = 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.core.camera.snap().orbitTowards(new VIM.THREE.Vector3(0, 0, -1)) +viewer.core.camera.lockRotation = new VIM.THREE.Vector2(0, 0) +viewer.core.camera.orthographic = true +viewer.core.inputs.pointerMode = VIM.Core.PointerMode.PAN +``` + +**Custom Tool Mode**: +```typescript +const originalMode = viewer.core.inputs.pointerMode +viewer.core.inputs.pointerMode = VIM.Core.PointerMode.RECT +viewer.core.inputs.mouse.onClick = (pos) => { /* custom logic */ } +// Restore: viewer.core.inputs.pointerMode = originalMode +``` + +See [INPUT.md](./.claude/docs/INPUT.md) for more patterns, coordinate systems, performance optimization, and debugging techniques + +--- + +## 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.load({ url: 'model.vim' }).getVim() +viewer.framing.frameScene.call() + +// Cleanup +viewer.dispose() +``` + +### Load Local File + +```typescript +const file = inputElement.files[0] +const buffer = await file.arrayBuffer() +const vim = await viewer.load({ buffer }).getVim() +viewer.framing.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.active.set(true) +viewer.sectionBox.sectionBox.call(box) +``` + +### Screenshot + +```typescript +viewer.core.renderer.requestRender() +viewer.core.renderer.render() +const url = viewer.core.renderer.three.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}`)) +}) +``` + +### 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 + +| Pattern | Usage | Example | +|---------|-------|---------| +| `I` prefix | Interfaces | `IVim`, `ICamera`, `ISelectable` | +| `Api` suffix | React API handles | `ViewerApi`, `FramingApi` | +| `Ref` suffix | Reactive primitives | `StateRef`, `ActionRef` | +| `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 +- Do not keep deprecated code or backwards-compatibility shims unless explicitly requested + +### Import Discipline + +- **Core files** (`core-viewers/`): Import directly from source files, never through barrel files (index.ts) +- **React layer** (`react-viewers/`): Import through barrel files (`import * as Core from '../../core-viewers'`), never reach into deep internal paths + +## Commands + +```bash +npm run dev # Dev server (localhost:5173) +npm run build # Production build (vite + tsc declarations + rollup d.ts bundles) +npm run eslint # Lint +npm run documentation # TypeDoc +``` + +--- + +## Architecture Details + +### Loading Pipeline (WebGL) + +> **📖 Loading Optimization**: See [.claude/optimization.md](./.claude/docs/optimization.md) for geometry building performance, lazy Element3D creation, and profiling techniques + +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), await vim.load() + → 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 `vim.load()` or `vim.load(subset)` +4. `VimMeshFactory.add()` splits the subset: ≤5 instances → `InsertableMeshFactory` (merged, chunked), >5 → `InstancedMeshFactory` (GPU instanced) +5. `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.load()` to build geometry. `open()` creates a Vim with no meshes — call `vim.load()` or `vim.load(subset)` later. + +### 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.subset()`: Returns an `ISubset` of all instances — the starting point for filtering +- `vim.load(subset?)`: Loads geometry for the given subset, or all geometry if omitted. Caller is responsible for not loading the same subset twice +- `G3dSubset.chunks(count)`: Splits a subset into smaller subsets by **index count** threshold (not vertex count) + +```typescript +// Load everything +await vim.load() + +// Load a filtered subset +const sub = vim.subset().filter('instance', indices) +await vim.load(sub) +``` + +### Rendering Pipeline (WebGL) + +> **📖 Optimization Guide**: See [.claude/RENDERING_OPTIMIZATIONS.md](./.claude/docs/RENDERING_OPTIMIZATIONS.md) for shader optimizations, GLSL3 migration, and performance improvements + +Multi-pass compositor: +``` +Scene (MSAA) → Selection Mask (mask material) → Outline Pass (depth edge detection) → FXAA → Merge → Screen +``` + +- 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` 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:** +- `Vim.load(subset?)` delegates directly to `VimMeshFactory.add()` +- No intermediate builder or progress signals — `load()` is awaitable + +### 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 = 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:** +- 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 | +|------|---------| +| `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` + +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) + → 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 + +``` +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` + +### 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 | +|---------|-------------| +| Element | BIM entity with properties | +| Instance | Geometry placement in 3D | +| G3D | Geometry container | +| VimDocument | BIM tables (accessed via `vim.bim`) | + +The public API for a loaded VIM is `IWebglVim` (concrete `Vim` class is `@internal`): + +```typescript +// Querying elements +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 + +// Geometry loading +await vim.load() // Load all geometry +await vim.load(subset) // Load filtered subset +vim.subset() // Get ISubset for filtering +vim.clear() // Remove loaded geometry + +// Unloading — do NOT call vim.dispose() directly +viewer.remove(vim) // Removes and disposes the vim +``` + +--- + +## React Layer Patterns & Gotchas + +### StateRef Interface vs Concrete Type + +The `StateRef` interface only exposes: `get()`, `set()`, `confirm()`, `onChange`. + +The object returned by `useStateRef()` also has: `useOnChange()`, `useValidate()`, `useMemo()` — but these are NOT on the `StateRef` interface type. + +If a variable is typed as `StateRef` (e.g., in ViewerState), you cannot call `useOnChange` on it. Use `onChange.subscribe()` in a useEffect instead. + +### Subscription Cleanup + +ste-signals `.sub()` and `.subscribe()` both return unsubscribe functions. Always return them from useEffect cleanup: + +```typescript +// CORRECT +useEffect(() => { + const unsub = signal.subscribe(() => { ... }); + return unsub +}, []); + +// WRONG — leaks subscription +useEffect(() => { + signal.subscribe(() => { ... }); +}, []); +``` + +### Resource Cleanup Checklist + +When writing or reviewing useEffect hooks, ensure cleanup for: +1. `.subscribe()` / `.sub()` — return unsub from effect +2. `ResizeObserver` — call `.disconnect()` in cleanup +3. `setTimeout` — call `clearTimeout()` in cleanup +4. Event listeners — call `removeEventListener()` in cleanup + +### Hook Naming Convention + +Any function calling React hooks (useState, useEffect, useRef, etc.) MUST be prefixed with `use`. Non-hook helper functions must NOT call hooks. + +### Intentional useEffect Without Dependencies + +These run every render and are intentional: +- `ReactTooltip.rebuild()` — tooltips need DOM re-scan after changes +- `resizeGfx()` in sidePanel — canvas must resize on every render + +State updates should NOT be in depless effects — use `onChange.subscribe` pattern instead. + +### IsolationApi Pattern + +`IsolationApi` has delegation methods (`hasSelection`, `showAll`, `isolateSelection`, etc.). Consumers call `isolation.showAll()` directly — never expose the internal adapter. + +The adapter (`IIsolationAdapter`) is an implementation detail: +- WebGL adapter uses closure variables (`let ghost = false`) +- Ultra adapter also uses closure variables (NOT useStateRef — that would be a Rules of Hooks violation since the adapter factory is not a hook) + +### Control Bar Customization + +Both viewers apply customization through the hook parameter, not at render time: + +```typescript +const controlBar = useControlBar(..., customization) + +``` diff --git a/README.md b/README.md index 83a0ff346..6d090e6c7 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,104 @@ # 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 (vite + tsc declarations + rollup d.ts bundles) +npm run eslint # Lint +npm run documentation # TypeDoc generation +``` -Explore the full [API Documentation](https://vimaec.github.io/vim-web). +### Build Pipeline -### Package -https://www.npmjs.com/package/vim-web +`npm run build` runs three steps in sequence: +1. **Vite build** — bundles `vim-web.js` (ESM) and `vim-web.iife.js` (IIFE) into `dist/` +2. **TypeScript declarations** (`tsc -p tsconfig.types.json`) — emits individual `.d.ts` files to `dist/types/` +3. **Rollup d.ts bundling** — produces two self-contained type bundles: + - `dist/vim-web.d.ts` — full library API (3,300+ lines), referenced by `"types"` in package.json + - `dist/vim-bim.d.ts` — standalone BIM data types from `vim-format` (1,900+ lines) -## Overview +### Type Bundles & AI Tooling -The **VIM-Web** repository consists of four primary components, divided into two layers: +The bundled `.d.ts` files serve a dual purpose: -### 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. +**`vim-web.d.ts`** is the package's type entry point. It inlines types from `ste-signals`, `ste-events`, and `ste-core` so consumers get full type information without needing those packages. Types from `three`, `react`, `deepmerge`, and `vim-format` remain external imports. -### 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. +**`vim-bim.d.ts`** bundles all BIM data interfaces (`IElement`, `ICategory`, `IRoom`, `VimDocument`, etc.) from `vim-format` into a single self-contained file. This is designed as an **AI-readable reference** — an LLM can read this one file to understand the complete BIM data model without needing access to the `vim-format` package source. It is not imported by the library itself. -## VIM Format +Both files have semantic namespace names (e.g., `Core_Webgl`, `React_Ultra`) instead of the opaque `index_d$1` names that `rollup-plugin-dts` generates by default. This makes them readable by both humans and AI tools. The rollup configs (`rollup.dts.config.mjs`, `rollup.bim-dts.config.mjs`) handle the namespace renaming and various fixups. -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. +## Architecture Overview -Learn more about the VIM format here: [VIM Format Repository](https://github.com/vimaec/vim-format) -). +### Dual Viewer System -### Built With -- [VIM WebGL Viewer](https://github.com/vimaec/vim-webgl-viewer) -- [React.js](https://reactjs.org/) +| Viewer | Use Case | Rendering | +|--------|----------|-----------| +| **WebGL** | Small-medium models | Local Three.js rendering | +| **Ultra** | Large models | Server-side streaming via WebSocket RPC | -## Getting Started +### Layer Separation + +``` +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 +``` + +### Import Discipline -Follow these steps to get started with the project: +- **Core files** (`core-viewers/`): Import directly from source files, never through barrel files +- **React layer** (`react-viewers/`): Import through barrel files (`import * as Core from '../../core-viewers'`) -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`. +## Key Concepts -> **Note:** Ensure you have a recent version of **Node.js** installed, as required by Vite. +- **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/FuncRef**: Observable state and callable function references used in the React layer for customization. +- **ViewerApi**: The root API handle returned by `createViewer()`. Provides `load()`, `open()`, `unload()`, and access to all subsystems (isolation, sectionBox, controlBar, etc.). -## Repository Organization +## Customization -- **`./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. +The React viewer exposes customization points for: +- **Control bar**: Add/replace toolbar buttons via `viewer.controlBar.customize()` +- **Context menu**: Add custom menu items via `viewer.contextMenu.customize()` +- **BIM info panel**: Modify displayed data or override rendering at any level +- **Settings panel**: Add/remove settings items via `viewer.settings.customize()` +- **Floating panels**: Modify section box offset and isolation render settings fields -## License +## Documentation -Distributed under the **MIT License**. See `LICENSE.txt` for more details. +- **[CLAUDE.md](./CLAUDE.md)** — Detailed API reference, code examples, architecture details, and patterns. This is the primary reference for both developers and AI tools. +- **[.claude/docs/INPUT.md](./.claude/docs/INPUT.md)** — Input system architecture, coordinate systems, override patterns +- **[.claude/docs/optimization.md](./.claude/docs/optimization.md)** — Loading pipeline performance and profiling +- **[.claude/docs/RENDERING_OPTIMIZATIONS.md](./.claude/docs/RENDERING_OPTIMIZATIONS.md)** — Shader material architecture and rendering patterns -## Contact +## Tech Stack -- **Simon Roberge** - [simon.roberge@vimaec.com](mailto:simon.roberge@vimaec.com) -- **Martin Ashton** - [martin.ashton@vimaec.com](mailto:martin.ashton@vimaec.com) +- **TypeScript 5.7**, **React 18.3**, **Vite 6** +- **Three.js 0.171**, **Tailwind CSS 3.4** (`vc-` prefix) +- **ste-events/ste-signals** for typed events, **vim-format** for BIM data +- **Rollup** + **rollup-plugin-dts** for type bundle generation -## Acknowledgments +## Code Style -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) +- Prettier: no semicolons, trailing commas, single quotes +- Index files control module exports +- No test framework — build pass is the verification +- No deprecated code or backwards-compatibility shims diff --git a/package-lock.json b/package-lock.json index 3e9f4ca12..196c01b21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,16 @@ { "name": "vim-web", - "version": "0.4.1", + "version": "0.6.0-dev.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vim-web", - "version": "0.4.1", + "version": "0.6.0-dev.1", "license": "MIT", "dependencies": { "@firefox-devtools/react-contextmenu": "^5.1.1", "@types/three": "0.170.0", - "@vitejs/plugin-react": "^4.3.4", "deepmerge": "^4.3.1", "is-plain-object": "^5.0.0", "re-resizable": "^6.9.9", @@ -30,6 +29,7 @@ "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^8.17.0", "@typescript-eslint/parser": "^8.17.0", + "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "eslint": "^8.23.1", "eslint-config-prettier": "^8.5.0", @@ -37,12 +37,12 @@ "eslint-plugin-promise": "^6.0.1", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", - "fs-extra": "^11.2.0", "gh-pages": "^6.3.0", "postcss": "^8.4.49", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.9", - "react-router-dom": "^7.0.2", + "rollup": "^4.57.1", + "rollup-plugin-dts": "^6.3.0", "tailwindcss": "^3.4.16", "tailwindcss-scoped-preflight": "^3.4.9", "typedoc": "^0.27.3", @@ -70,6 +70,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -79,13 +80,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -95,6 +97,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -103,6 +106,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -132,6 +136,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -140,6 +145,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "dev": true, "dependencies": { "@babel/parser": "^7.26.3", "@babel/types": "^7.26.3", @@ -155,6 +161,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "dev": true, "dependencies": { "@babel/compat-data": "^7.25.9", "@babel/helper-validator-option": "^7.25.9", @@ -170,6 +177,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "dependencies": { "yallist": "^3.0.2" } @@ -178,6 +186,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -186,6 +195,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" @@ -198,6 +208,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", @@ -214,6 +225,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -222,14 +234,16 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -238,6 +252,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -246,6 +261,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "dev": true, "dependencies": { "@babel/template": "^7.25.9", "@babel/types": "^7.26.0" @@ -258,6 +274,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "dev": true, "dependencies": { "@babel/types": "^7.26.3" }, @@ -272,6 +289,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -286,6 +304,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -300,6 +319,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.25.9", "@babel/parser": "^7.25.9", @@ -313,6 +333,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.3.tgz", "integrity": "sha512-yTmc8J+Sj8yLzwr4PD5Xb/WF3bOYu2C2OoSZPzbuqRm4n98XirsbzaX+GloeO376UnSYIYJ4NCanwV5/ugZkwA==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.3", @@ -330,6 +351,7 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, "engines": { "node": ">=4" } @@ -338,6 +360,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -353,6 +376,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -368,6 +392,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -383,6 +408,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -398,6 +424,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -413,6 +440,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -428,6 +456,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -443,6 +472,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -458,6 +488,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -473,6 +504,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -488,6 +520,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -503,6 +536,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -518,6 +552,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -533,6 +568,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -548,6 +584,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -563,6 +600,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -578,6 +616,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -593,6 +632,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -608,6 +648,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -623,6 +664,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -638,6 +680,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -653,6 +696,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -668,6 +712,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -683,6 +728,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -698,6 +744,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -885,6 +932,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -898,6 +946,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -906,19 +955,22 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -982,216 +1034,325 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.0.tgz", - "integrity": "sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.0.tgz", - "integrity": "sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.0.tgz", - "integrity": "sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.0.tgz", - "integrity": "sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.0.tgz", - "integrity": "sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.0.tgz", - "integrity": "sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.0.tgz", - "integrity": "sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.0.tgz", - "integrity": "sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.0.tgz", - "integrity": "sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.0.tgz", - "integrity": "sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.0.tgz", - "integrity": "sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.0.tgz", - "integrity": "sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.0.tgz", - "integrity": "sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.0.tgz", - "integrity": "sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.0.tgz", - "integrity": "sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.0.tgz", - "integrity": "sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.0.tgz", - "integrity": "sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.0.tgz", - "integrity": "sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1232,6 +1393,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -1244,6 +1406,7 @@ "version": "7.6.8", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, "dependencies": { "@babel/types": "^7.0.0" } @@ -1252,6 +1415,7 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -1261,16 +1425,11 @@ "version": "7.20.6", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, "dependencies": { "@babel/types": "^7.20.7" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true - }, "node_modules/@types/dom-webcodecs": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", @@ -1278,9 +1437,10 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "node_modules/@types/hast": { "version": "3.0.4", @@ -1295,6 +1455,7 @@ "version": "18.18.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.1.tgz", "integrity": "sha512-3G42sxmm0fF2+Vtb9TJQpnjmP+uKlWvFa8KoEGquh4gqRmoUG/N0ufuhikw6HEsdG2G2oIKhog1GCTfz9v5NdQ==", + "dev": true, "optional": true, "peer": true }, @@ -1852,6 +2013,7 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, "dependencies": { "@babel/core": "^7.26.0", "@babel/plugin-transform-react-jsx-self": "^7.25.9", @@ -2186,6 +2348,7 @@ "version": "4.24.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2254,6 +2417,7 @@ "version": "1.0.30001686", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001686.tgz", "integrity": "sha512-Y7deg0Aergpa24M3qLC5xjNklnKnhsmSyR/V89dLZ1n0ucJIFNs7PgR2Yfa/Zf6W79SbBicgtGxZr2juHkEUIA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2368,16 +2532,8 @@ "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, - "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", - "dev": true, - "engines": { - "node": ">=18" - } + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -2466,6 +2622,7 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -2571,7 +2728,8 @@ "node_modules/electron-to-chromium": { "version": "1.5.68", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.68.tgz", - "integrity": "sha512-FgMdJlma0OzUYlbrtZ4AeXjKxKPk6KT8WOP8BjcqxWtlg8qyJQjRzPJzUtUn5GBg1oQ26hFs7HOOHJMYiJRnvQ==" + "integrity": "sha512-FgMdJlma0OzUYlbrtZ4AeXjKxKPk6KT8WOP8BjcqxWtlg8qyJQjRzPJzUtUn5GBg1oQ26hFs7HOOHJMYiJRnvQ==", + "dev": true }, "node_modules/email-addresses": { "version": "5.0.0", @@ -2760,6 +2918,7 @@ "version": "0.24.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -2798,6 +2957,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "engines": { "node": ">=6" } @@ -3312,9 +3472,9 @@ } }, "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.0", @@ -3335,6 +3495,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -3384,6 +3545,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -4140,7 +4302,7 @@ "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "devOptional": true, + "dev": true, "bin": { "jiti": "bin/jiti.js" } @@ -4166,6 +4328,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -4189,6 +4352,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -4305,6 +4469,15 @@ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", "dev": true }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -4403,7 +4576,8 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "node_modules/mz": { "version": "2.7.0", @@ -4420,6 +4594,7 @@ "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "dev": true, "funding": [ { "type": "github", @@ -4442,7 +4617,8 @@ "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true }, "node_modules/normalize-path": { "version": "3.0.0", @@ -4716,7 +4892,8 @@ "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true }, "node_modules/picomatch": { "version": "2.3.1", @@ -4825,6 +5002,7 @@ "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5179,48 +5357,9 @@ "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-router": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.0.2.tgz", - "integrity": "sha512-m5AcPfTRUcjwmhBzOJGEl6Y7+Crqyju0+TgTQxoS4SO+BkWbhOrcfZNq6wSWdl2BBbJbsAoBUb8ZacOFT+/JlA==", - "dev": true, - "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0", - "turbo-stream": "2.4.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.0.2.tgz", - "integrity": "sha512-VJOQ+CDWFDGaWdrG12Nl+d7yHtLaurNgAQZVgaIy7/Xd+DojgmYLosFfZdGz1wpxmjJIAkAMVTKWcvkx1oggAw==", "dev": true, - "dependencies": { - "react-router": "7.0.2" - }, "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "node": ">=0.10.0" } }, "node_modules/react-tooltip": { @@ -5351,11 +5490,12 @@ } }, "node_modules/rollup": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.0.tgz", - "integrity": "sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -5365,27 +5505,56 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.28.0", - "@rollup/rollup-android-arm64": "4.28.0", - "@rollup/rollup-darwin-arm64": "4.28.0", - "@rollup/rollup-darwin-x64": "4.28.0", - "@rollup/rollup-freebsd-arm64": "4.28.0", - "@rollup/rollup-freebsd-x64": "4.28.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.28.0", - "@rollup/rollup-linux-arm-musleabihf": "4.28.0", - "@rollup/rollup-linux-arm64-gnu": "4.28.0", - "@rollup/rollup-linux-arm64-musl": "4.28.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.28.0", - "@rollup/rollup-linux-riscv64-gnu": "4.28.0", - "@rollup/rollup-linux-s390x-gnu": "4.28.0", - "@rollup/rollup-linux-x64-gnu": "4.28.0", - "@rollup/rollup-linux-x64-musl": "4.28.0", - "@rollup/rollup-win32-arm64-msvc": "4.28.0", - "@rollup/rollup-win32-ia32-msvc": "4.28.0", - "@rollup/rollup-win32-x64-msvc": "4.28.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-dts": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.3.0.tgz", + "integrity": "sha512-d0UrqxYd8KyZ6i3M2Nx7WOMy708qsV/7fTHMHxCMCBOAe3V/U7OMPu5GkX8hC+cmkHhzGnfeYongl1IgiooddA==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.21" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/Swatinem" + }, + "optionalDependencies": { + "@babel/code-frame": "^7.27.1" + }, + "peerDependencies": { + "rollup": "^3.29.4 || ^4", + "typescript": "^4.5 || ^5.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5465,12 +5634,6 @@ "node": ">=10" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "dev": true - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -5567,6 +5730,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6067,12 +6231,6 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, - "node_modules/turbo-stream": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", - "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", - "dev": true - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6264,6 +6422,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6324,6 +6483,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.2.tgz", "integrity": "sha512-XdQ+VsY2tJpBsKGs0wf3U/+azx8BBpYRHFAyKm5VeEZNOJZRB63q7Sc8Iup3k0TrN3KO6QgyzFf+opSbfY1y0g==", + "dev": true, "dependencies": { "esbuild": "^0.24.0", "postcss": "^8.4.49", @@ -6597,13 +6757,14 @@ "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true }, "node_modules/yaml": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", - "devOptional": true, + "dev": true, "bin": { "yaml": "bin.mjs" }, @@ -6645,30 +6806,34 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, "requires": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" } }, "@babel/compat-data": { "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", - "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==" + "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "dev": true }, "@babel/core": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -6690,7 +6855,8 @@ "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true } } }, @@ -6698,6 +6864,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "dev": true, "requires": { "@babel/parser": "^7.26.3", "@babel/types": "^7.26.3", @@ -6710,6 +6877,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "dev": true, "requires": { "@babel/compat-data": "^7.25.9", "@babel/helper-validator-option": "^7.25.9", @@ -6722,6 +6890,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "requires": { "yallist": "^3.0.2" } @@ -6729,7 +6898,8 @@ "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true } } }, @@ -6737,6 +6907,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, "requires": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" @@ -6746,6 +6917,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, "requires": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", @@ -6755,27 +6927,32 @@ "@babel/helper-plugin-utils": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==" + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "dev": true }, "@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==" + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true }, "@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==" + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true }, "@babel/helper-validator-option": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==" + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true }, "@babel/helpers": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "dev": true, "requires": { "@babel/template": "^7.25.9", "@babel/types": "^7.26.0" @@ -6785,6 +6962,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "dev": true, "requires": { "@babel/types": "^7.26.3" } @@ -6793,6 +6971,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.25.9" } @@ -6801,6 +6980,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "dev": true, "requires": { "@babel/helper-plugin-utils": "^7.25.9" } @@ -6809,6 +6989,7 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "dev": true, "requires": { "@babel/code-frame": "^7.25.9", "@babel/parser": "^7.25.9", @@ -6819,6 +7000,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.3.tgz", "integrity": "sha512-yTmc8J+Sj8yLzwr4PD5Xb/WF3bOYu2C2OoSZPzbuqRm4n98XirsbzaX+GloeO376UnSYIYJ4NCanwV5/ugZkwA==", + "dev": true, "requires": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.3", @@ -6832,7 +7014,8 @@ "globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true } } }, @@ -6840,6 +7023,7 @@ "version": "7.26.3", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "dev": true, "requires": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -6849,144 +7033,168 @@ "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", + "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", + "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", + "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", + "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", + "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", + "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", + "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", + "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", + "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", + "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", + "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", + "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", + "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", + "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", + "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", + "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", + "dev": true, "optional": true }, "@esbuild/openbsd-arm64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", + "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", + "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", + "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", + "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", + "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", + "dev": true, "optional": true }, "@eslint-community/eslint-utils": { @@ -7113,6 +7321,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, "requires": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -7122,22 +7331,26 @@ "@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true }, "@jridgewell/set-array": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==" + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true }, "@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true }, "@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, "requires": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -7183,111 +7396,178 @@ "dev": true }, "@rollup/rollup-android-arm-eabi": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.0.tgz", - "integrity": "sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.0.tgz", - "integrity": "sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.0.tgz", - "integrity": "sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.0.tgz", - "integrity": "sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "dev": true, "optional": true }, "@rollup/rollup-freebsd-arm64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.0.tgz", - "integrity": "sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "dev": true, "optional": true }, "@rollup/rollup-freebsd-x64": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.0.tgz", - "integrity": "sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.0.tgz", - "integrity": "sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.0.tgz", - "integrity": "sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.0.tgz", - "integrity": "sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.0.tgz", - "integrity": "sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "dev": true, "optional": true }, - "@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.0.tgz", - "integrity": "sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ==", + "@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.0.tgz", - "integrity": "sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.0.tgz", - "integrity": "sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.0.tgz", - "integrity": "sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.0.tgz", - "integrity": "sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.0.tgz", - "integrity": "sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.0.tgz", - "integrity": "sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.0.tgz", - "integrity": "sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "dev": true, "optional": true }, "@shikijs/engine-oniguruma": { @@ -7325,6 +7605,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, "requires": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -7337,6 +7618,7 @@ "version": "7.6.8", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, "requires": { "@babel/types": "^7.0.0" } @@ -7345,6 +7627,7 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, "requires": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -7354,16 +7637,11 @@ "version": "7.20.6", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, "requires": { "@babel/types": "^7.20.7" } }, - "@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true - }, "@types/dom-webcodecs": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", @@ -7371,9 +7649,10 @@ "dev": true }, "@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "@types/hast": { "version": "3.0.4", @@ -7388,6 +7667,7 @@ "version": "18.18.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.1.tgz", "integrity": "sha512-3G42sxmm0fF2+Vtb9TJQpnjmP+uKlWvFa8KoEGquh4gqRmoUG/N0ufuhikw6HEsdG2G2oIKhog1GCTfz9v5NdQ==", + "dev": true, "optional": true, "peer": true }, @@ -7737,6 +8017,7 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, "requires": { "@babel/core": "^7.26.0", "@babel/plugin-transform-react-jsx-self": "^7.25.9", @@ -7967,6 +8248,7 @@ "version": "4.24.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "dev": true, "requires": { "caniuse-lite": "^1.0.30001669", "electron-to-chromium": "^1.5.41", @@ -8002,7 +8284,8 @@ "caniuse-lite": { "version": "1.0.30001686", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001686.tgz", - "integrity": "sha512-Y7deg0Aergpa24M3qLC5xjNklnKnhsmSyR/V89dLZ1n0ucJIFNs7PgR2Yfa/Zf6W79SbBicgtGxZr2juHkEUIA==" + "integrity": "sha512-Y7deg0Aergpa24M3qLC5xjNklnKnhsmSyR/V89dLZ1n0ucJIFNs7PgR2Yfa/Zf6W79SbBicgtGxZr2juHkEUIA==", + "dev": true }, "chalk": { "version": "4.1.2", @@ -8082,12 +8365,7 @@ "convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, - "cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, "cross-spawn": { @@ -8150,6 +8428,7 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, "requires": { "ms": "2.1.2" } @@ -8226,7 +8505,8 @@ "electron-to-chromium": { "version": "1.5.68", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.68.tgz", - "integrity": "sha512-FgMdJlma0OzUYlbrtZ4AeXjKxKPk6KT8WOP8BjcqxWtlg8qyJQjRzPJzUtUn5GBg1oQ26hFs7HOOHJMYiJRnvQ==" + "integrity": "sha512-FgMdJlma0OzUYlbrtZ4AeXjKxKPk6KT8WOP8BjcqxWtlg8qyJQjRzPJzUtUn5GBg1oQ26hFs7HOOHJMYiJRnvQ==", + "dev": true }, "email-addresses": { "version": "5.0.0", @@ -8382,6 +8662,7 @@ "version": "0.24.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", + "dev": true, "requires": { "@esbuild/aix-ppc64": "0.24.0", "@esbuild/android-arm": "0.24.0", @@ -8412,7 +8693,8 @@ "escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true }, "escape-string-regexp": { "version": "4.0.0", @@ -8778,9 +9060,9 @@ "dev": true }, "fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", "dev": true, "requires": { "graceful-fs": "^4.2.0", @@ -8798,6 +9080,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "optional": true }, "function-bind": { @@ -8827,7 +9110,8 @@ "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true }, "get-intrinsic": { "version": "1.2.4", @@ -9328,7 +9612,7 @@ "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "devOptional": true + "dev": true }, "js-tokens": { "version": "4.0.0", @@ -9347,7 +9631,8 @@ "jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==" + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true }, "json-schema-traverse": { "version": "0.4.1", @@ -9364,7 +9649,8 @@ "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true }, "jsonfile": { "version": "6.1.0", @@ -9452,6 +9738,15 @@ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", "dev": true }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -9528,7 +9823,8 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "mz": { "version": "2.7.0", @@ -9544,7 +9840,8 @@ "nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==" + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "dev": true }, "natural-compare": { "version": "1.4.0", @@ -9555,7 +9852,8 @@ "node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true }, "normalize-path": { "version": "3.0.0", @@ -9748,7 +10046,8 @@ "picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true }, "picomatch": { "version": "2.3.1", @@ -9826,6 +10125,7 @@ "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "dev": true, "requires": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -9982,28 +10282,8 @@ "react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" - }, - "react-router": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.0.2.tgz", - "integrity": "sha512-m5AcPfTRUcjwmhBzOJGEl6Y7+Crqyju0+TgTQxoS4SO+BkWbhOrcfZNq6wSWdl2BBbJbsAoBUb8ZacOFT+/JlA==", - "dev": true, - "requires": { - "@types/cookie": "^0.6.0", - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0", - "turbo-stream": "2.4.0" - } - }, - "react-router-dom": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.0.2.tgz", - "integrity": "sha512-VJOQ+CDWFDGaWdrG12Nl+d7yHtLaurNgAQZVgaIy7/Xd+DojgmYLosFfZdGz1wpxmjJIAkAMVTKWcvkx1oggAw==", - "dev": true, - "requires": { - "react-router": "7.0.2" - } + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true }, "react-tooltip": { "version": "4.5.1", @@ -10092,32 +10372,50 @@ } }, "rollup": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.0.tgz", - "integrity": "sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ==", - "requires": { - "@rollup/rollup-android-arm-eabi": "4.28.0", - "@rollup/rollup-android-arm64": "4.28.0", - "@rollup/rollup-darwin-arm64": "4.28.0", - "@rollup/rollup-darwin-x64": "4.28.0", - "@rollup/rollup-freebsd-arm64": "4.28.0", - "@rollup/rollup-freebsd-x64": "4.28.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.28.0", - "@rollup/rollup-linux-arm-musleabihf": "4.28.0", - "@rollup/rollup-linux-arm64-gnu": "4.28.0", - "@rollup/rollup-linux-arm64-musl": "4.28.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.28.0", - "@rollup/rollup-linux-riscv64-gnu": "4.28.0", - "@rollup/rollup-linux-s390x-gnu": "4.28.0", - "@rollup/rollup-linux-x64-gnu": "4.28.0", - "@rollup/rollup-linux-x64-musl": "4.28.0", - "@rollup/rollup-win32-arm64-msvc": "4.28.0", - "@rollup/rollup-win32-ia32-msvc": "4.28.0", - "@rollup/rollup-win32-x64-msvc": "4.28.0", - "@types/estree": "1.0.6", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@types/estree": "1.0.8", "fsevents": "~2.3.2" } }, + "rollup-plugin-dts": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.3.0.tgz", + "integrity": "sha512-d0UrqxYd8KyZ6i3M2Nx7WOMy708qsV/7fTHMHxCMCBOAe3V/U7OMPu5GkX8hC+cmkHhzGnfeYongl1IgiooddA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.27.1", + "magic-string": "^0.30.21" + } + }, "run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10165,12 +10463,6 @@ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true }, - "set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "dev": true - }, "set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10239,7 +10531,8 @@ "source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true }, "stats-js": { "version": "1.0.1", @@ -10614,12 +10907,6 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, - "turbo-stream": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", - "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", - "dev": true - }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10755,6 +11042,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, "requires": { "escalade": "^3.2.0", "picocolors": "^1.1.0" @@ -10792,6 +11080,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.2.tgz", "integrity": "sha512-XdQ+VsY2tJpBsKGs0wf3U/+azx8BBpYRHFAyKm5VeEZNOJZRB63q7Sc8Iup3k0TrN3KO6QgyzFf+opSbfY1y0g==", + "dev": true, "requires": { "esbuild": "^0.24.0", "fsevents": "~2.3.3", @@ -10946,13 +11235,14 @@ "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true }, "yaml": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", - "devOptional": true + "dev": true }, "yocto-queue": { "version": "0.1.0", diff --git a/package.json b/package.json index eb42dd7a5..08a000904 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "vim-web", - "version": "0.5.0-dev.24", + "version": "0.6.0-dev.6", "description": "A demonstration app built on top of the vim-webgl-viewer", "type": "module", "files": [ - "dist" + "dist", + "!dist/types" ], "readme": "README.md", "main": "./dist/vim-web.iife.js", - "types": "./dist/types/index.d.ts", + "types": "./dist/vim-web.d.ts", "module": "./dist/vim-web.js", "homepage": "https://github.com/vimaec/vim-web.git", "bugs": { @@ -25,7 +26,9 @@ "eslint": "eslint --ext .js,.ts,.tsx src --fix", "documentation": "typedoc --entryPoints src/vim-web/index.ts --entryPointStrategy expand --out docs2 --excludeProtected --excludeExternals --excludePrivate", "declarations": "tsc -p tsconfig.types.json", - "build": "vite build && npm run declarations", + "bundle-dts": "rollup -c rollup.dts.config.mjs", + "bundle-bim-dts": "rollup -c rollup.bim-dts.config.mjs", + "build": "vite build && npm run declarations && npm run bundle-dts && npm run bundle-bim-dts", "publish:package": "npm run build && npm publish", "publish:documentation": "npm run documentation && gh-pages -d docs2", "publish:both": "npm run publish:package && npm run publish:documentation" @@ -36,6 +39,7 @@ "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^8.17.0", "@typescript-eslint/parser": "^8.17.0", + "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "eslint": "^8.23.1", "eslint-config-prettier": "^8.5.0", @@ -43,22 +47,21 @@ "eslint-plugin-promise": "^6.0.1", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", - "fs-extra": "^11.2.0", + "gh-pages": "^6.3.0", "postcss": "^8.4.49", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.9", - "react-router-dom": "^7.0.2", + "rollup": "^4.57.1", + "rollup-plugin-dts": "^6.3.0", "tailwindcss": "^3.4.16", "tailwindcss-scoped-preflight": "^3.4.9", "typedoc": "^0.27.3", "typescript": "^5.7.2", - "vite": "^6.0.2", - "gh-pages": "^6.3.0" + "vite": "^6.0.2" }, "dependencies": { "@firefox-devtools/react-contextmenu": "^5.1.1", "@types/three": "0.170.0", - "@vitejs/plugin-react": "^4.3.4", "deepmerge": "^4.3.1", "is-plain-object": "^5.0.0", "re-resizable": "^6.9.9", diff --git a/rollup.bim-dts.config.mjs b/rollup.bim-dts.config.mjs new file mode 100644 index 000000000..59bcb5fb4 --- /dev/null +++ b/rollup.bim-dts.config.mjs @@ -0,0 +1,18 @@ +import dts from 'rollup-plugin-dts' + +export default { + input: 'dist/types/bim-types.d.ts', + output: { + file: 'dist/vim-bim.d.ts', + format: 'es', + }, + plugins: [ + dts({ + // Inline vim-format so the BIM types are fully self-contained + includeExternal: ['vim-format'], + }), + ], + external: [ + /\.css$/, + ], +} diff --git a/rollup.dts.config.mjs b/rollup.dts.config.mjs new file mode 100644 index 000000000..bb8689acd --- /dev/null +++ b/rollup.dts.config.mjs @@ -0,0 +1,133 @@ +import dts from 'rollup-plugin-dts' + +export default { + input: 'dist/types/index.d.ts', + output: { + file: 'dist/vim-web.d.ts', + format: 'es', + }, + plugins: [ + // Skip CSS imports + { + name: 'skip-css', + resolveId(source) { + if (source.endsWith('.css')) return { id: source, external: true } + return null + }, + }, + dts({ + // Inline these so the d.ts is self-documenting (no opaque external types) + includeExternal: ['ste-signals', 'ste-simple-events', 'ste-events', 'ste-core'], + }), + // Fix broken self-referential imports from ste-signals. + // ste-signals' ISignal.d.ts has `import { ISignalHandler } from '.'` which + // rollup-plugin-dts can't resolve. Patch the output to remove the broken + // import and inject the missing type definition. + { + name: 'fix-ste-signals', + renderChunk(code) { + // Remove the broken import + code = code.replace(/^import \{ ISignalHandler \} from '\.';\n/m, '') + // If ISignalHandler is referenced but not defined, inject it + if (code.includes('ISignalHandler') && !code.includes('interface ISignalHandler')) { + // ISignalHandler is (ev: IEventManagement) => void + const definition = 'interface ISignalHandler {\n (ev: IEventManagement): void;\n}\n' + // Insert before first usage + code = code.replace( + /^(interface ISignal)/m, + definition + '\n$1' + ) + } + return code + }, + }, + // Replace opaque index_d$N namespace names with semantic names. + // rollup-plugin-dts generates index_d, index_d$1, ... for namespaces. + // We detect each namespace's identity from its exported content. + { + name: 'fix-namespace-names', + renderChunk(code) { + const nameMap = new Map() + + // Detect each namespace by peeking at its first export line + for (const m of code.matchAll(/declare namespace (index_d(?:\$\d+)?) \{/g)) { + const id = m[1] + const peek = code.substring(m.index, m.index + 500) + // Names match the access path: Core.Webgl → Core_Webgl, React.Ultra → React_Ultra + if (peek.includes('createCoreWebglViewer')) nameMap.set(id, 'Core_Webgl') + else if (peek.includes('createCoreUltraViewer')) nameMap.set(id, 'Core_Ultra') + else if (peek.includes('createWebglViewer')) nameMap.set(id, 'React_Webgl') + else if (peek.includes('createUltraViewer')) nameMap.set(id, 'React_Ultra') + else if (peek.includes('PointerMode')) nameMap.set(id, 'Core') + else if (peek.includes('controlBarIds')) nameMap.set(id, 'React_ControlBar') + else if (peek.includes('isFalse')) nameMap.set(id, 'React_Settings') + else if (peek.includes('errorStyle')) nameMap.set(id, 'React_Errors') + else if (peek.includes('contextMenuIds')) nameMap.set(id, 'React_ContextMenu') + } + + // Bare index_d is the React top-level namespace + if (!nameMap.has('index_d') && code.includes('declare namespace index_d {')) { + nameMap.set('index_d', 'React') + } + + // Fix file-derived namespace names (icons.tsx → icons_d, style.ts → style_d) + nameMap.set('icons_d', 'React_Icons') + nameMap.set('style_d', 'React_ControlBar_Style') + nameMap.set('errorStyle_d', 'React_Errors_Style') + + // Replace longest names first (index_d$10 before index_d$1 before index_d) + // Use \b prefix to prevent style_d matching inside errorStyle_d + const sorted = [...nameMap.entries()].sort((a, b) => b[0].length - a[0].length) + for (const [from, to] of sorted) { + const escaped = from.replace(/\$/g, '\\$') + code = code.replace(new RegExp('\\b' + escaped + '(?!\\$|\\d)', 'g'), to) + } + return code + }, + }, + // Strip namespace alias noise generated by rollup-plugin-dts. + // For each namespace, dts generates standalone aliases like: + // type Core_Webgl_IFoo = IFoo; + // declare const Core_Webgl_Bar: typeof Bar; + // and re-exports them as: + // export { Core_Webgl_IFoo as IFoo, Core_Webgl_Bar as Bar }; + // This plugin removes the standalone aliases and simplifies + // re-exports to reference the original names directly. + { + name: 'strip-namespace-aliases', + renderChunk(code) { + // Collect all alias names (not namespace names) from standalone declarations + const aliases = new Set() + for (const m of code.matchAll(/^type ((?:Core|React)_\w+)/gm)) { + aliases.add(m[1]) + } + for (const m of code.matchAll(/^declare const ((?:Core|React)_\w+):/gm)) { + aliases.add(m[1]) + } + + // Remove standalone type alias lines + code = code.replace(/^type (?:Core|React)_\w+[^;]*;\n/gm, '') + // Remove standalone const alias lines + code = code.replace(/^declare const (?:Core|React)_\w+: typeof \w+;\n/gm, '') + + // In export blocks, replace known aliases with direct references: + // "Core_Webgl_IFoo as IFoo" → "IFoo" + // Namespace references like "Core_Ultra as Ultra" are NOT in aliases set, so kept + code = code.replace(/(?:Core|React)_\w+ as (\w+)/g, (match, realName) => { + const aliasName = match.split(' as ')[0] + return aliases.has(aliasName) ? realName : match + }) + + return code + }, + }, + ], + external: [ + 'three', + 'react', + 'react-dom', + 'deepmerge', + 'vim-format', + /\.css$/, + ], +} diff --git a/src/main.tsx b/src/main.tsx index 11310a6de..af6543e06 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,11 @@ import React, { MutableRefObject, useEffect, useRef } from "react"; import { createRoot } from "react-dom/client"; import * as VIM from './vim-web' -type ViewerRef = VIM.React.Webgl.ViewerRef | VIM.React.Ultra.ViewerRef +type ViewerRef = VIM.React.Webgl.ViewerApi | VIM.React.Ultra.ViewerApi + +function isWebglViewer (viewer: ViewerRef): viewer is VIM.React.Webgl.ViewerApi { + return viewer.type === 'webgl' +} // Get the container div const container = document.getElementById("root"); @@ -27,54 +31,132 @@ function App() { const div = useRef(null) const viewerRef = useRef() + const fileInputRef = useRef(null) + 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() } }, []) + const handleLoadLocalFile = () => { + fileInputRef.current?.click() + } + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file || !viewerRef.current) return + if (!isWebglViewer(viewerRef.current)) return + + console.log('Loading local file:', file.name) + const buffer = await file.arrayBuffer() + const request = viewerRef.current.load({ buffer }) + + const result = await request.getResult() + if (result.isError) { + console.error('Load failed:', result.error) + return + } + + viewerRef.current.framing.frameScene.call() + } + return ( -
+ <> + {/* TEST SECTION - Local File Loading */} +
+ + +
+ +
+ ) } async function createWebgl (viewerRef: MutableRefObject, div: HTMLDivElement) { - const viewer = await VIM.React.Webgl.createViewer(div) + const viewer = await VIM.React.Webgl.createViewer(div, {ui: { + }}) + 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 }, - ) + const request = viewer.load({ url }) + const result = await request.getResult() - if (result.isSuccess()) { - viewer.loader.add(result.result) - viewer.camera.frameScene.call() + if (result.isError) { + console.error('Load failed:', result.error) + return } + + + viewer.framing.frameScene.call() } async function createUltra (viewerRef: MutableRefObject, div: HTMLDivElement) { const viewer = await VIM.React.Ultra.createViewer(div) await viewer.core.connect() - 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.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() + if (result.isError) { + console.error('Load failed:', result.type, result.error) + return } + viewer.framing.frameScene.call() } diff --git a/src/vim-web/bim-types.ts b/src/vim-web/bim-types.ts new file mode 100644 index 000000000..3ff94fc0f --- /dev/null +++ b/src/vim-web/bim-types.ts @@ -0,0 +1,26 @@ +/** + * Re-export of vim-format BIM types. + * Bundled into dist/vim-bim.d.ts as a self-contained reference. + */ +export type { + IElement, + ILevel, + IPhase, + ICategory, + IWorkset, + IDesignOption, + IView, + IGroup, + IAssemblyInstance, + IBimDocument, + IRoom, + IFamily, + IFamilyType, + IFamilyInstance, + IMaterial, + INode, + VimDocument, + VimHeader, +} from 'vim-format' + +export { VimHelpers } from 'vim-format' diff --git a/src/vim-web/core-viewers/shared/events.ts b/src/vim-web/core-viewers/shared/events.ts new file mode 100644 index 000000000..06d8f51da --- /dev/null +++ b/src/vim-web/core-viewers/shared/events.ts @@ -0,0 +1,41 @@ +/** + * A signal with no payload. + * Subscribe to be notified when the signal fires. + */ +export interface ISignal { + /** Number of active subscriptions. */ + readonly count: number + /** Subscribe to the signal. Returns a function that unsubscribes. */ + subscribe(fn: () => void): () => void + /** Alias for {@link subscribe}. */ + sub(fn: () => void): () => void + /** Subscribe once — automatically removed after first fire. */ + one(fn: () => void): () => void + /** Remove a subscription by handler reference. */ + unsubscribe(fn: () => void): void + /** Check whether a handler is currently subscribed. */ + has(fn: () => void): boolean + /** Remove all subscriptions. */ + clear(): void +} + +/** + * A signal that carries a payload of type `T`. + * Subscribers receive the payload as their first argument. + */ +export interface ISimpleEvent { + /** Number of active subscriptions. */ + readonly count: number + /** Subscribe to the event. Returns a function that unsubscribes. */ + subscribe(fn: (args: T) => void): () => void + /** Alias for {@link subscribe}. */ + sub(fn: (args: T) => void): () => void + /** Subscribe once — automatically removed after first fire. */ + one(fn: (args: T) => void): () => void + /** Remove a subscription by handler reference. */ + unsubscribe(fn: (args: T) => void): void + /** Check whether a handler is currently subscribed. */ + has(fn: (args: T) => void): boolean + /** Remove all subscriptions. */ + clear(): void +} diff --git a/src/vim-web/core-viewers/shared/index.ts b/src/vim-web/core-viewers/shared/index.ts index 4366640d2..44ca85ca4 100644 --- a/src/vim-web/core-viewers/shared/index.ts +++ b/src/vim-web/core-viewers/shared/index.ts @@ -1,14 +1,15 @@ -// Full export -export * from './inputHandler' - -// 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' \ No newline at end of file +// Input +export { PointerMode } from './input' +export type { IInputHandler, IMouseInput, MouseOverrides, ClickHandler, DoubleClickHandler, PointerButtonHandler, MoveHandler, WheelHandler, ContextMenuHandler, ITouchInput, TouchOverrides, TapHandler, DragHandler, PinchStartHandler, PinchHandler, IKeyboardInput, DragCallback } from './input' + +// Loading +export type { ILoadSuccess, ILoadError, IProgress, ProgressType, LoadResult, ILoadRequest } from './loadResult' + +// Vim +export type { IVimElement, IVim } from './vim' + +// Events +export type { ISignal, ISimpleEvent } from './events' + +// Helpers +export { authHeaders } from './loadSource' diff --git a/src/vim-web/core-viewers/shared/baseInputHandler.ts b/src/vim-web/core-viewers/shared/input/baseInputHandler.ts similarity index 62% rename from src/vim-web/core-viewers/shared/baseInputHandler.ts rename to src/vim-web/core-viewers/shared/input/baseInputHandler.ts index 5d7dbb56a..e58b9bd58 100644 --- a/src/vim-web/core-viewers/shared/baseInputHandler.ts +++ b/src/vim-web/core-viewers/shared/input/baseInputHandler.ts @@ -5,6 +5,7 @@ /** * Base class for various input handlers. * It provides convenience to register to and unregister from events. + * @internal */ export class BaseInputHandler { protected _canvas: HTMLCanvasElement @@ -18,16 +19,29 @@ 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); }); } /** - * Register handler to related browser events - * Prevents double registrations + * Whether this handler is actively listening to browser events. + */ + get active (): boolean { + return this._disconnect.length > 0 + } + + set active (value: boolean) { + if (value) this.register() + else this.unregister() + } + + /** + * Register handler to related browser events. + * Prevents double registrations. */ register () { if (this._disconnect.length > 0) return @@ -37,8 +51,8 @@ export class BaseInputHandler { protected addListeners () {} /** - * Unregister handler from related browser events - * Prevents double unregistration + * Unregister handler from related browser events. + * Prevents double unregistration. */ unregister () { this._disconnect.forEach((f) => f()) 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..02a36186b --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/clickDetection.ts @@ -0,0 +1,83 @@ +/** + * 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. + * + * 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 + * - Call onPointerUp when pointer is released + * - Check isClick() or wasMoved() to determine interaction type + * @internal + */ +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 (must be > 0) + */ + constructor(threshold: number) { + if (threshold <= 0 || !isFinite(threshold)) { + throw new Error('ClickDetector threshold must be a positive 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..1fff06fa1 --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/coordinates.ts @@ -0,0 +1,75 @@ +/** + * 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) + * @internal + */ +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) + * @internal + */ +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) + * @internal + */ +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..ceba273ff --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/doubleClickDetection.ts @@ -0,0 +1,85 @@ +/** + * 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 + * @internal + */ +export class DoubleClickDetector { + private _lastTime: number = 0 + private _lastPosition: THREE.Vector2 = new THREE.Vector2() + private _hasLastPosition: boolean = false + private _timeThreshold: number + private _distanceThreshold: number + + /** + * @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 + } + + /** + * 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. If a double-click is + * detected, state is reset to prevent triple-click false positives. + */ + check(position: THREE.Vector2): boolean { + const currentTime = Date.now() + const timeDiff = currentTime - this._lastTime + + const isClose = + this._hasLastPosition && + this._lastPosition.distanceTo(position) < this._distanceThreshold + + const isWithinTime = timeDiff < this._timeThreshold + const isDouble = isClose && isWithinTime + + if (isDouble) { + // Reset state to prevent triple-click detection + this.reset() + } else { + // Update state for next check + this._lastTime = currentTime + this._lastPosition.copy(position) + this._hasLastPosition = true + } + + return isDouble + } + + /** + * Reset the detector state. + */ + reset(): void { + this._lastTime = 0 + this._hasLastPosition = false + } +} 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..c4c3cd65e --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/dragTracking.ts @@ -0,0 +1,81 @@ +/** + * 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 + * @internal + */ +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..5bb5eabdd --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/index.ts @@ -0,0 +1,6 @@ +export { PointerMode } from './inputHandler' +export type { IInputHandler } from './inputHandler' +export type { IMouseInput, MouseOverrides, ClickHandler, DoubleClickHandler, PointerButtonHandler, MoveHandler, WheelHandler, ContextMenuHandler } from './mouseHandler' +export type { ITouchInput, TouchOverrides, TapHandler, DragHandler, PinchStartHandler, PinchHandler } from './touchHandler' +export type { IKeyboardInput } from './keyboardHandler' +export type { DragCallback } from './dragTracking' diff --git a/src/vim-web/core-viewers/shared/input/inputAdapter.ts b/src/vim-web/core-viewers/shared/input/inputAdapter.ts new file mode 100644 index 000000000..bb3d50e53 --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/inputAdapter.ts @@ -0,0 +1,70 @@ +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. + * @internal + */ +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 + + /** Handle key down event - return true if handled */ + keyDown: (keyCode: string) => boolean + + /** Handle key up event - return true if handled */ + keyUp: (keyCode: string) => boolean + + /** 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/inputConstants.ts b/src/vim-web/core-viewers/shared/input/inputConstants.ts new file mode 100644 index 000000000..ccf1e5c6e --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/inputConstants.ts @@ -0,0 +1,51 @@ +/** + * 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) + * @internal + */ +export const CLICK_MOVEMENT_THRESHOLD = 0.003 + +/** + * Maximum distance (in normalized canvas units [0-1]) between two clicks + * to be considered a double-click (~5px on a 1000px canvas) + * @internal + */ +export const DOUBLE_CLICK_DISTANCE_THRESHOLD = 0.005 + +/** + * Maximum time (in milliseconds) between two clicks + * to be considered a double-click + * @internal + */ +export const DOUBLE_CLICK_TIME_THRESHOLD = 300 + +/** + * Maximum duration (in milliseconds) for a touch to be considered a tap + * @internal + */ +export const TAP_DURATION_MS = 500 + +/** + * Maximum distance (in pixels) a touch can move + * and still be considered a tap (not a drag) + * @internal + */ +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 + * @internal + */ +export const MIN_MOVE_SPEED = -10 + +/** + * Maximum move speed for keyboard camera movement + * @internal + */ +export const MAX_MOVE_SPEED = 10 diff --git a/src/vim-web/core-viewers/shared/input/inputHandler.ts b/src/vim-web/core-viewers/shared/input/inputHandler.ts new file mode 100644 index 000000000..d7761265a --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/inputHandler.ts @@ -0,0 +1,317 @@ +/** + * Input coordinator that routes device events to viewer-specific adapters. + * + * See INPUT.md for architecture, pointer modes, and customization patterns. + */ + +import type { ISignal } from '../events' +import { SignalDispatcher } from 'ste-signals' +import type { ISimpleEvent } from '../events' +import { SimpleEventDispatcher } from 'ste-simple-events' +import * as THREE from 'three' +import { KeyboardHandler, type IKeyboardInput } from './keyboardHandler' +import { MouseHandler, type IMouseInput } from './mouseHandler' +import { TouchHandler, type ITouchInput } from './touchHandler' +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 + +/** + * Determines how left-drag (mouse) or single-finger drag (touch) is interpreted. + * + * - `ORBIT` — Rotate camera around its target point. + * - `LOOK` — Rotate camera in place (first-person). + * - `PAN` — Slide camera laterally. + * - `ZOOM` — Dolly camera forward/backward. + * - `RECT` — Rectangle selection (reserved). + */ +export enum PointerMode { + ORBIT = 'orbit', + LOOK = 'look', + PAN = 'pan', + ZOOM = 'zoom', + RECT = 'rect' +} + +interface InputSettings{ + orbit: boolean + scrollSpeed: number + moveSpeed: number +} + +/** + * Public API for the input system, accessed via `viewer.inputs`. + * + * Provides access to the three device handlers (keyboard, mouse, touch), + * pointer mode control, and speed settings. + * + * @example + * ```ts + * // Change pointer mode to pan + * viewer.inputs.pointerMode = PointerMode.PAN + * + * // Override a keyboard binding + * const restore = viewer.inputs.keyboard.override('KeyF', 'up', () => myFrameLogic()) + * // Later: restore() + * + * // Override mouse click behavior + * const restore = viewer.inputs.mouse.override({ + * onClick: (original, pos, ctrl) => { myLogic(pos); original(pos, ctrl) } + * }) + * ``` + */ +export interface IInputHandler { + /** Keyboard input handler. Override key bindings via {@link IKeyboardInput.override}. */ + keyboard: IKeyboardInput + /** Mouse input handler. Override callbacks via {@link IMouseInput.override}. */ + mouse: IMouseInput + /** Touch input handler. Override callbacks via {@link ITouchInput.override}. */ + touch: ITouchInput + + /** The active pointer mode controlling how left-drag is interpreted. */ + pointerMode: PointerMode + /** Temporary pointer mode during drag (e.g., right-drag = LOOK). Read-only. */ + readonly pointerOverride: PointerMode | undefined + /** Fires when {@link pointerMode} or {@link pointerOverride} changes. */ + readonly onPointerModeChanged: ISignal + + /** WASD move speed. Exponential scale: actual speed = 1.25^moveSpeed. Range: [-10, +10]. */ + moveSpeed: number + /** Scroll wheel zoom speed. Higher = faster zoom per scroll tick. */ + scrollSpeed: number + /** Fires when any speed setting changes (moveSpeed, scrollSpeed). */ + readonly onSettingsChanged: ISignal + + /** Fires when a right-click context menu should be shown. Payload is client-space position. */ + readonly onContextMenu: ISimpleEvent +} + +/** + * Input handler coordinator. + * + * Manages two-tier pointer modes (active/override). + * See INPUT.md for mode system and customization. + * @internal + */ +export class InputHandler implements IInputHandler { + + private _canvas: HTMLCanvasElement + private _touch: TouchHandler + private _mouse: MouseHandler + private _keyboard: KeyboardHandler + + get touch(): ITouchInput { return this._touch } + get mouse(): IMouseInput { return this._mouse } + get keyboard(): IKeyboardInput { return this._keyboard } + + private _scrollSpeed: number + private _rotateSpeed: number = 1 + private _orbitSpeed: number = 1 + private _moveSpeed: number + + private _pointerMode: PointerMode = PointerMode.ORBIT + private _pointerOverride: PointerMode | undefined + private _onPointerModeChanged = new SignalDispatcher() + private _onSettingsChanged = new SignalDispatcher() + private _adapter : IInputAdapter + + constructor (canvas: HTMLCanvasElement, adapter: IInputAdapter, settings: Partial = {}) { + this._canvas = canvas + this._adapter = adapter + + this._pointerMode = (settings.orbit === undefined || settings.orbit) ? PointerMode.ORBIT : PointerMode.LOOK + this._scrollSpeed = settings.scrollSpeed ?? 1.75 + this._moveSpeed = settings.moveSpeed ?? 1 + + this._keyboard = new KeyboardHandler(canvas, { + onKeyDown: (key: string) => adapter.keyDown(key), + onKeyUp: (key: string) => adapter.keyUp(key), + onMove: (value: THREE.Vector3) => { + const mul = Math.pow(MOVE_SPEED_BASE, this._moveSpeed) + adapter.moveCamera(value.multiplyScalar(mul)) + }, + }) + + this._keyboard.override('KeyP', 'up', () => adapter.toggleOrthographic()); + this._keyboard.override(['Equal', 'NumpadAdd'], 'up', () => this.moveSpeed++); + this._keyboard.override(['Minus', 'NumpadSubtract'], 'up', () => this.moveSpeed--); + this._keyboard.override('Home', 'up', () => adapter.resetCamera()); + this._keyboard.override('Escape', 'up', () => adapter.clearSelection()); + this._keyboard.override('KeyF', 'up', () => adapter.frameCamera()); + + this._mouse = new MouseHandler(canvas, { + onClick: (pos: THREE.Vector2, modif: boolean) => adapter.selectAtPointer(pos, modif), + onDoubleClick: adapter.frameAtPointer, + onDrag: (delta: THREE.Vector2, button: number) => { + if(button === 0){ + if(this.pointerMode === PointerMode.ORBIT) adapter.orbitCamera(toRotation(delta, this._orbitSpeed)) + if(this.pointerMode === PointerMode.LOOK) adapter.rotateCamera(toRotation(delta, this._rotateSpeed)) + if(this.pointerMode === PointerMode.PAN) adapter.panCamera(delta) + if(this.pointerMode === PointerMode.ZOOM) adapter.dollyCamera(delta) + } + if(button === 2){ + this._setPointerOverride(PointerMode.LOOK) + adapter.rotateCamera(toRotation(delta,1)) + } + if(button === 1){ + this._setPointerOverride(PointerMode.PAN) + adapter.panCamera(delta) + } + }, + onPointerDown: adapter.pointerDown, + onPointerUp: (pos: THREE.Vector2, button: number) => { + this._setPointerOverride(undefined) + adapter.pointerUp(pos, button) + }, + onPointerMove: adapter.pointerMove, + onWheel: (value: number, ctrl: boolean, clientX: number, clientY: number) => { + if(ctrl){ + this.moveSpeed -= Math.sign(value) + } + else{ + const rect = this._canvas.getBoundingClientRect() + const screenX = (clientX - rect.left) / rect.width + const screenY = (clientY - rect.top) / rect.height + _tempScreenPos.set(screenX, screenY) + adapter.zoom(this._getZoomValue(value), _tempScreenPos) + } + }, + onContextMenu: (pos: THREE.Vector2) => { + canvasToClient(pos.x, pos.y, canvas, _tempClientPos) + this._onContextMenu.dispatch(_tempClientPos) + }, + }) + + this._touch = new TouchHandler(canvas, { + onTap: (pos: THREE.Vector2) => adapter.selectAtPointer(pos, false), + onDoubleTap: adapter.frameAtPointer, + onDrag: (delta: THREE.Vector2) => { + if(this.pointerMode === PointerMode.ORBIT) adapter.orbitCamera(toRotation(delta, this._orbitSpeed)) + if(this.pointerMode === PointerMode.LOOK) adapter.rotateCamera(toRotation(delta, this._rotateSpeed)) + if(this.pointerMode === PointerMode.PAN) adapter.panCamera(delta) + if(this.pointerMode === PointerMode.ZOOM) adapter.dollyCamera(delta) + }, + onPinchStart: (center: THREE.Vector2) => adapter.pinchStart(center), + onPinchOrSpread: (totalRatio: number) => adapter.pinchZoom(totalRatio), + onDoubleDrag: (value: THREE.Vector2) => adapter.panCamera(value), + }) + } + + private _getZoomValue (value: number) { + return Math.pow(this._scrollSpeed, -value) + } + + private _setPointerOverride (value: PointerMode | undefined) { + if (value === this._pointerOverride) return + this._pointerOverride = value + this._onPointerModeChanged.dispatch() + } + + init(){ + this.registerAll() + this._adapter.init() + } + + get moveSpeed () { + return this._moveSpeed + } + + set moveSpeed (value: number) { + this._moveSpeed = Math.max(MIN_MOVE_SPEED, Math.min(MAX_MOVE_SPEED, value)) + this._onSettingsChanged.dispatch() + } + + get scrollSpeed () { return this._scrollSpeed } + set scrollSpeed (value: number) { + this._scrollSpeed = value + this._onSettingsChanged.dispatch() + } + + get onSettingsChanged() { + return this._onSettingsChanged.asEvent() + } + + /** + * Returns current pointer mode. + */ + get pointerMode (): PointerMode { + return this._pointerMode + } + + /** + * A temporary pointer mode during drag (e.g., right-drag = LOOK). + */ + get pointerOverride (): PointerMode | undefined { + return this._pointerOverride + } + + /** + * Changes pointer interaction mode. + */ + set pointerMode (value: PointerMode) { + if (value === this._pointerMode) return + this._pointerMode = value + this._onPointerModeChanged.dispatch() + } + + /** + * Event fired when pointer mode or pointer override changes. + */ + get onPointerModeChanged () { + return this._onPointerModeChanged.asEvent() + } + + private _onContextMenu = new SimpleEventDispatcher< + THREE.Vector2 | undefined + >() + + /** + * Event called when when context menu could be displayed + */ + get onContextMenu () { + return this._onContextMenu.asEvent() + } + + /** + * Register inputs handlers for default viewer behavior + */ + registerAll () { + this._keyboard.register() + this._mouse.register() + this._touch.register() + } + + /** + * Unregisters all input handlers + */ + unregisterAll = () => { + this._mouse.unregister() + this._keyboard.unregister() + this._touch.unregister() + } + + /** + * Resets all input state + */ + resetAll () { + this._mouse.reset() + this._keyboard.reset() + this._touch.reset() + } + + dispose(){ + this.unregisterAll() + } +} + +// 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) +} diff --git a/src/vim-web/core-viewers/shared/keyboardHandler.ts b/src/vim-web/core-viewers/shared/input/keyboardHandler.ts similarity index 57% rename from src/vim-web/core-viewers/shared/keyboardHandler.ts rename to src/vim-web/core-viewers/shared/input/keyboardHandler.ts index 3801aa017..20226d72e 100644 --- a/src/vim-web/core-viewers/shared/keyboardHandler.ts +++ b/src/vim-web/core-viewers/shared/input/keyboardHandler.ts @@ -5,29 +5,62 @@ import * as THREE from 'three'; import { BaseInputHandler } from './baseInputHandler'; -type CallbackMode = 'replace' | 'append' | 'prepend'; +type KeyHandler = (code: string) => boolean +type MoveHandler = (value: THREE.Vector3) => void + +/** @internal */ +export type KeyboardCallbacks = { + onKeyDown: KeyHandler + onKeyUp: KeyHandler + onMove: MoveHandler +} /** - * KeyboardHandler - * - * A modern keyboard handler that manages keyboard events using a stateful pattern. - * It supports separate handlers for key down, key up, and continuous key pressed events. - * The handler calculates a movement vector based on currently pressed keys. + * Public API for keyboard input, accessed via `viewer.inputs.keyboard`. + * + * Supports per-key overrides with automatic restore, and an `active` toggle + * to temporarily disable all keyboard handling (e.g., when a text input has focus). + * + * @example + * ```ts + * // Override the F key (key up) + * const restore = viewer.inputs.keyboard.override('KeyF', 'up', () => myAction()) + * // Later: restore() + * + * // Chain with the original handler + * viewer.inputs.keyboard.override('KeyF', 'up', (original) => { + * original?.() + * myExtraAction() + * }) + * + * // Disable keyboard while typing + * viewer.inputs.keyboard.active = false + * // Re-enable + * viewer.inputs.keyboard.active = true + * ``` */ -export class KeyboardHandler extends BaseInputHandler { - +export interface IKeyboardInput { + /** Whether keyboard event listeners are active. Set to `false` to suspend all keyboard handling. */ + active: boolean /** - * Callback invoked whenever the calculated movement vector is updated. + * Overrides a key handler. Returns a function that restores the previous handler. + * + * @param code - The `KeyboardEvent.code` (e.g., `'KeyF'`, `'Escape'`) or an array of codes. + * @param on - Whether to intercept key `'down'` or `'up'` events. + * @param handler - Callback invoked on the event. Receives the previous handler as `original`. + * @returns A function that restores the previous handler when called. */ - public onMove: (value: THREE.Vector3) => void; - public onKeyUp: (code: string) => boolean; - public onKeyDown: (code: string) => boolean; + override(code: string | string[], on: 'down' | 'up', handler: (original?: () => void) => void): () => void +} + +/** @internal */ +export class KeyboardHandler extends BaseInputHandler { + // Callbacks + private _onMove: MoveHandler; + private _onKeyUp: KeyHandler; + private _onKeyDown: KeyHandler; - /** - * Speed multiplier applied when the Shift key is held. - * @private - */ private readonly SHIFT_MULTIPLIER: number = 3.0; @@ -53,8 +86,12 @@ export class KeyboardHandler extends BaseInputHandler { * Creates an instance of KeyboardHandler. * @param canvas The HTMLCanvasElement to attach keyboard events to. */ - constructor(canvas: HTMLCanvasElement) { + constructor(canvas: HTMLCanvasElement, callbacks: KeyboardCallbacks) { super(canvas); + this._onKeyDown = callbacks.onKeyDown + this._onKeyUp = callbacks.onKeyUp + this._onMove = callbacks.onMove + // Ensure the canvas can receive focus. this._canvas.tabIndex = 0; this.addListeners(); @@ -67,14 +104,22 @@ export class KeyboardHandler extends BaseInputHandler { */ protected override addListeners(): void { // Listen for keyboard events on the canvas. - this.reg(this._canvas, 'keydown', this._onKeyDown); - this.reg(this._canvas, 'keyup', this._onKeyUp); + this.reg(this._canvas, 'keydown', this._handleKeyDown); + this.reg(this._canvas, 'keyup', this._handleKeyUp); // 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 { const movementKeys = [ 'KeyD', 'ArrowRight', // Move right @@ -85,11 +130,11 @@ export class KeyboardHandler extends BaseInputHandler { 'KeyQ', // Move down // 'ShiftLeft', 'ShiftRight' // Speed boost. They don't provoke any movement. Don't register. ]; - + // Register movement keys for both key down and key up movementKeys.forEach(key => { - this.registerKeyDown(key, 'replace', () => this.applyMove()); - this.registerKeyUp(key, 'replace', () => this.applyMove()); + this.override(key, 'down', () => this.applyMove()); + this.override(key, 'up', () => this.applyMove()); }); } @@ -113,45 +158,34 @@ export class KeyboardHandler extends BaseInputHandler { } /** - * Registers a handler for a key down event. - * @param code The event.code of the key. - * @param handler Callback invoked on key down. - */ - public registerKeyDown(code: string, mode: CallbackMode, handler: () => void): void { - this.registerKey(this.keyDownHandlers, code, mode, handler); - } - - /** - * Registers a handler for a key up event. - * @param code The event.code of the key. - * @param handler Callback invoked on key up. + * Overrides a key handler. Returns a function that restores the previous handler. + * @param code The event.code of the key (or array of codes). + * @param on Whether to handle key down or key up. + * @param handler Callback invoked on the event. Receives the previous handler as `original`. */ - public registerKeyUp(code: string | string[], mode: CallbackMode, handler: () => void): void { + public override(code: string | string[], on: 'down' | 'up', handler: (original?: () => void) => void): () => void { + const map = on === 'down' ? this.keyDownHandlers : this.keyUpHandlers if (Array.isArray(code)) { - code.forEach(c => this.registerKey(this.keyUpHandlers, c, mode, handler)); + const restores = code.map(c => this.registerKey(map, c, handler)); + return () => restores.forEach(r => r()) } else { - this.registerKey(this.keyUpHandlers, code, mode, handler); + return this.registerKey(map, code, handler); } } - private registerKey(map: Map void>, code: string, mode: CallbackMode, callback: () => void){ - mode = map.has(code) ? mode : 'replace' - + private registerKey(map: Map void>, code: string, handler: (original?: () => void) => void): () => void { const previous = map.get(code) - const next = mode === 'replace' ? callback - : mode === 'prepend' ? () => {callback(); previous()} - : mode === 'append' ? () => {previous(); callback()} - : undefined - map.set(code, next) + map.set(code, () => handler(previous)) + return () => { map.set(code, previous) } } - + /** * Internal key down event handler. * @param event The KeyboardEvent object. * @private */ - private _onKeyDown = (event: KeyboardEvent): void => { + private _handleKeyDown = (event: KeyboardEvent): void => { this.pressedKeys.add(event.code); // Invoke the registered key down handler, if any. @@ -162,7 +196,7 @@ export class KeyboardHandler extends BaseInputHandler { } // Key is not registered, call the onKeyDown callback if defined. - if(this.onKeyDown?.(event.code) ?? false){ + if(this._onKeyDown?.(event.code) ?? false){ event.preventDefault(); } }; @@ -172,7 +206,7 @@ export class KeyboardHandler extends BaseInputHandler { * @param event The KeyboardEvent object. * @private */ - private _onKeyUp = (event: KeyboardEvent): void => { + private _handleKeyUp = (event: KeyboardEvent): void => { this.pressedKeys.delete(event.code); // Invoke the registered key up handler, if any. @@ -183,7 +217,7 @@ export class KeyboardHandler extends BaseInputHandler { } // Key is not registered, call the onKeyUp callback if defined. - if(this.onKeyUp?.(event.code) ?? false){ + if(this._onKeyUp?.(event.code) ?? false){ event.preventDefault(); } }; @@ -194,7 +228,7 @@ export class KeyboardHandler extends BaseInputHandler { * @private */ private applyMove(): void { - + // Calculate horizontal movement: right (D/ArrowRight) minus left (A/ArrowLeft). const moveX = (this.isKeyPressed('KeyD') || this.isKeyPressed('ArrowRight') ? 1 : 0) - (this.isKeyPressed('KeyA') || this.isKeyPressed('ArrowLeft') ? 1 : 0); @@ -202,7 +236,7 @@ export class KeyboardHandler extends BaseInputHandler { // Calculate forward/backward movement: forward (W/ArrowUp) minus backward (S/ArrowDown). const moveZ = (this.isKeyPressed('KeyW') || this.isKeyPressed('ArrowUp') ? 1 : 0) - (this.isKeyPressed('KeyS') || this.isKeyPressed('ArrowDown') ? 1 : 0); - + // Calculate vertical movement: up (E) minus down (Q). const moveY = (this.isKeyPressed('KeyE') ? 1 : 0) - (this.isKeyPressed('KeyQ') ? 1 : 0); @@ -210,13 +244,13 @@ export class KeyboardHandler extends BaseInputHandler { let move = new THREE.Vector3(moveX, moveY, moveZ); - + // Apply speed multiplier if Shift is held. if (this.isKeyPressed('ShiftLeft') || this.isKeyPressed('ShiftRight')) { move.multiplyScalar(this.SHIFT_MULTIPLIER); } // Call the onMove callback if defined. - this.onMove?.(move); + this._onMove?.(move); } } diff --git a/src/vim-web/core-viewers/shared/input/mouseHandler.ts b/src/vim-web/core-viewers/shared/input/mouseHandler.ts new file mode 100644 index 000000000..72448593b --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/mouseHandler.ts @@ -0,0 +1,274 @@ +/** + * 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"; +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'; + +export type ClickHandler = (position: THREE.Vector2, ctrl: boolean) => void +export type DoubleClickHandler = (position: THREE.Vector2) => void +export type PointerButtonHandler = (pos: THREE.Vector2, button: number) => void +export type MoveHandler = (pos: THREE.Vector2) => void +export type WheelHandler = (value: number, ctrl: boolean, clientX: number, clientY: number) => void +export type ContextMenuHandler = (position: THREE.Vector2) => void + +/** @internal */ +export type MouseCallbacks = { + onClick: ClickHandler + onDoubleClick: DoubleClickHandler + onDrag: DragCallback + onPointerDown: PointerButtonHandler + onPointerUp: PointerButtonHandler + onPointerMove: MoveHandler + onWheel: WheelHandler + onContextMenu: ContextMenuHandler +} + +/** + * Public API for mouse input, accessed via `viewer.inputs.mouse`. + * + * Supports overriding any mouse callback with automatic restore. Each override + * handler receives the original callback as its first parameter for chaining. + * + * @example + * ```ts + * // Override click to add custom logic + * const restore = viewer.inputs.mouse.override({ + * onClick: (original, pos, ctrl) => { + * if (myCondition) myAction(pos) + * else original(pos, ctrl) + * } + * }) + * // Later: restore() + * ``` + */ +export interface IMouseInput { + /** Whether mouse event listeners are active. Set to `false` to suspend all mouse handling. */ + active: boolean + /** + * Temporarily overrides mouse callbacks. Only provided handlers are replaced; + * others keep their current behavior. Each handler receives the original as its first param. + * + * @param handlers - Partial set of callbacks to override. + * @returns A function that restores all overridden callbacks when called. + */ + override(handlers: MouseOverrides): () => void +} + +/** + * Partial set of mouse callbacks for use with {@link IMouseInput.override}. + * Each handler receives the original callback as its first parameter. + * All positions are canvas-relative, normalized to [0, 1]. + */ +export type MouseOverrides = { + onClick?: (original: ClickHandler, pos: THREE.Vector2, ctrl: boolean) => void + onDoubleClick?: (original: DoubleClickHandler, pos: THREE.Vector2) => void + onDrag?: (original: DragCallback, delta: THREE.Vector2, button: number) => void + onPointerDown?: (original: PointerButtonHandler, pos: THREE.Vector2, button: number) => void + onPointerUp?: (original: PointerButtonHandler, pos: THREE.Vector2, button: number) => void + onPointerMove?: (original: MoveHandler, pos: THREE.Vector2) => void + onWheel?: (original: WheelHandler, value: number, ctrl: boolean, clientX: number, clientY: number) => void + onContextMenu?: (original: ContextMenuHandler, pos: THREE.Vector2) => void +} + +/** + * 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. + * @internal + */ +export class MouseHandler extends BaseInputHandler { + private _capture: PointerCapture; + private _dragHandler: DragTracker; + private _doubleClickHandler: DoubleClickDetector; + private _clickHandler: ClickDetector; + + // Reusable vectors to avoid per-frame allocations + private _tempPosition = new THREE.Vector2(); + + // Callbacks + private _onClick: ClickHandler + private _onDoubleClick: DoubleClickHandler + private _onDrag: DragCallback + private _onPointerDown: PointerButtonHandler + private _onPointerUp: PointerButtonHandler + private _onPointerMove: MoveHandler + private _onWheel: WheelHandler + private _onContextMenu: ContextMenuHandler + + constructor(canvas: HTMLCanvasElement, callbacks: MouseCallbacks) { + super(canvas); + this._onClick = callbacks.onClick + this._onDoubleClick = callbacks.onDoubleClick + this._onDrag = callbacks.onDrag + this._onPointerDown = callbacks.onPointerDown + this._onPointerUp = callbacks.onPointerUp + this._onPointerMove = callbacks.onPointerMove + this._onWheel = callbacks.onWheel + this._onContextMenu = callbacks.onContextMenu + + 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 { + + 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); }); + } + + /** + * Temporarily overrides mouse callbacks. Each handler receives the original as its first param. + * Returns a function that restores the previous callbacks. Only one level of override at a time. + */ + override(handlers: MouseOverrides): () => void { + const saved = { + onClick: this._onClick, + onDoubleClick: this._onDoubleClick, + onDrag: this._onDrag, + onPointerDown: this._onPointerDown, + onPointerUp: this._onPointerUp, + onPointerMove: this._onPointerMove, + onWheel: this._onWheel, + onContextMenu: this._onContextMenu, + } + if (handlers.onClick) this._onClick = (p, c) => handlers.onClick(saved.onClick, p, c) + if (handlers.onDoubleClick) this._onDoubleClick = (p) => handlers.onDoubleClick(saved.onDoubleClick, p) + if (handlers.onDrag) this._onDrag = (d, b) => handlers.onDrag(saved.onDrag, d, b) + if (handlers.onPointerDown) this._onPointerDown = (p, b) => handlers.onPointerDown(saved.onPointerDown, p, b) + if (handlers.onPointerUp) this._onPointerUp = (p, b) => handlers.onPointerUp(saved.onPointerUp, p, b) + if (handlers.onPointerMove) this._onPointerMove = (p) => handlers.onPointerMove(saved.onPointerMove, p) + if (handlers.onWheel) this._onWheel = (v, c, x, y) => handlers.onWheel(saved.onWheel, v, c, x, y) + if (handlers.onContextMenu) this._onContextMenu = (p) => handlers.onContextMenu(saved.onContextMenu, p) + + return () => { + this._onClick = saved.onClick + this._onDoubleClick = saved.onDoubleClick + this._onDrag = saved.onDrag + this._onPointerDown = saved.onPointerDown + this._onPointerUp = saved.onPointerUp + this._onPointerMove = saved.onPointerMove + this._onWheel = saved.onWheel + this._onContextMenu = saved.onContextMenu + } + } + + /** + * Cleanup method - unregisters all event listeners. + */ + dispose(): void { + this.unregister(); + } + + private handlePointerDown(event: PointerEvent): void { + if (event.pointerType !== 'mouse') return; // We don't handle touch yet + + const pos = this.relativePosition(event); + this._onPointerDown?.(pos, event.button); + // Start drag + this._dragHandler.onPointerDown(pos, event.button); + this._clickHandler.onPointerDown(pos); + this._capture.onPointerDown(event); + event.preventDefault(); + } + + private handlePointerUp(event: PointerEvent): void { + if (event.pointerType !== 'mouse') return; + event.preventDefault(); + + const pos = this.relativePosition(event); + + // Button up event + this._onPointerUp?.(pos, event.button); + this._capture.onPointerUp(); + this._dragHandler.onPointerUp(); + this._clickHandler.onPointerUp(); + + + // Click type event + if(this._doubleClickHandler.check(pos)){ + this.handleDoubleClick(event); + return + } + if(this._clickHandler.isClick(event.button, 0)){ + this.handleMouseClick(event); + return + } + + if (event.button === 2) { + 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(); + this._dragHandler.onPointerUp(); + this._clickHandler.onPointerUp(); + } + + private async handleMouseClick(event: PointerEvent): Promise { + if (event.pointerType !== 'mouse') return; + if(event.button !== 0) return; + + const pos = this.relativePosition(event); + const modif = event.getModifierState('Shift') || event.getModifierState('Control'); + this._onClick?.(pos, modif); + } + + private async handleContextMenu(event: PointerEvent): Promise { + if (event.pointerType !== 'mouse') return; + if(event.button !== 2) return; + + // Don't show context menu if there was a drag + if (this._clickHandler.wasMoved()) { + return; + } + + const pos = this.relativePosition(event); + this._onContextMenu?.(pos); + } + + + private handlePointerMove(event: PointerEvent): void { + if (event.pointerType !== 'mouse') return; + this._canvas.focus(); + const pos = this.relativePosition(event); + this._dragHandler.onPointerMove(pos); + this._clickHandler.onPointerMove(pos); + this._onPointerMove?.(pos); + } + + private async handleDoubleClick(event: MouseEvent): Promise { + const pos = this.relativePosition(event); + this._onDoubleClick?.(pos); + event.preventDefault(); + } + + private onMouseScroll(event: WheelEvent): void { + this._onWheel?.(Math.sign(event.deltaY), event.ctrlKey, event.clientX, event.clientY); + event.preventDefault(); + } + + private relativePosition(event: PointerEvent | MouseEvent): THREE.Vector2 { + return pointerToCanvas(event, this._canvas, this._tempPosition); + } +} 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..dacb8e611 --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/pointerCapture.ts @@ -0,0 +1,66 @@ +/** + * 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 + * @internal + */ +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/input/touchHandler.ts b/src/vim-web/core-viewers/shared/input/touchHandler.ts new file mode 100644 index 000000000..d85f8aace --- /dev/null +++ b/src/vim-web/core-viewers/shared/input/touchHandler.ts @@ -0,0 +1,294 @@ +/** + * 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'; +import { clientToCanvas } from './coordinates'; + +export type TapHandler = (position: THREE.Vector2) => void +export type DragHandler = (delta: THREE.Vector2) => void +export type PinchStartHandler = (screenCenter: THREE.Vector2) => void +export type PinchHandler = (totalRatio: number) => void + +/** @internal */ +export type TouchCallbacks = { + onTap: TapHandler + onDoubleTap: TapHandler + onDrag: DragHandler + onDoubleDrag: DragHandler + onPinchStart: PinchStartHandler + onPinchOrSpread: PinchHandler +} + +/** + * Public API for touch input, accessed via `viewer.inputs.touch`. + * + * Supports overriding any touch callback with automatic restore. Each override + * handler receives the original callback as its first parameter for chaining. + * + * @example + * ```ts + * const restore = viewer.inputs.touch.override({ + * onTap: (original, pos) => { myAction(pos) } + * }) + * // Later: restore() + * ``` + */ +export interface ITouchInput { + /** Whether touch event listeners are active. Set to `false` to suspend all touch handling. */ + active: boolean + /** + * Temporarily overrides touch callbacks. Only provided handlers are replaced; + * others keep their current behavior. Each handler receives the original as its first param. + * + * @param handlers - Partial set of callbacks to override. + * @returns A function that restores all overridden callbacks when called. + */ + override(handlers: TouchOverrides): () => void +} + +/** + * Partial set of touch callbacks for use with {@link ITouchInput.override}. + * Each handler receives the original callback as its first parameter. + * All positions are canvas-relative, normalized to [0, 1]. + */ +export type TouchOverrides = { + /** Single-finger tap. */ + onTap?: (original: TapHandler, pos: THREE.Vector2) => void + /** Double tap. */ + onDoubleTap?: (original: TapHandler, pos: THREE.Vector2) => void + /** Single-finger drag. Delta is normalized to canvas size. */ + onDrag?: (original: DragHandler, delta: THREE.Vector2) => void + /** Two-finger drag (pan). Delta is normalized to canvas size. */ + onDoubleDrag?: (original: DragHandler, delta: THREE.Vector2) => void + /** Two-finger pinch/spread started. Center is canvas-relative position. */ + onPinchStart?: (original: PinchStartHandler, center: THREE.Vector2) => void + /** Two-finger pinch/spread. Ratio is cumulative distance relative to start (1.0 = no change). */ + onPinchOrSpread?: (original: PinchHandler, ratio: number) => void +} + +/** + * Handles touch gestures with zero-allocation vector reuse. + * @internal + */ +export class TouchHandler extends BaseInputHandler { + // Callbacks + private _onTap: TapHandler + private _onDoubleTap: TapHandler + private _onDrag: DragHandler + private _onDoubleDrag: DragHandler + private _onPinchStart: PinchStartHandler + private _onPinchOrSpread: PinchHandler + + // Temp vectors (reused, never store references!) + 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() + private _tempTouch1 = new THREE.Vector2() + private _tempTouch2 = new THREE.Vector2() + + constructor (canvas: HTMLCanvasElement, callbacks: TouchCallbacks) { + super(canvas) + this._onTap = callbacks.onTap + this._onDoubleTap = callbacks.onDoubleTap + this._onDrag = callbacks.onDrag + this._onDoubleDrag = callbacks.onDoubleDrag + this._onPinchStart = callbacks.onPinchStart + this._onPinchOrSpread = callbacks.onPinchOrSpread + } + + // 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 + private _lastTapMs: number | undefined + private _touchStart = new THREE.Vector2() + private _hasTouchStart = false + private _startDist: number | undefined + + protected override addListeners (): void { + 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, active) + this.reg(this._canvas, 'touchcancel', this.onTouchCancel) + } + + override reset = () => { + this._hasTouch = this._hasTouch1 = this._hasTouch2 = this._hasTouchStart = false + this._touchStartTime = this._startDist = undefined + } + + /** + * Temporarily overrides touch callbacks. Each handler receives the original as its first param. + * Returns a function that restores the previous callbacks. Only one level of override at a time. + */ + override(handlers: TouchOverrides): () => void { + const saved = { + onTap: this._onTap, + onDoubleTap: this._onDoubleTap, + onDrag: this._onDrag, + onDoubleDrag: this._onDoubleDrag, + onPinchStart: this._onPinchStart, + onPinchOrSpread: this._onPinchOrSpread, + } + if (handlers.onTap) this._onTap = (p) => handlers.onTap(saved.onTap, p) + if (handlers.onDoubleTap) this._onDoubleTap = (p) => handlers.onDoubleTap(saved.onDoubleTap, p) + if (handlers.onDrag) this._onDrag = (d) => handlers.onDrag(saved.onDrag, d) + if (handlers.onDoubleDrag) this._onDoubleDrag = (d) => handlers.onDoubleDrag(saved.onDoubleDrag, d) + if (handlers.onPinchStart) this._onPinchStart = (c) => handlers.onPinchStart(saved.onPinchStart, c) + if (handlers.onPinchOrSpread) this._onPinchOrSpread = (r) => handlers.onPinchOrSpread(saved.onPinchOrSpread, r) + + return () => { + this._onTap = saved.onTap + this._onDoubleTap = saved.onDoubleTap + this._onDrag = saved.onDrag + this._onDoubleDrag = saved.onDoubleDrag + this._onPinchStart = saved.onPinchStart + this._onPinchOrSpread = saved.onPinchOrSpread + } + } + + /** + * Cleanup method - unregisters all event listeners and resets state. + */ + dispose(): void { + this.unregister() + } + + private _handleTap = (position: THREE.Vector2) => { + const time = Date.now() + const double = + this._lastTapMs && time - this._lastTapMs < DOUBLE_CLICK_TIME_THRESHOLD + this._lastTapMs = time + + clientToCanvas(position.x, position.y, this._canvas, this._tempScreenPos) + + if(double) + this._onDoubleTap?.(this._tempScreenPos) + else + this._onTap?.(this._tempScreenPos) + } + + private onTouchStart = (event: TouchEvent) => { + if (event.cancelable) event.preventDefault() + if (!event || !event.touches || !event.touches.length) { + return + } + this._touchStartTime = Date.now() + + if (event.touches.length === 1) { + this.touchToVector(event.touches[0], this._touch) + this._hasTouch = true + this._hasTouch1 = this._hasTouch2 = false + } else if (event.touches.length === 2) { + 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) + + clientToCanvas(this._touch.x, this._touch.y, this._canvas, this._tempScreenPos) + this._onPinchStart?.(this._tempScreenPos) + } + this._touchStart.copy(this._touch) + this._hasTouchStart = true + } + + private onTouchMove = (event: TouchEvent) => { + if (event.cancelable) event.preventDefault() + if (!event.touches.length) return + if (!this._hasTouch) return + + if (event.touches.length === 1) { + 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)) + + this._touch.copy(pos) + this._onDrag?.(this._tempDelta) + return + } + + if (!this._hasTouch1 || !this._hasTouch2) return + if (event.touches.length >= 2) { + 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 = this._tempTouch1.distanceTo(this._tempTouch2) + + this._touch.copy(p) + this._touch1.copy(this._tempTouch1) + this._touch2.copy(this._tempTouch2) + + this._onDoubleDrag?.(this._tempDelta) + if (this._startDist) { + this._onPinchOrSpread?.(dist / this._startDist) + } + } + } + + private onTouchEnd = (event: TouchEvent) => { + // 2→1 finger: transition to single-finger drag + if (event.touches.length === 1) { + this.touchToVector(event.touches[0], this._touch) + this._hasTouch = true + this._hasTouch1 = this._hasTouch2 = false + this._startDist = undefined + return + } + + // All fingers lifted: check for tap, then reset + if (this.isSingleTouch() && this._hasTouchStart) { + const touchDurationMs = Date.now() - this._touchStartTime + const length = this._touch.distanceTo(this._touchStart) + if ( + touchDurationMs < TAP_DURATION_MS && + length < TAP_MOVEMENT_THRESHOLD + ) { + this._handleTap(this._touch) + } + } + 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 && + this._touchStartTime !== undefined && + !this._hasTouch1 && + !this._hasTouch2 + ) + } + + 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 { + this._tempVec2.copy(p1).lerp(p2, 0.5) + return this._tempVec2 + } +} diff --git a/src/vim-web/core-viewers/shared/inputAdapter.ts b/src/vim-web/core-viewers/shared/inputAdapter.ts deleted file mode 100644 index 0d90cb88d..000000000 --- a/src/vim-web/core-viewers/shared/inputAdapter.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as THREE from 'three' - -export interface IInputAdapter{ - init: () => void - - toggleOrthographic: () => void - toggleCameraOrbitMode: () => void - resetCamera: () => void - clearSelection: () => void - frameCamera: () => void - moveCamera: (value: THREE.Vector3) => void - orbitCamera: (value: THREE.Vector2) => void - rotateCamera: (value: THREE.Vector2) => void - panCamera: (value: THREE.Vector2) => void - dollyCamera: (value: THREE.Vector2) => void - - // Raw input handlers for Ultra - keyDown: (keyCode: string) => boolean - keyUp: (keyCode: string) => boolean - mouseDown: (pos: THREE.Vector2, button: number) => void - 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) => 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 deleted file mode 100644 index 549c8197a..000000000 --- a/src/vim-web/core-viewers/shared/inputHandler.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { SignalDispatcher } from 'ste-signals' -import { SimpleEventDispatcher } from 'ste-simple-events' -import * as THREE from 'three' -import { BaseInputHandler } from './baseInputHandler' -import { KeyboardHandler } from './keyboardHandler' -import { MouseHandler } from './mouseHandler' -import { TouchHandler } from './touchHandler' -import { IInputAdapter } from './inputAdapter' - -export enum PointerMode { - ORBIT = 'orbit', - LOOK = 'look', - PAN = 'pan', - ZOOM = 'zoom', - RECT = 'rect' -} - - -interface InputSettings{ - orbit: boolean - scrollSpeed: number - moveSpeed: number - rotateSpeed: number - orbitSpeed: number -} - -export class InputHandler extends BaseInputHandler { - - /** - * Touch input handler - */ - touch: TouchHandler - /** - * Mouse input handler - */ - mouse: MouseHandler - /** - * Keyboard input handler - */ - keyboard: KeyboardHandler - - - scrollSpeed: number = 1.6 - private _moveSpeed: number - rotateSpeed: number - orbitSpeed: number - - private _pointerActive: PointerMode = PointerMode.ORBIT - private _pointerFallback: PointerMode = PointerMode.LOOK - private _pointerOverride: PointerMode | undefined - private _onPointerOverrideChanged = new SignalDispatcher() - private _onPointerModeChanged = new SignalDispatcher() - private _onSettingsChanged = new SignalDispatcher() - private _adapter : IInputAdapter - - constructor (canvas: HTMLCanvasElement, adapter: IInputAdapter, settings: Partial = {}) { - super(canvas) - this._adapter = adapter - - this._pointerActive = (settings.orbit === undefined || settings.orbit) ? PointerMode.ORBIT : PointerMode.LOOK - this.scrollSpeed = settings.scrollSpeed ?? 1.6 - this._moveSpeed = settings.moveSpeed ?? 1 - this.rotateSpeed = settings.rotateSpeed ?? 1 - this.orbitSpeed = settings.orbitSpeed ?? 1 - - this.keyboard = new KeyboardHandler(canvas) - this.mouse = new MouseHandler(canvas) - this.touch = new TouchHandler(canvas) - - // Keyboard controls - this.keyboard.onKeyDown = (key: string) => adapter.keyDown(key) - this.keyboard.onKeyUp = (key: string) => adapter.keyUp(key) - - 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', () => { - adapter.frameCamera(); - }); - - this.keyboard.onMove = (value: THREE.Vector3 ) =>{ - const mul = Math.pow(1.25, this._moveSpeed) - adapter.moveCamera(value.multiplyScalar(mul)) - } - - // Mouse controls - this.mouse.onContextMenu = (pos: THREE.Vector2) => this._onContextMenu.dispatch(pos); - this.mouse.onButtonDown = adapter.mouseDown - this.mouse.onMouseMove = adapter.mouseMove - this.mouse.onButtonUp = (pos: THREE.Vector2, button: number) => { - this.pointerOverride = undefined - adapter.mouseUp(pos, button) - } - this.mouse.onDrag = (delta: THREE.Vector2, button: number) =>{ - if(button === 0){ - 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) - } - if(button === 2){ - this.pointerOverride = PointerMode.LOOK - adapter.rotateCamera(toRotation(delta,1)) - - } - if(button === 1){ - this.pointerOverride = PointerMode.PAN - adapter.panCamera(delta) - } - } - - this.mouse.onClick = (pos: THREE.Vector2, modif: boolean) => adapter.selectAtPointer(pos, modif) - this.mouse.onDoubleClick = adapter.frameAtPointer - this.mouse.onWheel = (value: number, ctrl: boolean) => { - if(ctrl){ - console.log('ctrl', value) - this.moveSpeed -= Math.sign(value) - } - else{ - adapter.zoom(this.getZoomValue(value)) - } - } - - // 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.onPinchOrSpread = adapter.zoom - this.touch.onDoubleDrag = (value : THREE.Vector2) => adapter.panCamera(value) - } - - getZoomValue (value: number) { - return Math.pow(this.scrollSpeed, value) - } - - init(){ - this.registerAll() - this._adapter.init() - } - - get moveSpeed () { - return this._moveSpeed - } - - set moveSpeed (value: number) { - this._moveSpeed = value - this._onSettingsChanged.dispatch() - } - - get onSettingsChanged() { - return this._onSettingsChanged.asEvent() - } - - /** - * Returns the last main mode (orbit, look) that was active. - */ - get pointerFallback () : PointerMode { - return this._pointerFallback - } - - /** - * Returns current pointer mode. - */ - get pointerActive (): PointerMode { - return this._pointerActive - } - - /** - * A temporary pointer mode used for temporary icons. - */ - get pointerOverride (): PointerMode { - return this._pointerOverride - } - - set pointerOverride (value: PointerMode | undefined) { - if (value === this._pointerOverride) return - this._pointerOverride = value - this._onPointerOverrideChanged.dispatch() - } - - /** - * Changes pointer interaction mode. Look mode will set camera orbitMode to false. - */ - set pointerActive (value: PointerMode) { - if (value === this._pointerActive) return - this._pointerActive = value - this._onPointerModeChanged.dispatch() - } - - /** - * Event called when pointer interaction mode changes. - */ - get onPointerModeChanged () { - return this._onPointerModeChanged.asEvent() - } - - - /** - * Event called when the pointer is temporarily overriden. - */ - get onPointerOverrideChanged () { - return this._onPointerOverrideChanged.asEvent() - } - - private _onContextMenu = new SimpleEventDispatcher< - THREE.Vector2 | undefined - >() - - /** - * Event called when when context menu could be displayed - */ - get onContextMenu () { - return this._onContextMenu.asEvent() - } - - /** - * Calls context menu action - */ - ContextMenu (position: THREE.Vector2 | undefined) { - this._onContextMenu.dispatch(position) - } - - - /** - * Register inputs handlers for default viewer behavior - */ - registerAll () { - this.keyboard.register() - this.mouse.register() - this.touch.register() - } - - /** - * Unregisters all input handlers - */ - unregisterAll = () => { - this.mouse.unregister() - this.keyboard.unregister() - this.touch.unregister() - } - - /** - * Resets all input state - */ - resetAll () { - this.mouse.reset() - this.keyboard.reset() - this.touch.reset() - } - - dispose(){ - this.unregisterAll() - } -} - -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 -} \ No newline at end of file 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..374108a0d --- /dev/null +++ b/src/vim-web/core-viewers/shared/loadResult.ts @@ -0,0 +1,109 @@ +import { AsyncQueue } from '../../utils/asyncQueue' +import { ControllablePromise } from '../../utils/promise' + +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 ProgressType = 'bytes' | 'percent' + +export interface IProgress { + type: ProgressType + current: number + total?: number +} + +export type LoadResult = ILoadSuccess | TError + +/** @internal */ +export class LoadSuccess implements ILoadSuccess { + readonly isSuccess = true as const + readonly isError = false as const + constructor(readonly vim: T) {} +} + +/** @internal */ +export class LoadError implements ILoadError { + readonly isSuccess = false as const + readonly isError = true as const + constructor( + readonly error: string, + readonly details?: string + ) {} +} + +/** + * Interface for load requests that can be used as a type constraint. + */ +export interface ILoadRequest { + /** Whether the load has finished (successfully or with error). */ + readonly isCompleted: boolean + /** Yields progress updates as they arrive. Use with `for await`. */ + getProgress(): AsyncGenerator + /** + * Returns the final load result (success or error). + * Awaits completion if still loading. Use `result.isSuccess` to discriminate. + */ + getResult(): Promise> + /** + * Convenience method that awaits the result and returns the vim directly. + * @throws Error if the load failed. + */ + getVim(): Promise + /** Cancels the load request. Pending getVim()/getResult() promises will reject/resolve with error. */ + 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. + * @internal + */ +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 + } + + async getVim (): Promise { + const result = await this.getResult() + if (result.isSuccess === false) throw new Error(result.error) + return result.vim + } + + /** @internal */ + pushProgress (progress: IProgress) { + this._progressQueue.push(progress) + } + + /** @internal */ + 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/shared/loadSource.ts b/src/vim-web/core-viewers/shared/loadSource.ts new file mode 100644 index 000000000..9f2652c1f --- /dev/null +++ b/src/vim-web/core-viewers/shared/loadSource.ts @@ -0,0 +1,4 @@ +/** Creates a headers object with a Bearer authorization token. */ +export function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}` } +} diff --git a/src/vim-web/core-viewers/ultra/logger.ts b/src/vim-web/core-viewers/shared/logger.ts similarity index 53% rename from src/vim-web/core-viewers/ultra/logger.ts rename to src/vim-web/core-viewers/shared/logger.ts index ac2023d88..4767f928d 100644 --- a/src/vim-web/core-viewers/ultra/logger.ts +++ b/src/vim-web/core-viewers/shared/logger.ts @@ -1,26 +1,26 @@ export interface ILogger { log(message: string, obj?: any): void - error:(message: string, e :unknown) => void + error(message: string, e: unknown): void } export const defaultLogger: ILogger = { - + log: (message: string, obj?: any) => { const caller = getCaller() - const msg = `VIM Ultra : ${message}` - if(obj){ - console.log(msg, obj, {caller}) - } - else{ - console.log(msg, {caller}) - } + const msg = `VIM: ${message}` + if (obj) { + console.log(msg, obj, { caller }) + } + else { + console.log(msg, { caller }) + } }, error: (message: string, e: unknown) => { - console.error('VIM Ultra ' + message, e) + console.error('VIM: ' + message, e) } } -export function createLogger (onMsg : (str : string) => void) { +export function createLogger (onMsg: (str: string) => void): ILogger { return { log: (str: string) => { defaultLogger.log(str) @@ -33,11 +33,11 @@ export function createLogger (onMsg : (str : string) => void) { } } -function getCaller(): string { - const stack = new Error().stack; - if (!stack) return ''; +function getCaller (): string { + const stack = new Error().stack + if (!stack) return '' const files = stack.split('/') const file = files[files.length - 1] - const clean = file.replace(/\?[^:]+/, ''); // Removes "?t=..." or similar + const clean = file.replace(/\?[^:]+/, '') return clean -} \ 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 deleted file mode 100644 index 63145587f..000000000 --- a/src/vim-web/core-viewers/shared/mouseHandler.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { BaseInputHandler } from "./baseInputHandler"; - -import * as THREE from 'three'; -import * as Utils from "../../utils"; - -type DragCallback = (delta: THREE.Vector2, button: number) => void; - -// Existing MouseHandler class -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(); - - onButtonDown: (pos: THREE.Vector2, button: number) => void; - onButtonUp: (pos: THREE.Vector2, button: number) => void; - onMouseMove: (event: THREE.Vector2) => void; - onDrag: DragCallback; // Callback for drag movement - onClick: (position: THREE.Vector2, ctrl: boolean) => void; - onDoubleClick: (position: THREE.Vector2) => void; - onWheel: (value: number, ctrl: boolean) => void; - onContextMenu: (position: THREE.Vector2) => void; - - constructor(canvas: HTMLCanvasElement) { - super(canvas); - this._capture = new CaptureHandler(canvas); - this._dragHandler = new DragHandler((delta: THREE.Vector2, button:number) => this.onDrag(delta, button)); - } - - 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, 'pointermove', e => { this.handlePointerMove(e); }); - this.reg(this._canvas, 'wheel', e => { this.onMouseScroll(e); }); - } - - dispose(): void { - this.unregister(); - } - - private handlePointerDown(event: PointerEvent): void { - if (event.pointerType !== 'mouse') return; // We don't handle touch yet - - 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); - this._capture.onPointerDown(event); - event.preventDefault(); - } - - private handlePointerUp(event: PointerEvent): void { - if (event.pointerType !== 'mouse') return; - event.preventDefault(); - - const pos = this.relativePosition(event); - - // Button up event - this.onButtonUp?.(pos, event.button); - this._capture.onPointerUp(event); - this._dragHandler.onPointerUp(); - this._clickHandler.onPointerUp(); - - - // Click type event - if(this._doubleClickHandler.isDoubleClick(event)){ - this.handleDoubleClick(event); - return - } - if(this._clickHandler.isClick(event)){ - this.handleMouseClick(event); - return - } - - this.handleContextMenu(event); - - } - - 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 modif = event.getModifierState('Shift') || event.getModifierState('Control'); - this.onClick?.(pos, modif); - } - - 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)); - } - - - private handlePointerMove(event: PointerEvent): void { - if (event.pointerType !== 'mouse') return; - this._canvas.focus(); - const pos = this.relativePosition(event); - this._dragHandler.onPointerMove(pos); - this._clickHandler.onPointerMove(pos); - this.onMouseMove?.(pos); - } - - private async handleDoubleClick(event: MouseEvent): Promise { - const pos = this.relativePosition(event); - this.onDoubleClick?.(pos); - event.preventDefault(); - } - - private onMouseScroll(event: WheelEvent): void { - this.onWheel?.(Math.sign(event.deltaY), event.ctrlKey); - event.preventDefault(); - } - - private relativePosition(event: PointerEvent | MouseEvent): THREE.Vector2 { - const rect = this._canvas.getBoundingClientRect(); - return new THREE.Vector2( - event.offsetX / rect.width, - event.offsetY / rect.height - ); - } -} - -/** - * 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; - } - - onPointerDown(event: PointerEvent): void { - this.release() - this._canvas.setPointerCapture(event.pointerId); - this._id = event.pointerId; - } - - onPointerUp(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(); - 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) { - this._moved = true; - } - } - - onPointerUp(): void { } - - isClick(event: PointerEvent): boolean { - if (event.button !== 0) return false; // Only left button - return !this._moved; - } -} - -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(); - const currentPosition = new THREE.Vector2(event.clientX, event.clientY); - const timeDiff = currentTime - this._lastClickTime; - - const isClose = - this._lastClickPosition !== null && - this._lastClickPosition.distanceTo(currentPosition) < this._positionThreshold; - - const isWithinTime = timeDiff < this._clickDelay; - - this._lastClickTime = currentTime; - this._lastClickPosition = currentPosition; - - return isClose && isWithinTime; - } -} - -class DragHandler { - private _lastDragPosition:THREE.Vector2 | null = null; - private _button: number; - - private _onDrag: DragCallback; - - - 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 = pos; - 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._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); - } - } - - /** - * Ends the drag operation and resets the last drag position. - */ - onPointerUp(): void { - this._lastDragPosition = null; - } - -} diff --git a/src/vim-web/core-viewers/shared/raycaster.ts b/src/vim-web/core-viewers/shared/raycaster.ts index ffaca3b1f..48654f2e5 100644 --- a/src/vim-web/core-viewers/shared/raycaster.ts +++ b/src/vim-web/core-viewers/shared/raycaster.ts @@ -4,9 +4,9 @@ export interface IRaycastResult{ /** The model Object hit */ object: T | undefined; - /** The 3D world position of the hit point */ + /** The 3D world position of the hit point, in Z-up space (X = right, Y = forward, Z = up). */ worldPosition: THREE.Vector3; - /** The surface normal at the hit point */ + /** The surface normal at the hit point, in Z-up space. */ worldNormal: THREE.Vector3; } @@ -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. + * @param position - The world position to raycast through (Z-up). + * @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>; } diff --git a/src/vim-web/core-viewers/shared/selection.ts b/src/vim-web/core-viewers/shared/selection.ts index eeb78d9b8..e4dda78fa 100644 --- a/src/vim-web/core-viewers/shared/selection.ts +++ b/src/vim-web/core-viewers/shared/selection.ts @@ -1,17 +1,83 @@ -import { ISignal, SignalDispatcher } from "ste-signals"; +import type { ISignal } from './events' +import { SignalDispatcher } from "ste-signals"; import { IVimElement, IVim } from "./vim"; import { THREE } from "../.."; import { DebouncedSignal } from "../../utils"; +/** @internal */ export interface ISelectionAdapter { outline(target: T, state: boolean): void; } /** + * Public interface for the selection manager. + * + * In the WebGL viewer, `T` is {@link ISelectable} — a union of `IElement3D` and `IMarker`. + * Use the `type` discriminant (`'Element3D'` or `'Marker'`) to narrow: + * + * @example + * ```ts + * const selected = viewer.core.selection.getAll() + * for (const item of selected) { + * if (item.type === 'Element3D') { + * // item is IElement3D — has .hasMesh, .getBimElement(), etc. + * } + * } + * + * // Common operations + * viewer.core.selection.select(element) // Replace selection + * viewer.core.selection.add(element) // Add to selection + * viewer.core.selection.clear() // Clear all + * viewer.core.selection.onSelectionChanged.sub(() => { ... }) // React to changes + * ``` + */ +export interface ISelection { + /** If true, selecting an already-selected single object toggles it off. */ + toggleOnRepeatSelect: boolean + /** If false, all selection operations are no-ops. */ + enabled: boolean + /** Returns true if the target is currently selected. */ + has(target: T): boolean + /** Returns the number of currently selected objects. */ + count(): number + /** Returns true if at least one object is selected. */ + any(): boolean + /** Signal that fires whenever the selection changes. */ + readonly onSelectionChanged: ISignal + /** Replaces the entire selection with the given object. */ + select(object: T): void + /** Replaces the entire selection with the given objects. */ + select(objects: T[]): void + /** Toggles the selection state of the given object. */ + toggle(object: T): void + /** Toggles the selection state of the given objects. */ + toggle(objects: T[]): void + /** Adds the given object to the selection. */ + add(object: T): void + /** Adds the given objects to the selection. */ + add(objects: T[]): void + /** Removes the given object from the selection. */ + remove(object: T): void + /** Removes the given objects from the selection. */ + remove(objects: T[]): void + /** Clears the entire selection. */ + clear(): void + /** Returns an array of all currently selected objects. */ + getAll(): T[] + /** Returns all selected objects belonging to a specific VIM model. */ + getFromVim(vim: IVim): T[] + /** Removes all selected objects that belong to a specific VIM model. */ + removeFromVim(vim: IVim): void + /** Computes the bounding box encompassing all selected objects, in Z-up world space (X = right, Y = forward, Z = up). */ + getBoundingBox(): Promise +} + +/** + * @internal * Selection manager that supports adding, removing, toggling, and querying selected objects. * The selection change signal is debounced to dispatch only once per animation frame. */ -export class Selection{ +export class Selection implements ISelection { private _onSelectionChanged = new DebouncedSignal(); private _selection = new Set(); private _adapter: ISelectionAdapter; @@ -29,6 +95,7 @@ export class Selection{ /** * Creates a new Selection manager. * @param adapter - Adapter responsible for visual selection feedback. + * @internal */ constructor(adapter: ISelectionAdapter) { this._adapter = adapter; diff --git a/src/vim-web/core-viewers/shared/touchHandler.ts b/src/vim-web/core-viewers/shared/touchHandler.ts deleted file mode 100644 index e6e428d1b..000000000 --- a/src/vim-web/core-viewers/shared/touchHandler.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * @module viw-webgl-viewer/inputs - */ - -import * as THREE from 'three' -import { BaseInputHandler } from './baseInputHandler'; - -/** - * 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 - - onTap: (position: THREE.Vector2) => void - onDoubleTap: (position: THREE.Vector2) => void - onDrag: (delta: THREE.Vector2) => void - onDoubleDrag: (delta: THREE.Vector2) => void - onPinchOrSpread: (delta: number) => void - - 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 - private _touchStartTime: number | undefined = undefined // In ms since epoch - private _lastTapMs: number | undefined - private _touchStart: THREE.Vector2 | undefined - - protected override addListeners (): void { - this.reg(this._canvas, 'touchstart', this.onTouchStart) - this.reg(this._canvas, 'touchend', this.onTouchEnd) - this.reg(this._canvas, 'touchmove', this.onTouchMove) - } - - override reset = () => { - this._touch = this._touch1 = this._touch2 = this._touchStartTime = undefined - } - - private _onTap = (position: THREE.Vector2) => { - const time = Date.now() - const double = - this._lastTapMs && time - this._lastTapMs < this.DOUBLE_TAP_DELAY_MS - this._lastTapMs = time - - if(double) - this._onTap?.(position) - else - this.onDoubleTap?.(position) - } - - private onTouchStart = (event: any) => { - event.preventDefault() // prevent scrolling - if (!event || !event.touches || !event.touches.length) { - return - } - this._touchStartTime = Date.now() - - if (event.touches.length === 1) { - this._touch = this.touchToVector(event.touches[0]) - this._touch1 = this._touch2 = undefined - } 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._touchStart = this._touch - } - - 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 - } - - /* - 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().move2(move, 'XY') - } - */ - - /* - private onPinchOrSpread = (delta: number) => { - 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') - } - } - */ - - private onTouchMove = (event: any) => { - event.preventDefault() - if (!event || !event.touches || !event.touches.length) return - if (!this._touch) 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) - return - } - - if (!this._touch1 || !this._touch2) 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) - ) - - const zoom = p1.distanceTo(p2) - const prevZoom = this._touch1.distanceTo(this._touch2) - const min = Math.min(size.x, size.y) - // -1 to invert movement - const zoomDelta = (zoom - prevZoom) / -min - - this._touch = p - this._touch1 = p1 - this._touch2 = p2 - - if (moveDelta.length() > Math.abs(zoomDelta)) { - this.onDoubleDrag(moveDelta) - } else { - this.onPinchOrSpread(zoomDelta) - } - } - } - - private onTouchEnd = (event: any) => { - if (this.isSingleTouch() && this._touchStart && this._touch) { - 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 - ) { - this._onTap(this._touch) - } - } - this.reset() - } - - private isSingleTouch (): boolean { - return ( - this._touch !== undefined && - this._touchStartTime !== undefined && - this._touch1 === undefined && - this._touch2 === undefined - ) - } - - private touchToVector (touch: any) { - return new THREE.Vector2(touch.pageX, touch.pageY) - } - - private average (p1: THREE.Vector2, p2: THREE.Vector2): THREE.Vector2 { - return p1.clone().lerp(p2, 0.5) - } - - /** - * 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/shared/vim.ts b/src/vim-web/core-viewers/shared/vim.ts index 6d122a711..c48271b40 100644 --- a/src/vim-web/core-viewers/shared/vim.ts +++ b/src/vim-web/core-viewers/shared/vim.ts @@ -1,14 +1,14 @@ -import { THREE } from "../.." +import * as THREE from 'three' /** * Interface for a Vim element. */ export interface IVimElement{ - + /** * The vim from which this object came. */ - vim: IVim + vim: IVim | undefined /** * The bounding box of the object. @@ -21,37 +21,36 @@ export interface IVimElement{ * @template T - The type of element contained in the Vim. */ export interface IVim { + /** + * Stable index assigned by the viewer (0-254). + * Used by VimCollection for O(1) lookup. + */ + readonly vimIndex: number + /** * Retrieves the element associated with the specified instance index. * @param instance - The instance index of the of one of the instance included in the element. * @returns The object corresponding to the instance, or undefined if not found. */ getElement(instance: number): T | undefined - + /** * Retrieves the element associated with the specified id. - * @param id - The element ID to retrieve objects for. + * @param id - The element ID to retrieve objects for (number or bigint). * @returns An array of element corresponding to the given id. */ - getElementsFromId(id: number): T[] - + getElementsFromId(id: number | bigint): T[] + /** * Retrieves the element associated with the given index. * @param element - The index of the element. * @returns The element corresponding to the element index, or undefined if not found. */ getElementFromIndex(element: number): T | undefined - + /** * Retrieves all elements within the Vim. * @returns An array of all Vim objects. */ getAllElements(): T[] - - /** - * Retrieves the bounding box of the Vim object. - * @returns The bounding box of the Vim object. - */ - getBoundingBox(): Promise - } \ 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..ccca2bc69 --- /dev/null +++ b/src/vim-web/core-viewers/shared/vimCollection.ts @@ -0,0 +1,134 @@ +import type { ISignal } from './events' +import { SignalDispatcher } 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 +} + +/** + * @internal + * Maximum number of vims that can be loaded simultaneously. + * 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 = 255 + +/** + * @internal + * Manages a collection of Vim objects with stable IDs. + * + * Each vim is assigned a stable ID (0-254) that persists for its lifetime. + * IDs are allocated sequentially and only reused after all 255 have been used. + */ +export class VimCollection> implements IVimCollection { + private _vimsById: (T | undefined)[] = new Array(MAX_VIMS).fill(undefined) + private _nextId = 0 + private _freedIds: number[] = [] + private _count = 0 + private _onChanged = new SignalDispatcher() + + get onChanged (): ISignal { + return this._onChanged.asEvent() + } + + /** + * Allocates a stable ID for a new vim. + * Fresh IDs are allocated sequentially (0, 1, 2, ..., 254). + * Freed IDs are only reused after all 255 have been allocated once. + * @returns The allocated ID, or undefined if all 255 IDs are in use + */ + allocateId (): number | undefined { + if (this._nextId < MAX_VIMS) { + return this._nextId++ + } + if (this._freedIds.length > 0) { + return this._freedIds.pop() + } + return undefined + } + + /** Whether the collection has reached maximum capacity. */ + get isFull (): boolean { + return this._nextId >= MAX_VIMS && this._freedIds.length === 0 + } + + get count (): number { + return this._count + } + + add (vim: T): void { + const id = vim.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() + } + + remove (vim: T): void { + const id = vim.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() + } + + getFromId (id: number): T | undefined { + if (id < 0 || id >= MAX_VIMS) return undefined + return this._vimsById[id] + } + + has (vim: T): boolean { + const id = vim.vimIndex + return this._vimsById[id] === vim + } + + getAll (): T[] { + return this._vimsById.filter((v): v is T => v !== undefined) + } + + 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/ultra/camera.ts b/src/vim-web/core-viewers/ultra/camera.ts index cb4e6523a..616d7e9dd 100644 --- a/src/vim-web/core-viewers/ultra/camera.ts +++ b/src/vim-web/core-viewers/ultra/camera.ts @@ -1,180 +1,171 @@ import { RpcSafeClient } from './rpcSafeClient' -import { Element3D } from './element3d' -import { Vim } from './vim' +import type { IUltraElement3D } from './element3d' +import type { IUltraVim } from './vim' import { Segment } from './rpcTypes' import * as THREE from 'three' /** - * Interface defining camera control operations in the 3D viewer - * @interface + * Camera movement operations obtained via `camera.snap()` or `camera.lerp(duration)`. + * + * All positions use **Z-up**: X = right, Y = forward, Z = up. + * + * @example + * ```ts + * camera.lerp(1).frame(element) // Animated frame + * camera.snap().set(position, target) // Instant position/target + * camera.lerp(0.5).frame('all') // Animated frame all + * ``` */ -export interface ICamera { +export interface IUltraCameraMovement { /** - * Frames all Vim models in the viewer to fit within the camera view - * @param {number} [blendTime=0.5] - Animation duration in seconds - * @returns {Promise} Promise resolving to the final camera position segment + * Frames the camera to fit the specified target. + * @param target - Element, vim, bounding box, or 'all' to frame everything. */ - frameAll(blendTime?: number): Promise + frame(target: IUltraElement3D | IUltraVim | THREE.Box3 | 'all'): Promise /** - * Frames a specified bounding box in the viewer - * @param {Box3} box - The 3D bounding box to frame - * @param {number} [blendTime=0.5] - Animation duration in seconds - * @returns {Promise} Promise resolving to the final camera position segment + * Sets the camera position and target. + * @param position - The new camera position (Z-up). + * @param target - The new look-at target (Z-up). */ - frameBox(box: THREE.Box3, blendTime?: number): Promise + set(position: THREE.Vector3, target: THREE.Vector3): void /** - * Frames specified nodes of a Vim model in the camera view - * @param {Vim} vim - The target Vim model - * @param {number[] | 'all'} nodes - Array of node indices or 'all' for entire model - * @param {number} [blendTime=0.5] - Animation duration in seconds - * @returns {Promise} Promise resolving to the final camera position segment + * Resets the camera to its last saved position. */ - frameVim(vim: Vim, nodes: number[] | 'all', blendTime?: number): Promise + reset(): void +} +/** + * Public interface for the Ultra camera. + * Uses the same snap/lerp pattern as the WebGL camera. + */ +export interface IUltraCamera { /** - * Frames a specific object in the camera view - * @param {Element3D} object - The target object to frame - * @param {number} [blendTime=0.5] - Animation duration in seconds - * @returns {Promise} Promise resolving to the final camera position segment + * Returns a camera movement interface that executes instantly (blendTime = 0). */ - frameObject(object: Element3D, blendTime?: number): Promise + snap(): IUltraCameraMovement /** - * Saves the current camera position for later restoration - * @param {Segment} [segment] - Optional specific camera position to save + * Returns a camera movement interface that animates over the given duration. + * @param duration - Animation duration in seconds. Defaults to 0.5. */ - save(segment?: Segment): void + lerp(duration?: number): IUltraCameraMovement /** - * Controls the rendering state of the viewer - * @param {boolean} value - True to pause, false to resume rendering + * Saves the current camera position for later restoration via `reset()`. + * @param segment - Optional specific camera position to save. */ - pause(value: boolean): void + save(segment?: Segment): void /** - * Restores the camera to its previously saved position - * Initially that will be the first call to a Frame method - * @param {number} [blendTime=0.5] - Animation duration in seconds + * Controls the rendering state of the viewer. + * @param value - True to pause, false to resume rendering. */ - restoreSavedPosition(blendTime?: number): void + pause(value: boolean): void } -/** - * Implements camera control operations for the 3D viewer - * @class - */ -export class Camera implements ICamera { +/** @internal */ +export class Camera implements IUltraCamera { private _rpc: RpcSafeClient - private _lastPosition : Segment | undefined - private _defaultBlendTime = 0.5 + private _lastPosition: Segment | undefined private _savedPosition: Segment | undefined - - /** - * Creates a new Camera instance - * @param rpc - RPC client for camera communication - */ - constructor(rpc: RpcSafeClient){ + + constructor (rpc: RpcSafeClient) { this._rpc = rpc } - /** - * Saves the current camera position for later restoration - * @param segment - Optional segment to save as the camera position - */ - async save(segment?: Segment){ - this._savedPosition = segment ?? await this._rpc.RPCGetCameraView() + snap (): IUltraCameraMovement { + return new CameraMovement(this, 0) } - /** - * Resets the camera to the last saved position - */ - restoreSavedPosition(blendTime: number = this._defaultBlendTime){ - if(!this._savedPosition) return - this._rpc.RPCSetCameraView(this._savedPosition, blendTime) + lerp (duration: number = 0.5): IUltraCameraMovement { + return new CameraMovement(this, duration) } - - /** - * Restores the camera to its last tracked position - * @param blendTime - Duration of the camera animation in seconds - */ - restoreLastPosition(blendTime: number = this._defaultBlendTime){ - if(this._lastPosition?.isValid()){ - console.log('Restoring camera position: ', this._lastPosition) - this._rpc.RPCSetCameraView(this._lastPosition, blendTime) - } + + async save (segment?: Segment) { + this._savedPosition = segment ?? await this._rpc.RPCGetCameraView() } + pause (value: boolean) { + this._rpc.RPCPauseRendering(value) + } - /** - * Handles camera initialization when connection is established - */ - onConnect(){ - this.set(new THREE.Vector3(-1000, 1000, 1000), new THREE.Vector3(0, 0, 0), 0) - this.restoreLastPosition() + /** @internal */ + onConnect () { + this._setView(new Segment(new THREE.Vector3(-1000, 1000, 1000), new THREE.Vector3(0, 0, 0)), 0) + this._restoreLastPosition() } - onCameraPose(pose: Segment){ + /** @internal */ + onCameraPose (pose: Segment) { this._lastPosition = pose } - set(position: THREE.Vector3, target: THREE.Vector3, blendTime: number = this._defaultBlendTime){ - this._rpc.RPCSetCameraView(new Segment(position, target), blendTime) + /** @internal — called by CameraMovement */ + _frameAll (blendTime: number): Promise { + return this._frameAndSave(this._rpc.RPCFrameScene(blendTime)) } - /** - * Pauses or resumes rendering - * @param value - True to pause rendering, false to resume - */ - pause(value: boolean){ - this._rpc.RPCPauseRendering(value) + /** @internal */ + _frameBox (box: THREE.Box3, blendTime: number): Promise { + return this._frameAndSave(this._rpc.RPCFrameAABB(box, blendTime)) } - /** - * Frames all vims in the viewer to fit within the camera view - * @param blendTime - Duration of the camera animation in seconds (defaults to 0.5) - * @returns Promise that resolves when the framing animation is complete - */ - async frameAll (blendTime: number = this._defaultBlendTime): Promise { - const segment = await this._rpc.RPCFrameScene(blendTime) - this._savedPosition = this._savedPosition ?? segment - return segment + /** @internal */ + _frameVim (vim: IUltraVim, blendTime: number): Promise { + return this._frameAndSave(this._rpc.RPCFrameVim(vim.handle, blendTime)) } - /** - * Frames a specific bounding box in the viewer - * @param box - The 3D bounding box to frame in the camera view - * @param blendTime - Duration of the camera animation in seconds (defaults to 0.5) - */ - async frameBox(box: THREE.Box3, blendTime: number = this._defaultBlendTime) : Promise { - - const segment = await this._rpc.RPCFrameAABB(box, blendTime) - this._savedPosition = this._savedPosition ?? segment - return segment + /** @internal */ + _frameObject (object: IUltraElement3D, blendTime: number): Promise { + return this._frameAndSave(this._rpc.RPCFrameElements(object.vimHandle, [object.element], blendTime)) } - /** - * Frames specific nodes of a Vim model in the camera view - * @param vim - The Vim model containing the nodes to frame - * @param elements - Array of element indices to frame, or 'all' to frame the entire model - * @param blendTime - Duration of the camera animation in seconds (defaults to 0.5) - * @returns Promise that resolves when the framing animation is complete - */ - async frameVim(vim: Vim, elements: number[] | 'all', blendTime: number = this._defaultBlendTime): Promise { - let segment: Segment | undefined - if (elements === 'all') { - segment = await this._rpc.RPCFrameVim(vim.handle, blendTime); - } else { - segment = await this._rpc.RPCFrameElements(vim.handle, elements, blendTime); + /** @internal */ + _setView (segment: Segment, blendTime: number) { + this._rpc.RPCSetCameraView(segment, blendTime) + } + + /** @internal */ + _reset (blendTime: number) { + if (!this._savedPosition) return + this._rpc.RPCSetCameraView(this._savedPosition, blendTime) + } + + private _restoreLastPosition () { + if (this._lastPosition?.isValid()) { + console.log('Restoring camera position: ', this._lastPosition) + this._rpc.RPCSetCameraView(this._lastPosition, 0.5) } - this._savedPosition = this._savedPosition ?? segment - return segment } - async frameObject(object: Element3D, blendTime: number = this._defaultBlendTime) : Promise { - const segment = await this._rpc.RPCFrameElements(object.vim.handle, [object.element], blendTime) + private async _frameAndSave (promise: Promise): Promise { + const segment = await promise this._savedPosition = this._savedPosition ?? segment return segment } -} \ No newline at end of file +} + +/** + * @internal + * Captures a blend time and delegates to Camera internals. + */ +class CameraMovement implements IUltraCameraMovement { + constructor (private _camera: Camera, private _blendTime: number) {} + + frame (target: IUltraElement3D | IUltraVim | THREE.Box3 | 'all'): Promise { + if (target === 'all') return this._camera._frameAll(this._blendTime) + if (target instanceof THREE.Box3) return this._camera._frameBox(target, this._blendTime) + if ('getAllElements' in target) return this._camera._frameVim(target as IUltraVim, this._blendTime) + return this._camera._frameObject(target as IUltraElement3D, this._blendTime) + } + + set (position: THREE.Vector3, target: THREE.Vector3): void { + this._camera._setView(new Segment(position, target), this._blendTime) + } + + reset (): void { + this._camera._reset(this._blendTime) + } +} diff --git a/src/vim-web/core-viewers/ultra/colorManager.ts b/src/vim-web/core-viewers/ultra/colorManager.ts index bc0245e64..60107fb5e 100644 --- a/src/vim-web/core-viewers/ultra/colorManager.ts +++ b/src/vim-web/core-viewers/ultra/colorManager.ts @@ -1,16 +1,27 @@ import { MaterialHandles } from './rpcClient' import { RpcSafeClient } from './rpcSafeClient' -import { RemoteColor } from './remoteColor' +import { RemoteColor, type IRemoteColor } from './remoteColor' import * as RpcUtils from './rpcUtils' import * as THREE from 'three' const MAX_BATCH_SIZE = 3000 /** - * Manages the creation, caching, and deletion of color instances. - * Handles batched deletion of colors to optimize RPC calls. + * Public interface for the Ultra color manager. + * Creates, caches, and destroys remote color instances. */ -export class ColorManager { +export interface IColorManager { + getColor(color: THREE.Color): Promise + getColors(colors: (THREE.Color | undefined)[]): Promise + getFromId(id: number): IRemoteColor | undefined + destroy(color: IRemoteColor): void + clear(): void +} + +/** + * @internal + */ +export class ColorManager implements IColorManager { private _rpc: RpcSafeClient private _hexToColor = new Map() private _idToColor = new Map() diff --git a/src/vim-web/core-viewers/ultra/decoder.ts b/src/vim-web/core-viewers/ultra/decoder.ts index 4c1a88ee9..046c61cab 100644 --- a/src/vim-web/core-viewers/ultra/decoder.ts +++ b/src/vim-web/core-viewers/ultra/decoder.ts @@ -1,6 +1,6 @@ import { WebGLRenderer } from './streamRenderer' import type { VideoFrameMessage } from './protocol' -import { ILogger } from './logger' +import { ILogger } from '../shared/logger' /** * Configuration for the video decoder. @@ -28,7 +28,7 @@ const RenderDelayMs = 500 /** * Interface defining the basic decoder operations */ -export interface IDecoder { +export interface IUltraDecoder { /** Indicates if the decoder is ready to process frames */ ready: boolean; /** Indicates if the decoder is currently paused */ @@ -40,10 +40,9 @@ export interface IDecoder { } /** - * Decoder class responsible for decoding video frames and rendering them using WebGL. - * Handles frame queueing, decoding, and rendering through WebGL. + * @internal */ -export class Decoder implements IDecoder { +export class Decoder implements IUltraDecoder { private _decoder: globalThis.VideoDecoder | undefined private readonly _canvas: OffscreenCanvas private readonly _renderer: WebGLRenderer diff --git a/src/vim-web/core-viewers/ultra/decoderWithWorker.ts b/src/vim-web/core-viewers/ultra/decoderWithWorker.ts index ea46108fb..c5e9bfe4c 100644 --- a/src/vim-web/core-viewers/ultra/decoderWithWorker.ts +++ b/src/vim-web/core-viewers/ultra/decoderWithWorker.ts @@ -1,6 +1,6 @@ import { WebGLRenderer } from './streamRenderer' import type { VideoFrameMessage } from './protocol' -import { ILogger } from './logger' +import { ILogger } from '../shared/logger' import DecoderWorker from './decoderWorker' const RenderDelayMs = 500 @@ -13,7 +13,7 @@ export enum FrameType { } /** - * Decoder class responsible for decoding video frames and rendering them using WebGL. + * @internal */ export class DecoderWithWorker { private readonly _canvas: OffscreenCanvas diff --git a/src/vim-web/core-viewers/ultra/element3d.ts b/src/vim-web/core-viewers/ultra/element3d.ts index 32d467296..d4d3a611f 100644 --- a/src/vim-web/core-viewers/ultra/element3d.ts +++ b/src/vim-web/core-viewers/ultra/element3d.ts @@ -1,13 +1,50 @@ import { IVimElement } from "../shared/vim"; import { VisibilityState } from "./visibility"; import { Vim } from "./vim"; +import type { IUltraVim } from "./vim"; import * as THREE from "three"; /** - * Represents a single 3D element within a `Vim` model. - * Provides access to per-instance state, color, and bounding box. + * Public interface for an Ultra 3D element. + * Provides visual state control (visibility, outline, color) and bounding box queries. + * + * **WebGL vs Ultra parity:** Ultra elements do NOT expose BIM data methods + * (`getBimElement`, `getBimParameters`), geometry metadata (`hasMesh`, `isRoom`, + * `elementId`, `instances`), or `getCenter()`. The Ultra viewer renders server-side, + * so BIM data is not available on the client. For BIM queries, use the WebGL viewer. + * + * @example + * ```ts + * element.visible = false // Hide + * element.outline = true // Highlight + * element.ghosted = true // Ghosted appearance + * element.color = new THREE.Color(0xff0000) // Override color + * const box = await element.getBoundingBox() // Get bounding box + * ``` */ -export class Element3D implements IVimElement { +export interface IUltraElement3D extends IVimElement { + /** The parent vim this element belongs to. */ + readonly vim: IUltraVim + /** The BIM element index. */ + readonly element: number + /** The handle of the parent vim on the server. */ + readonly vimHandle: number + /** Whether the element is visible (not hidden). Preserves highlight state. */ + visible: boolean + /** Whether the element has an outline highlight. Preserves visibility state. */ + outline: boolean + /** Whether the element is rendered as a ghost. Preserves highlight state. */ + ghosted: boolean + /** The display color override. Set to undefined to revert to default. */ + color: THREE.Color | undefined + /** Retrieves the bounding box in Z-up world space (X = right, Y = forward, Z = up), or undefined if the element is abstract. */ + getBoundingBox(): Promise +} + +/** + * @internal + */ +export class Element3D implements IUltraElement3D { /** * The parent `Vim` instance this element belongs to. */ @@ -48,6 +85,50 @@ export class Element3D implements IVimElement { this.vim.visibility.setStateForElement(this.element, state); } + /** + * Whether the element is visible (not hidden). Preserves highlight state. + */ + get visible(): boolean { + const s = this.state; + return s !== VisibilityState.HIDDEN && s !== VisibilityState.HIDDEN_HIGHLIGHTED; + } + set visible(value: boolean) { + const highlighted = this.state >= 16; + if (value) { + this.state = highlighted ? VisibilityState.HIGHLIGHTED : VisibilityState.VISIBLE; + } else { + this.state = highlighted ? VisibilityState.HIDDEN_HIGHLIGHTED : VisibilityState.HIDDEN; + } + } + + /** + * Whether the element has an outline highlight. Preserves visibility state. + */ + get outline(): boolean { + return this.state >= 16; + } + set outline(value: boolean) { + const s = this.state; + const baseState = s >= 16 ? s - 16 : s; + this.state = value ? baseState + 16 : baseState; + } + + /** + * Whether the element is rendered as a ghost. Preserves highlight state. + */ + get ghosted(): boolean { + const s = this.state; + return s === VisibilityState.GHOSTED || s === VisibilityState.GHOSTED_HIGHLIGHTED; + } + set ghosted(value: boolean) { + const highlighted = this.state >= 16; + if (value) { + this.state = highlighted ? VisibilityState.GHOSTED_HIGHLIGHTED : VisibilityState.GHOSTED; + } else { + this.state = highlighted ? VisibilityState.HIGHLIGHTED : VisibilityState.VISIBLE; + } + } + /** * Gets or sets the color override of the element. */ @@ -64,6 +145,6 @@ export class Element3D implements IVimElement { * @returns A promise resolving to the element's bounding box. */ async getBoundingBox(): Promise { - return this.vim.getBoundingBoxForElements([this.element]); + return this.vim.scene.getBoundingBoxForElements([this.element]) } } diff --git a/src/vim-web/core-viewers/ultra/index.ts b/src/vim-web/core-viewers/ultra/index.ts index a67413872..d3c0f6d1e 100644 --- a/src/vim-web/core-viewers/ultra/index.ts +++ b/src/vim-web/core-viewers/ultra/index.ts @@ -1,38 +1,53 @@ import "./style.css" -// Full export -export * from './viewer'; - -// Partial export -// We don't want to reexport THREE.Box3 and THREE.Vector3 -export {Segment, type SectionBoxState, type HitCheckResult, type VimStatus} from './rpcTypes' - -// We don't want to export RPCClient -export {materialHandles, MaterialHandles, type MaterialHandle, } from './rpcClient' -export {InputMode, VimLoadingStatus} from './rpcSafeClient'; -export {VisibilityState} from './visibility'; //Runtime values for enum - -// Type exports -export type * from './camera'; -export type * from './colorManager'; -export type * from './decoder'; -export type * from './visibility'; -export type * from './element3d'; -export type * from './inputAdapter'; -export type * from './loadRequest'; -export type * from './logger'; -export type * from './protocol'; -export type * from './raycaster'; -export type * from './remoteColor'; -export type * from './renderer'; -export type * from './rpcClient'; -export type * from './rpcMarshal'; -export type * from './rpcSafeClient'; -export type * from './sectionBox'; -export type * from './selection'; -export type * from './socketClient'; -export type * from './streamLogger'; -export type * from './streamRenderer'; -export type * from './viewport'; -export type * from './vim'; -export type * from './vimCollection'; +// Viewer +export type { IUltraViewer as Viewer } from './viewer' +export { INVALID_HANDLE } from './viewer' +export { createCoreUltraViewer as createViewer } from './viewer' + +// Data model (interfaces — concrete classes are @internal) +export type { IUltraElement3D } from './element3d' +export type { IUltraVim } from './vim' +export type { IUltraScene } from './scene' + +// Viewer component interfaces (returned by Viewer getters) +export type { IUltraCamera, IUltraCameraMovement } from './camera' +export type { IUltraRenderer } from './renderer' +export type { IUltraDecoder } from './decoder' +export type { IUltraViewport } from './viewport' +export type { IUltraSelection } from './selection' +export type { IUltraRaycaster, IUltraRaycastResult } from './raycaster' +export type { ILogger } from '../shared/logger' +export type { IUltraSectionBox } from './sectionBox' + +// RPC types +export { Segment } from './rpcTypes' + +// Enums (runtime values) +export { InputMode, VimLoadingStatus } from './rpcSafeClient' +export { VisibilityState } from './visibility' +export type { IVisibilitySynchronizer } from './visibility' + +// Settings +export type { RenderSettings } from './renderer' +export { defaultRenderSettings } from './renderer' +export type { SceneSettings } from './rpcSafeClient' +export { defaultSceneSettings } from './rpcSafeClient' + +// Loading +export type { VimSource, VimLoadingState } from './rpcSafeClient' +export type { IUltraLoadRequest, VimRequestErrorType } from './loadRequest' + +// Connection +export type { ConnectionSettings } from './socketClient' +export type { + ClientState, + ClientError, + ClientStateConnecting, + ClientStateValidating, + ClientStateConnected, + ClientStateDisconnected, + ClientStateCompatibilityError, + ClientStateConnectionError, + ClientStateStreamError, +} from './socketClient' diff --git a/src/vim-web/core-viewers/ultra/inputAdapter.ts b/src/vim-web/core-viewers/ultra/inputAdapter.ts index 48920b67c..6bcbd00ab 100644 --- a/src/vim-web/core-viewers/ultra/inputAdapter.ts +++ b/src/vim-web/core-viewers/ultra/inputAdapter.ts @@ -1,6 +1,6 @@ -import { IInputAdapter } from "../shared/inputAdapter"; -import { InputHandler } from "../shared/inputHandler"; -import { Viewer } from "./viewer"; +import { IInputAdapter } from "../shared/input/inputAdapter"; +import { InputHandler } from "../shared/input/inputHandler"; +import { UltraViewer } from "./viewer"; import * as THREE from 'three'; /** @@ -25,7 +25,7 @@ const CODE_TO_KEYCODE: Record = { * @param viewer - The target viewer. * @returns An `InputHandler` instance wired to the viewer. */ -export function ultraInputAdapter(viewer: Viewer) { +export function ultraInputAdapter(viewer: UltraViewer) { return new InputHandler( viewer.viewport.canvas, createAdapter(viewer), @@ -37,10 +37,10 @@ export function ultraInputAdapter(viewer: Viewer) { * @param viewer - The viewer instance. * @returns A configured `IInputAdapter`. */ -function createAdapter(viewer: Viewer): IInputAdapter { +function createAdapter(viewer: UltraViewer): IInputAdapter { return { init: () => { - viewer.rpc.RPCSetCameraSpeed(10); + // No initialization needed }, orbitCamera: (value: THREE.Vector2) => { // handled server side @@ -57,12 +57,8 @@ 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(); + viewer.camera.lerp().reset(); }, clearSelection: () => { viewer.selection.clear(); @@ -71,7 +67,7 @@ function createAdapter(viewer: Viewer): IInputAdapter { if (viewer.selection.any()) { frameSelection(viewer); } else { - viewer.camera.frameAll(); + viewer.camera.lerp().frame('all'); } }, selectAtPointer: async (pos: THREE.Vector2, add: boolean) => { @@ -89,30 +85,51 @@ function createAdapter(viewer: Viewer): IInputAdapter { frameAtPointer: async (pos: THREE.Vector2) => { const hit = await viewer.raycaster.raycastFromScreen(pos); if (hit) { - viewer.camera.frameObject(hit.object); + viewer.camera.lerp().frame(hit.object); } else { - viewer.camera.frameAll(1); + viewer.camera.lerp(1).frame('all'); } }, - zoom: (value: number) => { - viewer.rpc.RPCMouseScrollEvent(value >= 1 ? 1 : -1); + 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) => { // handled server side }, + pinchStart: () => {}, + pinchZoom: (totalRatio: number) => { + // 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); }, 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); }, }; @@ -121,16 +138,16 @@ function createAdapter(viewer: Viewer): IInputAdapter { /** * Frames the camera around the current selection. */ -async function frameSelection(viewer: Viewer) { +async function frameSelection(viewer: UltraViewer) { const box = await viewer.selection.getBoundingBox(); if (!box) return; - viewer.camera.frameBox(box); + viewer.camera.lerp().frame(box); } /** * Sends a key event to the viewer RPC system. */ -function sendKey(viewer: Viewer, code: string, pressed: boolean): boolean { +function sendKey(viewer: UltraViewer, code: string, pressed: boolean): boolean { const key = CODE_TO_KEYCODE[code]; if (!key) return false; viewer.rpc.RPCKeyEvent(key, pressed); diff --git a/src/vim-web/core-viewers/ultra/loadRequest.ts b/src/vim-web/core-viewers/ultra/loadRequest.ts index 99c1e8e73..4cb4cf483 100644 --- a/src/vim-web/core-viewers/ultra/loadRequest.ts +++ b/src/vim-web/core-viewers/ultra/loadRequest.ts @@ -1,87 +1,43 @@ -import { Vim } from './vim' -import * as Utils from '../../utils' +import { Vim, type IUltraVim } from './vim' +import { + LoadRequest as BaseLoadRequest, + ILoadRequest as BaseILoadRequest, + ILoadError, + IProgress, + LoadSuccess, + LoadError as SharedLoadError +} 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 - constructor (vim: Vim) { - this.vim = vim - } +export interface IUltraLoadError extends ILoadError { + readonly type: VimRequestErrorType } -export class LoadError { - readonly isError = true - readonly isSuccess = false +/** @internal */ +export class LoadError extends SharedLoadError implements IUltraLoadError { 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 interface ILoadRequest { - get isCompleted(): boolean; - getProgress(): AsyncGenerator; - 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() - - 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 IUltraLoadRequest = BaseILoadRequest - onProgress (progress: number) { - this._progress = progress - this._progressPromise.resolve() - this._progressPromise = new Utils.ControllablePromise() +/** @internal */ +export class LoadRequest extends BaseLoadRequest { + onProgress (progress: IProgress) { + 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/raycaster.ts b/src/vim-web/core-viewers/ultra/raycaster.ts index 9d64192c8..968d9a07c 100644 --- a/src/vim-web/core-viewers/ultra/raycaster.ts +++ b/src/vim-web/core-viewers/ultra/raycaster.ts @@ -1,15 +1,16 @@ import * as THREE from "three"; import { Validation } from "../../utils"; import type {IRaycastResult, IRaycaster} from "../shared/raycaster"; -import { Element3D } from "./element3d"; +import { Element3D, type IUltraElement3D } from "./element3d"; import { RpcSafeClient } from "./rpcSafeClient"; -import { IReadonlyVimCollection } from "./vimCollection"; +import type { IReadonlyVimCollection } from "../shared/vimCollection"; +import type { Vim } from "./vim"; -export type IUltraRaycastResult = IRaycastResult; -export type IUltraRaycaster = IRaycaster; +export type IUltraRaycastResult = IRaycastResult; +export type IUltraRaycaster = IRaycaster; /** - * Represents the result of a hit test operation. + * @internal */ export class UltraRaycastResult implements IUltraRaycastResult { @@ -34,22 +35,15 @@ export class UltraRaycastResult implements IUltraRaycastResult { */ /** - * Handles raycasting operations in the Ultra system, enabling picking and - * interaction with 3D objects in the scene. + * @internal */ export class Raycaster implements IUltraRaycaster { private _rpc: RpcSafeClient; - private _vims: IReadonlyVimCollection; + private _vims: IReadonlyVimCollection; - /** - * Creates a new UltraCoreRaycaster instance. - * - * @param {RpcSafeClient} rpc - RPC client for communication with the viewer. - * @param {IReadonlyVimCollection} vims - Collection of VIM instances to manage. - */ constructor( rpc: RpcSafeClient, - vims: IReadonlyVimCollection + vims: IReadonlyVimCollection ) { this._rpc = rpc; this._vims = vims; @@ -68,7 +62,8 @@ export class Raycaster implements IUltraRaycaster { const test = await this._rpc.RPCPerformHitTest(position); if (!test) return undefined; - const vim = this._vims.getFromHandle(test.vimIndex); + // test.vimIndex is the server handle, find vim by handle + const vim = this._vims.getAll().find(v => v.handle === test.vimIndex); if (!vim) return undefined; const object = vim.getElement(test.vimElementIndex); diff --git a/src/vim-web/core-viewers/ultra/remoteColor.ts b/src/vim-web/core-viewers/ultra/remoteColor.ts index 86757ec58..22bab9221 100644 --- a/src/vim-web/core-viewers/ultra/remoteColor.ts +++ b/src/vim-web/core-viewers/ultra/remoteColor.ts @@ -2,10 +2,21 @@ import { ColorManager } from './colorManager'; import * as THREE from 'three'; /** - * Represents a handle to a color in the color management system. - * This class provides access to color components and manages the lifecycle of color instances. + * Public interface for a remote color handle. + * Provides read-only access to color properties and lifecycle management. */ -export class RemoteColor { +export interface IRemoteColor { + readonly id: number + readonly color: THREE.Color + readonly disposed: boolean + readonly hex: number + dispose(): void +} + +/** + * @internal + */ +export class RemoteColor implements IRemoteColor { private _manager: ColorManager; /** Unique identifier for the color instance */ readonly id: number; diff --git a/src/vim-web/core-viewers/ultra/renderer.ts b/src/vim-web/core-viewers/ultra/renderer.ts index 994c9b87b..4dc82f030 100644 --- a/src/vim-web/core-viewers/ultra/renderer.ts +++ b/src/vim-web/core-viewers/ultra/renderer.ts @@ -1,9 +1,10 @@ -import { ISignal, SignalDispatcher } from "ste-signals"; +import type { ISignal } from '../shared/events'; +import { SignalDispatcher } from "ste-signals"; import * as THREE from "three"; import { Validation } from "../../utils"; -import { ILogger } from "./logger"; +import { ILogger } from "../shared/logger"; import { defaultSceneSettings, RpcSafeClient, SceneSettings } from "./rpcSafeClient"; -import { ClientStreamError } from "./socketClient"; +import { ClientStateStreamError } from "./socketClient"; import * as RpcUtils from "./rpcUtils"; @@ -28,7 +29,7 @@ export const defaultRenderSettings: RenderSettings = { /** * Interface defining the basic renderer capabilities */ -export interface IRenderer { +export interface IUltraRenderer { onSceneUpdated: ISignal ghostColor: THREE.Color ghostOpacity: number @@ -43,8 +44,9 @@ export interface IRenderer { /** * Renderer class that handles 3D scene rendering and settings management + * @internal */ -export class Renderer implements IRenderer { +export class Renderer implements IUltraRenderer { private _rpc: RpcSafeClient private _logger : ILogger @@ -72,9 +74,9 @@ export class Renderer implements IRenderer { /** * Validates the connection to the server by attempting to start a scene. - * @returns A promise that resolves to a ClientStreamError if the connection fails, or undefined if successful. + * @returns A promise that resolves to a ClientStateStreamError if the connection fails, or undefined if successful. */ - async validateConnection() : Promise{ + async validateConnection() : Promise{ const success = await this._rpc.RPCStartScene(this._settings) if(success) { this._logger.log('Scene stream started successfully') @@ -163,7 +165,7 @@ export class Renderer implements IRenderer { * @returns Current background color as RGBA */ get backgroundColor(): THREE.Color { - return this._settings.backgroundColor.toThree(); + return this._settings.backgroundColor; } // Setters @@ -252,9 +254,8 @@ export class Renderer implements IRenderer { * @param value - New background color as THREE.Color */ set backgroundColor(value: THREE.Color) { - const color = RpcUtils.RGBAfromThree(value, 1); - if (this._settings.backgroundColor.equals(color)) return; - this._settings.backgroundColor = color; + if (this._settings.backgroundColor.equals(value)) return; + this._settings.backgroundColor = value.clone(); this._updateLighting = true this.requestSettingsUpdate(); } diff --git a/src/vim-web/core-viewers/ultra/rpcSafeClient.ts b/src/vim-web/core-viewers/ultra/rpcSafeClient.ts index 4117654f6..4302a71cf 100644 --- a/src/vim-web/core-viewers/ultra/rpcSafeClient.ts +++ b/src/vim-web/core-viewers/ultra/rpcSafeClient.ts @@ -1,4 +1,6 @@ import * as RpcTypes from "./rpcTypes" +import * as RpcUtils from "./rpcUtils" +import * as THREE from "three" import { MaterialHandle, RpcClient } from "./rpcClient" import { Validation } from "../../utils"; import { batchArray, batchArrays } from "../../utils/array" @@ -17,12 +19,13 @@ export type VimSource = { * URL to the VIM file. * Can be a local path (file://) or remote URL (http:// or https://). */ - url: string; + url: string /** - * Optional authentication token for accessing protected resources. + * Optional HTTP headers for authentication and other purposes. + * Use {@link authHeaders} to create Bearer token headers. */ - authToken?: string; + headers?: Record } /** @@ -85,9 +88,9 @@ export type SceneSettings = { backgroundBlur: number; /** - * Background color in linear RGBA format. + * Background color in linear RGB format. */ - backgroundColor: RpcTypes.RGBA; + backgroundColor: THREE.Color; } /** @@ -99,7 +102,7 @@ export const defaultSceneSettings: SceneSettings = { hdrBackgroundScale: 1.0, hdrBackgroundSaturation: 1.0, backgroundBlur: 1.0, - backgroundColor: new RpcTypes.RGBA(0.9, 0.9, 0.9, 1.0) + backgroundColor: new THREE.Color(0.9, 0.9, 0.9) } /** @@ -137,9 +140,7 @@ export enum VimLoadingStatus { FailedToLoad = 5 } /** - * Provides safe, validated methods to interact with the RpcClient. - * This class wraps low-level RPC calls with input validation, error handling, - * and batching support to ensure robustness and performance when dealing with large data. + * @internal */ export class RpcSafeClient { private readonly rpc: RpcClient @@ -163,6 +164,7 @@ export class RpcSafeClient { * Creates a new RpcSafeClient instance. * @param rpc - The underlying RpcClient used for communication * @param batchSize - Maximum size of batched data for operations (default: 10000) + * @internal */ constructor(rpc: RpcClient, batchSize: number = defaultBatchSize) { this.rpc = rpc @@ -191,7 +193,7 @@ export class RpcSafeClient { Validation.clamp01(s.hdrBackgroundScale), Validation.clamp01(s.hdrBackgroundSaturation), Validation.clamp01(s.backgroundBlur), - Validation.clampRGBA01(s.backgroundColor) + RpcUtils.RGBAfromThree(s.backgroundColor, 1) ), false ) @@ -209,7 +211,7 @@ export class RpcSafeClient { Validation.clamp01(s.hdrBackgroundScale), Validation.clamp01(s.hdrBackgroundSaturation), Validation.clamp01(s.backgroundBlur), - Validation.clampRGBA01(s.backgroundColor) + RpcUtils.RGBAfromThree(s.backgroundColor, 1) ) } @@ -659,7 +661,7 @@ export class RpcSafeClient { // Run return await this.safeCall( - () => this.rpc.RPCLoadVimURL(source.url, source.authToken ?? ""), + () => this.rpc.RPCLoadVimURL(source.url, source.headers?.['Authorization']?.replace('Bearer ', '') ?? ""), INVALID_HANDLE ) } diff --git a/src/vim-web/core-viewers/ultra/scene.ts b/src/vim-web/core-viewers/ultra/scene.ts new file mode 100644 index 000000000..a6e5d9294 --- /dev/null +++ b/src/vim-web/core-viewers/ultra/scene.ts @@ -0,0 +1,60 @@ +import * as THREE from 'three' +import type { IUltraElement3D } from './element3d' +import type { RpcSafeClient } from './rpcSafeClient' + +/** + * Public interface for an Ultra Vim's scene. + * Provides cached geometry information and spatial queries. + */ +export interface IUltraScene { + /** Bounding box of the loaded geometry in Z-up world space (X = right, Y = forward, Z = up). Undefined before load or if empty. */ + getBoundingBox(): THREE.Box3 | undefined + /** Returns elements whose bounding boxes intersect the given box. Box coordinates are in Z-up world space. */ + getObjectsInBox(box: THREE.Box3): IUltraElement3D[] + /** Returns the combined bounding box for the given elements (or all), in Z-up world space. */ + getBoundingBoxForElements(elements: number[] | 'all'): Promise +} + +/** + * @internal + */ +export class UltraScene implements IUltraScene { + private _box: THREE.Box3 | undefined + private readonly _rpc: RpcSafeClient + private readonly _getHandle: () => number + private readonly _isConnected: () => boolean + + constructor( + rpc: RpcSafeClient, + getHandle: () => number, + isConnected: () => boolean + ) { + this._rpc = rpc + this._getHandle = getHandle + this._isConnected = isConnected + } + + /** Called after load to cache the bounding box. */ + setBox(box: THREE.Box3 | undefined) { + this._box = box?.clone() + } + + getBoundingBox(): THREE.Box3 | undefined { + return this._box?.clone() + } + + getObjectsInBox(box: THREE.Box3): IUltraElement3D[] { + throw new Error('Method not implemented.') + } + + async getBoundingBoxForElements(elements: number[] | 'all'): Promise { + if (!this._isConnected() || (elements !== 'all' && elements.length === 0)) { + return undefined + } + const handle = this._getHandle() + if (elements === 'all') { + return await this._rpc.RPCGetAABBForVim(handle) + } + return await this._rpc.RPCGetAABBForElements(handle, elements) + } +} diff --git a/src/vim-web/core-viewers/ultra/sectionBox.ts b/src/vim-web/core-viewers/ultra/sectionBox.ts index db08f464b..491ed06fb 100644 --- a/src/vim-web/core-viewers/ultra/sectionBox.ts +++ b/src/vim-web/core-viewers/ultra/sectionBox.ts @@ -1,13 +1,38 @@ +import type { ISignal } from '../shared/events' import { SignalDispatcher } from "ste-signals" import { RpcSafeClient } from "./rpcSafeClient" import { safeBox } from "../../utils/threeUtils" import * as THREE from "three" -export class SectionBox { +/** + * Public interface for the Ultra section box. + * Controls clipping, visibility, and interactivity of the section box. + * + * @example + * ```ts + * const sb = viewer.sectionBox + * sb.active = true // Enable clipping + * sb.visible = true // Show gizmo + * sb.setBox(await vim.getBoundingBox()) // Fit to model + * ``` + */ +export interface IUltraSectionBox { + readonly onUpdate: ISignal + visible: boolean + interactive: boolean + active: boolean + setBox(box: THREE.Box3): void + getBox(): THREE.Box3 | undefined +} + +/** + * @internal + */ +export class SectionBox implements IUltraSectionBox { private _visible: boolean = false private _interactible: boolean = false - private _clip: boolean = false + private _active: boolean = false private _box : THREE.Box3 | undefined private _rpc: RpcSafeClient @@ -55,14 +80,14 @@ export class SectionBox { let changed = false if(state.visible !== this._visible || state.interactive !== this._interactible || - state.clip !== this._clip || + state.clip !== this._active || state.box !== this._box){ changed = true } this._visible = state.visible this._interactible = state.interactive - this._clip = state.clip + this._active = state.clip this._box = state.box if(changed){ this._onUpdate.dispatch() @@ -73,7 +98,7 @@ export class SectionBox { await this._rpc.RPCSetSectionBox({ visible: this._visible, interactive: this._interactible, - clip: this._clip, + clip: this._active, box: this._box }) } @@ -96,12 +121,12 @@ export class SectionBox { this.scheduleUpdate() } - get clip(): boolean { - return this._clip + get active(): boolean { + return this._active } - set clip(value: boolean) { - this._clip = value + set active(value: boolean) { + this._active = value this.scheduleUpdate() } diff --git a/src/vim-web/core-viewers/ultra/selection.ts b/src/vim-web/core-viewers/ultra/selection.ts index 23dd3dc87..ec45a8e67 100644 --- a/src/vim-web/core-viewers/ultra/selection.ts +++ b/src/vim-web/core-viewers/ultra/selection.ts @@ -1,9 +1,10 @@ -import {Selection, ISelectionAdapter} from "../shared/selection"; -import { Element3D } from "./element3d"; +import {Selection, type ISelection, ISelectionAdapter} from "../shared/selection"; +import { Element3D, type IUltraElement3D } from "./element3d"; import { VisibilityState } from "./visibility"; -export type ISelection = Selection -export function createSelection(): ISelection { +export type IUltraSelection = ISelection +/** @internal */ +export function createSelection(): IUltraSelection { return new Selection(new SelectionAdapter()); } diff --git a/src/vim-web/core-viewers/ultra/socketClient.ts b/src/vim-web/core-viewers/ultra/socketClient.ts index 1f05c230a..54fc710dc 100644 --- a/src/vim-web/core-viewers/ultra/socketClient.ts +++ b/src/vim-web/core-viewers/ultra/socketClient.ts @@ -1,6 +1,6 @@ import { SimpleEventDispatcher } from 'ste-simple-events' import * as Utils from '../../utils' -import { ILogger } from './logger' +import { ILogger } from '../shared/logger' import * as Protocol from './protocol' import { Marshal, ReadMarshal } from './rpcMarshal' import { Segment } from './rpcTypes' @@ -16,7 +16,7 @@ export type ConnectionSettings = { } export type ClientState = ClientStateConnecting | ClientStateConnected | ClientStateDisconnected | ClientStateValidating | ClientError -export type ClientError = ClientStateCompatibilityError | ClientStateConnectionError | ClientStreamError// | other types of errors +export type ClientError = ClientStateCompatibilityError | ClientStateConnectionError | ClientStateStreamError// | other types of errors export type ClientStateConnecting = { status: 'connecting' @@ -44,7 +44,7 @@ export type ClientStateConnectionError = { serverUrl: string } -export type ClientStreamError = { +export type ClientStateStreamError = { status: 'error' error: 'stream' serverUrl: string diff --git a/src/vim-web/core-viewers/ultra/streamLogger.ts b/src/vim-web/core-viewers/ultra/streamLogger.ts index eab55ad70..e64be102d 100644 --- a/src/vim-web/core-viewers/ultra/streamLogger.ts +++ b/src/vim-web/core-viewers/ultra/streamLogger.ts @@ -1,5 +1,5 @@ import type { VideoFrameMessage } from './protocol' -import { ILogger } from './logger' +import { ILogger } from '../shared/logger' export class StreamLogger { private readonly _logger: ILogger diff --git a/src/vim-web/core-viewers/ultra/viewer.ts b/src/vim-web/core-viewers/ultra/viewer.ts index b21a4f14d..c6bc05819 100644 --- a/src/vim-web/core-viewers/ultra/viewer.ts +++ b/src/vim-web/core-viewers/ultra/viewer.ts @@ -1,30 +1,89 @@ -import type { ISimpleEvent } from 'ste-simple-events' -import type {InputHandler} from '../shared' -import { Camera, ICamera } from './camera' +import type { ISimpleEvent } from '../shared/events' +import {type IInputHandler} from '../shared' +import {type InputHandler} from '../shared/input/inputHandler' +import { Camera, IUltraCamera } from './camera' import { ColorManager } from './colorManager' -import { Decoder, IDecoder } from './decoder' +import { Decoder, IUltraDecoder } from './decoder' import { DecoderWithWorker } from './decoderWithWorker' import { ultraInputAdapter } from './inputAdapter' -import { ILoadRequest, LoadRequest } from './loadRequest' -import { defaultLogger, ILogger } from './logger' +import { type IUltraLoadRequest, LoadRequest } from './loadRequest' +import { defaultLogger, ILogger } from '../shared/logger' import { IUltraRaycaster, Raycaster } from './raycaster' -import { IRenderer, Renderer } from './renderer' +import { IUltraRenderer, Renderer } from './renderer' import { RpcClient } from './rpcClient' import { RpcSafeClient, VimSource } from './rpcSafeClient' -import { SectionBox } from './sectionBox' -import { createSelection, ISelection } from './selection' +import { SectionBox, type IUltraSectionBox } from './sectionBox' +import { createSelection, IUltraSelection } from './selection' import { ClientError, ClientState, ConnectionSettings, SocketClient } from './socketClient' -import { IViewport, Viewport } from './viewport' -import { Vim } from './vim' -import { IReadonlyVimCollection, VimCollection } from './vimCollection' +import { IUltraViewport, Viewport } from './viewport' +import { Vim, type IUltraVim } from './vim' +import { VimCollection } from '../shared/vimCollection' export const INVALID_HANDLE = 0xffffffff /** - * The main Viewer class responsible for managing VIM files, + * Public interface for the Ultra viewer (server-side rendering via WebSocket). + * Consumers should use this instead of the concrete class. + * + * **Lifecycle:** Call `connect()` first, then `load()` to stream VIM models + * (auto-populates `vims`). Use `unload(vim)` to remove one, `clear()` to + * remove all, `disconnect()` to close the server connection, and `dispose()` + * to tear down the viewer entirely. + * + * @example + * ```ts + * const viewer = Core.Ultra.createViewer(parentDiv) + * await viewer.connect({ url: 'wss://server:8080' }) + * const vim = await viewer.load({ url: 'model.vim' }).getVim() + * + * viewer.unload(vim) // Remove one vim + * viewer.disconnect() // Close server connection + * viewer.dispose() // Tear down viewer + * ``` + */ +export interface IUltraViewer { + readonly type: 'ultra' + readonly camera: IUltraCamera + readonly inputs: IInputHandler + /** All loaded VIM models. Auto-populated on load, auto-removed on unload. */ + readonly vims: IUltraVim[] + readonly viewport: IUltraViewport + readonly renderer: IUltraRenderer + readonly decoder: IUltraDecoder + readonly raycaster: IUltraRaycaster + readonly selection: IUltraSelection + /** The server URL this viewer is connected to, or undefined if not connected. */ + readonly serverUrl: string | undefined + /** Fires when the connection state changes (connecting, connected, disconnected, error). */ + readonly onStateChanged: ISimpleEvent + /** The current connection state. */ + readonly connectionState: ClientState + readonly sectionBox: IUltraSectionBox + /** + * Connects to the Ultra rendering server. Must be awaited before calling `load()`. + * Returns true on success, false on failure. Subscribe to `onStateChanged` for progress. + */ + connect (settings?: ConnectionSettings): Promise + /** Disconnects from the server. Loaded vims become inoperable. */ + disconnect (): void + /** + * Loads a VIM file via the server. Requires a successful `connect()` first. + * The resulting vim is added to `vims` on success. + */ + load (source: VimSource): IUltraLoadRequest + /** Removes a vim from the viewer and disposes its resources. */ + unload (vim: IUltraVim): void + /** Removes and disposes all loaded vims. */ + clear (): void + /** Tears down the viewer entirely — releases canvas, connection, and all resources. */ + dispose (): void +} + +/** + * The main UltraViewer class responsible for managing VIM files, * handling connections, and coordinating various components like the camera, decoder, and inputs. */ -export class Viewer { +export class UltraViewer implements IUltraViewer { /** * The type of the viewer, indicating it is a WebGL viewer. * Useful for distinguishing between different viewer types in a multi-viewer application. @@ -39,47 +98,48 @@ export class Viewer { private readonly _renderer : Renderer private readonly _viewport: Viewport private readonly _camera: Camera - private readonly _selection: ISelection + private readonly _selection: IUltraSelection private readonly _raycaster: Raycaster - private readonly _vims : VimCollection + private readonly _vims : VimCollection private _disposed = false // API components /** * The camera API for controlling camera movements and settings. */ - get camera (): ICamera { + get camera (): IUltraCamera { return this._camera } /** * The RPC client for making remote procedure calls. + * @internal */ readonly rpc: RpcSafeClient /** * The input API for handling user input events. */ - get inputs () { + get inputs (): IInputHandler { return this._input } - get vims (): IReadonlyVimCollection { - return this._vims + get vims (): IUltraVim[] { + return this._vims.getAll() } /** * The viewport API for managing the rendering viewport. */ - get viewport (): IViewport { + get viewport (): IUltraViewport { return this._viewport } - get renderer (): IRenderer { + get renderer (): IUltraRenderer { return this._renderer } - get decoder (): IDecoder { + get decoder (): IUltraDecoder { return this._decoder } @@ -87,14 +147,11 @@ export class Viewer { return this._raycaster } - get selection (): ISelection { + get selection (): IUltraSelection { return this._selection } - /** - * API to create, manage, and destroy colors. - */ - readonly colors: ColorManager + private readonly _colors: ColorManager /** * Gets the current URL to which the viewer is connected. @@ -120,28 +177,32 @@ export class Viewer { return this._socketClient.state } + private readonly _sectionBox: SectionBox + /** * The section box API for controlling the section box. */ - readonly sectionBox : SectionBox + get sectionBox (): IUltraSectionBox { + return this._sectionBox + } /** - * Creates a Viewer instance with a new canvas element appended to the given parent element. + * Creates an UltraViewer instance with a new canvas element appended to the given parent element. * @param parent - The parent HTML element to which the canvas will be appended. * @param logger - Optional logger for logging messages. - * @returns A new instance of the Viewer class. + * @returns A new instance of the UltraViewer class. */ - static createWithCanvas (parent: HTMLElement, logger?: ILogger): Viewer { + static createWithCanvas (parent: HTMLElement, logger?: ILogger): UltraViewer { const canvas = document.createElement('canvas') parent.appendChild(canvas) canvas.style.width = '100%' canvas.style.height = '100%' - const uv = new Viewer(canvas, logger) + const uv = new UltraViewer(canvas, logger) return uv } /** - * Constructs a new Viewer instance. + * Constructs a new UltraViewer instance. * @param canvas - The HTML canvas element for rendering. * @param logger - Optional logger for logging messages. */ @@ -152,18 +213,18 @@ export class Viewer { this.rpc = new RpcSafeClient(new RpcClient(this._socketClient)) this._canvas = canvas - this._vims = new VimCollection() + this._vims = new VimCollection() this._viewport = new Viewport(canvas, this.rpc, this._logger) this._decoder = new Decoder(canvas, this._logger) this._selection = createSelection() this._renderer = new Renderer(this.rpc, this._logger) - this.colors = new ColorManager(this.rpc) + this._colors = new ColorManager(this.rpc) this._camera = new Camera(this.rpc) - this._raycaster = new Raycaster(this.rpc, this.vims) + this._raycaster = new Raycaster(this.rpc, this._vims) this._input = ultraInputAdapter(this) - this.sectionBox = new SectionBox(this.rpc) + this._sectionBox = new SectionBox(this.rpc) // Set up the video frame handler this._socketClient.onVideoFrame = (msg) => this._decoder.enqueue(msg) @@ -193,7 +254,7 @@ export class Viewer { this._camera.onConnect() this._vims.getAll().forEach((vim) => vim.connect()) - this.sectionBox.onConnect() //needs to be called after vims are connected + this._sectionBox.onConnect() //needs to be called after vims are connected this._viewport.update() this._decoder.start() @@ -243,7 +304,7 @@ export class Viewer { private onDisconnect (): void { this._decoder.stop() this._decoder.clear() - this.colors.clear() + this._colors.clear() this._vims.getAll().forEach((vim) => vim.disconnect()) } @@ -268,14 +329,21 @@ export class Viewer { * @param source - The path or URL to the VIM file. * @returns A load request object that can be used to wait for the load to complete. */ - loadVim (source: VimSource): ILoadRequest { + load (source: VimSource): IUltraLoadRequest { if (typeof source.url !== 'string' || source.url.trim() === '') { const request = new LoadRequest() request.error('loadingError', 'Invalid path') return request } - const vim = new Vim(this.rpc, this.colors, this._renderer, source, this._logger) + const vimIndex = this._vims.allocateId() + if (vimIndex === undefined) { + const request = new LoadRequest() + request.error('loadingError', 'Maximum vim capacity reached') + return request + } + + const vim = new Vim(vimIndex, this.rpc, this._colors, this._renderer, source, this._logger) this._vims.add(vim) const request = vim.connect() request.getResult().then((result) => { @@ -290,23 +358,19 @@ export class Viewer { * Unloads the given VIM from the viewer. * @param vim - The VIM instance to unload. */ - unloadVim (vim: Vim): void { - this._vims.remove(vim) + unload (vim: IUltraVim): void { + this._vims.remove(vim as Vim) vim.disconnect() } /** * Clears all loaded VIMs from the viewer. */ - clearVims (): void { + clear (): void { this._vims.getAll().forEach((vim) => vim.disconnect()) this._vims.clear() } - getElement3Ds() : Promise { - return this.rpc.RPCGetElementCountForScene() - } - /** * Disposes all resources used by the viewer and disconnects from the server. */ @@ -318,8 +382,26 @@ export class Viewer { this._viewport.dispose() this._decoder.dispose() this._input.dispose() - this.sectionBox.dispose() + this._sectionBox.dispose() this._canvas.remove() window.onbeforeunload = null } } + +/** + * Creates a headless Ultra viewer without React UI. + * Use this for programmatic-only usage or custom UI frameworks. + * For a full React UI viewer, use `React.Ultra.createViewer()` instead. + * + * @param parent - The parent HTML element to which the canvas will be appended. + * @param logger - Optional logger for logging messages. + * @returns A new Ultra viewer. + * + * @example + * const viewer = Core.Ultra.createViewer(document.getElementById('app')) + * await viewer.connect({ url: 'wss://server:8080' }) + * viewer.load({ url: 'model.vim' }) + */ +export function createCoreUltraViewer (parent: HTMLElement, logger?: ILogger): IUltraViewer { + return UltraViewer.createWithCanvas(parent, logger) +} diff --git a/src/vim-web/core-viewers/ultra/viewport.ts b/src/vim-web/core-viewers/ultra/viewport.ts index 275e5501b..cdc317052 100644 --- a/src/vim-web/core-viewers/ultra/viewport.ts +++ b/src/vim-web/core-viewers/ultra/viewport.ts @@ -1,11 +1,11 @@ import * as Utils from "../../utils" -import { ILogger } from "./logger"; +import { ILogger } from "../shared/logger"; import { RpcSafeClient } from "./rpcSafeClient"; /** * Interface defining viewport functionality */ -export interface IViewport { +export interface IUltraViewport { /** The HTML canvas element used for rendering */ canvas: HTMLCanvasElement @@ -17,7 +17,7 @@ export interface IViewport { } /** - * Class managing the viewport and canvas resizing functionality + * @internal */ export class Viewport { /** The HTML canvas element used for rendering */ diff --git a/src/vim-web/core-viewers/ultra/vim.ts b/src/vim-web/core-viewers/ultra/vim.ts index b4d0d3788..e392e7932 100644 --- a/src/vim-web/core-viewers/ultra/vim.ts +++ b/src/vim-web/core-viewers/ultra/vim.ts @@ -1,159 +1,277 @@ -import * as Utils from '../../utils'; -import type { IVim } from '../shared/vim'; -import type { ILogger } from './logger'; -import { ColorManager } from './colorManager'; -import { Element3D } from './element3d'; -import { LoadRequest } from './loadRequest'; -import { VisibilityState, VisibilitySynchronizer } from './visibility'; -import { Renderer } from './renderer'; -import { MaterialHandles } from './rpcClient'; -import { RpcSafeClient, VimLoadingStatus, VimSource } from './rpcSafeClient'; -import { INVALID_HANDLE } from './viewer'; - -import * as THREE from 'three'; - -export class Vim implements IVim { - readonly type = 'ultra'; - - readonly source: VimSource; - private _handle: number = -1; - private _request: LoadRequest | undefined; - - private readonly _rpc: RpcSafeClient; - private _colors: ColorManager; - private _renderer: Renderer; - private _logger: ILogger; - - // The StateSynchronizer wraps a StateTracker and handles RPC synchronization. - // Should be private - readonly visibility: VisibilitySynchronizer; - - // Color tracking remains unchanged. - private _elementColors: Map = new Map(); - private _updatedColors = new Set(); - private _removedColors = new Set(); - - // Delayed update flag. - private _updateScheduled: boolean = false; - - private _elementCount: number = 0; - private _objects: Map = new Map(); +import * as Utils from '../../utils' +import type { IVim } from '../shared/vim' +import type { ILogger } from '../shared/logger' +import { ColorManager } from './colorManager' +import { Element3D, type IUltraElement3D } from './element3d' +import { UltraScene, type IUltraScene } from './scene' +import { LoadRequest, type IUltraLoadRequest } from './loadRequest' +import { VisibilityState, type IVisibilitySynchronizer, VisibilitySynchronizer } from './visibility' +import { Renderer } from './renderer' +import { RpcSafeClient, VimLoadingStatus, VimSource } from './rpcSafeClient' +import { INVALID_HANDLE } from './viewer' + +import * as THREE from 'three' +/** + * Public interface for an Ultra Vim model (server-side rendered). + * Provides access to elements, visibility state, and scene queries. + * + * **Cleanup:** Do not call dispose directly — use `viewer.unload(vim)`. + * + * @example + * ```ts + * const vim = await viewer.load({ url: 'model.vim' }).getVim() + * + * // Query elements + * const element = vim.getElementFromIndex(301) + * element.visible = false + * element.outline = true + * element.ghosted = true + * element.color = new THREE.Color(0xff0000) + * + * // Scene queries + * const box = vim.scene.getBoundingBox() + * + * // Cleanup + * viewer.unload(vim) + * ``` + */ +export interface IUltraVim extends IVim { + readonly type: 'ultra' + /** The source URL and headers this vim was loaded from. */ + readonly source: VimSource + /** Scene providing bounding box and spatial queries. */ + readonly scene: IUltraScene + /** The server-side handle for this vim. */ + readonly handle: number + /** Whether this vim is currently connected to the server. */ + readonly connected: boolean + /** Reconnects this vim to the server. */ + connect(): IUltraLoadRequest + /** Disconnects this vim from the server. */ + disconnect(): void +} + +/** + * @internal + */ +export class Vim implements IUltraVim { + readonly type = 'ultra' + readonly vimIndex: number + + readonly source: VimSource + private _handle: number = -1 + private _request: LoadRequest | undefined + + private readonly _rpc: RpcSafeClient + private _colors: ColorManager + private _renderer: Renderer + private _logger: ILogger + + readonly scene: UltraScene + readonly visibility: IVisibilitySynchronizer + + // Color tracking — private, accessed via element.color + private _elementColors: Map = new Map() + private _updatedColors = new Set() + private _removedColors = new Set() + private _updateScheduled: boolean = false + + private _elementCount: number = 0 + private _objects: Map = new Map() + + /** @internal */ constructor( + vimIndex: number, rpc: RpcSafeClient, color: ColorManager, renderer: Renderer, source: VimSource, logger: ILogger ) { - this._rpc = rpc; - this.source = source; - this._colors = color; - this._renderer = renderer; - this._logger = logger; + this.vimIndex = vimIndex + this._rpc = rpc + this.source = source + this._colors = color + this._renderer = renderer + this._logger = logger + + this.scene = new UltraScene( + rpc, + () => this._handle, + () => this.connected + ) - // Instantiate the synchronizer with a new StateTracker. this.visibility = new VisibilitySynchronizer( this._rpc, () => this._handle, () => this.connected, () => this._renderer.notifySceneUpdated(), - VisibilityState.VISIBLE // default state - ); + VisibilityState.VISIBLE + ) } //TODO: Rename this to getElementFromNode, prefer using element instead getElement(elementIndex: number): Element3D { if (this._objects.has(elementIndex)) { - return this._objects.get(elementIndex)!; + return this._objects.get(elementIndex)! } - const object = new Element3D(this, elementIndex); - this._objects.set(elementIndex, object); - return object; + const object = new Element3D(this, elementIndex) + this._objects.set(elementIndex, object) + return object } - getElementsFromId(id: number): Element3D[] { - throw new Error('Method not implemented.'); + + getElementsFromId(_id: number | bigint): Element3D[] { + // Ultra viewer does not support element ID lookup. + // Use getElementFromIndex() or getAllElements() instead. + return [] } + getElementFromIndex(element: number): Element3D { - return this.getElement(element); - } - getObjectsInBox(box: THREE.Box3): Element3D[] { - throw new Error('Method not implemented.'); + return this.getElement(element) } + getAllElements(): Element3D[] { - for(var i = 0; i < this._elementCount; i++) { - this.getElement(i); + for (var i = 0; i < this._elementCount; i++) { + this.getElement(i) } - return Array.from(this._objects.values()); + return Array.from(this._objects.values()) } get handle(): number { - return this._handle; + return this._handle } get connected(): boolean { - return this._handle >= 0; + return this._handle >= 0 } - connect(): LoadRequest { + connect(): IUltraLoadRequest { if (this._request) { - return this._request; + return this._request } - this._logger.log('Loading: ', this.source); - this._request = new LoadRequest(); + this._logger.log('Loading: ', this.source) + this._request = new LoadRequest() this._load(this.source, this._request).then(async (request) => { - const result = await request.getResult(); + const result = await request.getResult() if (result.isSuccess) { - // Reapply Node state and colors in case this is a reconnection - this._logger.log('Successfully loaded vim: ', this.source); - this.visibility.reapplyStates(); + // Reapply state and colors in case this is a reconnection + this._logger.log('Successfully loaded vim: ', this.source) + this.visibility.reapplyStates() this.reapplyColors() - } else { - this._logger.log('Failed to load vim: ', this.source); + this._logger.log('Failed to load vim: ', this.source) } - }); - return this._request; + }) + return this._request } disconnect(): void { - this._request?.error('cancelled', 'The request was cancelled'); - this._request = undefined; + this._request?.error('cancelled', 'The request was cancelled') + this._request = undefined if (this.connected) { - this._handle = -1; + this._handle = -1 + } + } + + // --- Color management (internal, accessed via element.color) --- + + getColor(elementIndex: number): THREE.Color | undefined { + return this._elementColors.get(elementIndex) + } + + setColor(elementIndex: number[], color: THREE.Color | undefined) { + const colors = new Array(elementIndex.length).fill(color) + this.applyColor(elementIndex, colors) + } + + private applyColor(elements: number[], colors: (THREE.Color | undefined)[]) { + for (let i = 0; i < colors.length; i++) { + const color = colors[i] + const element = elements[i] + const existingColor = this._elementColors.get(element) + + if (color === undefined && existingColor !== undefined) { + this._elementColors.delete(element) + this._removedColors.add(element) + } else if (color !== existingColor) { + this._elementColors.set(element, color) + this._updatedColors.add(element) + } + } + this.scheduleColorUpdate() + } + + private reapplyColors(): void { + this._updatedColors.clear() + this._removedColors.clear() + this._elementColors.forEach((c, n) => this._updatedColors.add(n)) + this.scheduleColorUpdate() + } + + private scheduleColorUpdate(): void { + if (!this._updateScheduled) { + this._updateScheduled = true + requestAnimationFrame(() => this.updateRemote()) } } + private updateRemote(): void { + this._updateScheduled = false + if (!this.connected) return + this.updateRemoteColors() + this._renderer.notifySceneUpdated() + } + + private async updateRemoteColors() { + const updatedElement = Array.from(this._updatedColors) + const removedElement = Array.from(this._removedColors) + + const colors = updatedElement.map(n => this._elementColors.get(n)) + const remoteColors = await this._colors.getColors(colors) + const colorIds = remoteColors.map((c) => c?.id ?? -1) + + this._rpc.RPCClearMaterialOverridesForElements(this._handle, removedElement) + this._rpc.RPCSetMaterialOverridesForElements(this._handle, updatedElement, colorIds) + + this._updatedColors.clear() + this._removedColors.clear() + } + + // --- Loading internals --- + private async _load(source: VimSource, result: LoadRequest): Promise { - const handle = await this._getHandle(source, result); + const handle = await this._getHandle(source, result) if (result.isCompleted || handle === INVALID_HANDLE) { - return result; + return result } while (true) { try { - const state = await this._rpc.RPCGetVimLoadingState(handle); - this._logger.log('state :', state); - result.onProgress(state.progress); + const state = await this._rpc.RPCGetVimLoadingState(handle) + this._logger.log('state :', state) + result.onProgress({ type: 'percent', current: state.progress, total: 100 }) switch (state.status) { case VimLoadingStatus.Loading: case VimLoadingStatus.Downloading: - await wait(100); - continue; + await wait(100) + continue case VimLoadingStatus.FailedToDownload: case VimLoadingStatus.FailedToLoad: case VimLoadingStatus.Unknown: - const details = await this._rpc.RPCGetLastError(); - const error = this.getErrorType(state.status); - return result.error(error, details); + const details = await this._rpc.RPCGetLastError() + const error = this.getErrorType(state.status) + return result.error(error, details) case VimLoadingStatus.Done: - this._handle = handle; - this._elementCount = await this._rpc.RPCGetElementCountForVim(handle); - return result.success(this); + this._handle = handle + this._elementCount = await this._rpc.RPCGetElementCountForVim(handle) + const box = await this._rpc.RPCGetAABBForVim(handle) + this.scene.setBox(box) + return result.success(this) } } catch (e) { - const details = e instanceof Error ? e.message : 'An unknown error occurred'; - return result.error('unknown', details); + const details = e instanceof Error ? e.message : 'An unknown error occurred' + return result.error('unknown', details) } } } @@ -161,143 +279,37 @@ export class Vim implements IVim { private getErrorType(status: VimLoadingStatus) { switch (status) { case VimLoadingStatus.FailedToDownload: - return 'downloadingError'; + return 'downloadingError' case VimLoadingStatus.FailedToLoad: - return 'loadingError'; + return 'loadingError' default: - return 'unknown'; + return 'unknown' } } private async _getHandle(source: VimSource, result: LoadRequest): Promise { - let handle = undefined; + let handle = undefined try { if (Utils.isURL(source.url)) { - handle = await this._rpc.RPCLoadVimURL(source); + handle = await this._rpc.RPCLoadVimURL(source) } else if (Utils.isFileURI(source.url)) { - handle = await this._rpc.RPCLoadVim(source); + handle = await this._rpc.RPCLoadVim(source) } else { - console.log('Defaulting to file path'); - handle = await this._rpc.RPCLoadVim(source); + console.log('Defaulting to file path') + handle = await this._rpc.RPCLoadVim(source) } } catch (e) { - result.error('downloadingError', (e as Error).message); - return INVALID_HANDLE; + result.error('downloadingError', (e as Error).message) + return INVALID_HANDLE } if (handle === INVALID_HANDLE) { - result.error('downloadingError', 'Unknown error occurred'); - return INVALID_HANDLE; - } - return handle; - } - - async getBoundingBoxForElements(elements: number[] | 'all'): Promise { - if (!this.connected || (elements !== 'all' && elements.length === 0)) { - return Promise.resolve(undefined); - } - if (elements === 'all') { - return await this._rpc.RPCGetAABBForVim(this._handle); - } - return await this._rpc.RPCGetAABBForElements(this._handle, elements); - } - - async getBoundingBox(): Promise { - if (!this.connected ) { - return Promise.resolve(undefined); - } - return await this._rpc.RPCGetAABBForVim(this._handle); - } - - getColor(elementIndex: number): THREE.Color | undefined { - return this._elementColors.get(elementIndex); - } - - async setColor(elementIndex: number[], color: THREE.Color | undefined) { - const colors = new Array(elementIndex.length).fill(color); - this.applyColor(elementIndex, colors); - } - - async setColors(elements: number[], color: (THREE.Color | undefined)[]) { - if (color.length !== elements.length) { - throw new Error('Color and elements length must be equal'); + result.error('downloadingError', 'Unknown error occurred') + return INVALID_HANDLE } - this.applyColor(elements, color); - } - - private applyColor(elements: number[], colors: (THREE.Color | undefined)[]) { - for (let i = 0; i < colors.length; i++) { - const color = colors[i]; - const element = elements[i]; - const existingColor = this._elementColors.get(element); - - if (color === undefined && existingColor !== undefined) { - this._elementColors.delete(element); - this._removedColors.add(element); - } - else if (color !== existingColor){ - this._elementColors.set(element, color); - this._updatedColors.add(element); - } - } - this.scheduleColorUpdate(); - } - - //TODO: Remove and rely on element.color - clearColor(elements: number[] | 'all'): void { - if (elements === 'all') { - this._elementColors.clear(); - } else { - elements.forEach((n) => this._elementColors.delete(n)); - } - if (!this.connected) return; - if (elements === 'all') { - this._rpc.RPCClearMaterialOverridesForVim(this._handle); - } else { - this._rpc.RPCClearMaterialOverridesForElements(this._handle, elements); - } - } - - reapplyColors(): void { - this._updatedColors.clear(); - this._removedColors.clear(); - this._elementColors.forEach((c, n) => this._updatedColors.add(n)); - this.scheduleColorUpdate(); - } - - private scheduleColorUpdate(): void { - if (!this._updateScheduled) { - this._updateScheduled = true; - requestAnimationFrame(() => this.updateRemote()); - } - } - - private updateRemote(): void { - this._updateScheduled = false; - if (!this.connected) return; - this.updateRemoteColors(); - this._renderer.notifySceneUpdated(); - } - - private async updateRemoteColors() { - const updatedElement = Array.from(this._updatedColors); - const removedElement = Array.from(this._removedColors); - - const colors = updatedElement.map(n => this._elementColors.get(n)); - const remoteColors = await this._colors.getColors(colors); - const colorIds = remoteColors.map((c) => c?.id ?? -1); - - this._rpc.RPCClearMaterialOverridesForElements(this._handle, removedElement); - this._rpc.RPCSetMaterialOverridesForElements(this._handle, updatedElement, colorIds); - - - this._updatedColors.clear(); - this._removedColors.clear(); + return handle } } -/** - * Waits for the specified number of milliseconds. - */ function wait(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/src/vim-web/core-viewers/ultra/vimCollection.ts b/src/vim-web/core-viewers/ultra/vimCollection.ts deleted file mode 100644 index de32b6af2..000000000 --- a/src/vim-web/core-viewers/ultra/vimCollection.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ISignal, SignalDispatcher } from "ste-signals"; -import { Vim } from "./vim"; - -export interface IReadonlyVimCollection { - getFromHandle(handle: number): Vim | undefined; - getAll(): ReadonlyArray; - getAt(index: number): Vim | undefined - count: number; - onChanged: ISignal; -} - -export class VimCollection implements IReadonlyVimCollection { - private _vims: Vim[]; - private _onChanged = new SignalDispatcher(); - get onChanged() { - return this._onChanged.asEvent(); - } - - constructor() { - this._vims = []; - } - - public get count(): number { - return this._vims.length; - } - - /** - * Adds a Vim instance to the collection. - * @param vim - The Vim instance to add. - */ - public add(vim: Vim): void { - // Check if the Vim is already in the collection to prevent duplicates - if (!this._vims.some(v => v.handle === vim.handle)) { - this._vims.push(vim); - this._onChanged.dispatch(); - } - } - - /** - * Removes a Vim instance from the collection. - * @param vim - The Vim instance to remove. - */ - public remove(vim: Vim): void { - const count = this._vims.length; - this._vims = this._vims.filter(v => v.handle !== vim.handle); - if (this._vims.length !== count) { - this._onChanged.dispatch(); - } - } - - /** - * Gets a Vim instance by its handle. - * @param handle - The handle 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); - } - - /** - * Gets a Vim instance at a specific index. - * @param index - The index of the Vim instance. - * @returns The Vim instance or undefined if the index is out of bounds. - */ - public getAt(index: number): Vim | undefined { - return this._vims[index]; - } - - /** - * Gets all Vim instances. - * @returns An array of Vim instances. - */ - public getAll(): ReadonlyArray { - return this._vims; - } - - /** - * Clears all Vim instances from the collection. - */ - public clear(): void { - this._vims = []; - } -} diff --git a/src/vim-web/core-viewers/ultra/visibility.ts b/src/vim-web/core-viewers/ultra/visibility.ts index e65e67844..d3a31069d 100644 --- a/src/vim-web/core-viewers/ultra/visibility.ts +++ b/src/vim-web/core-viewers/ultra/visibility.ts @@ -13,11 +13,27 @@ export enum VisibilityState { GHOSTED_HIGHLIGHTED = 18 } +/** + * Public interface for visibility state management. + * Accessed via `vim.visibility` on Ultra Vim instances. + */ +export interface IVisibilitySynchronizer { + areAllInState(state: VisibilityState | VisibilityState[]): boolean + getElementState(elementIndex: number): VisibilityState + getElementsInState(state: VisibilityState): number[] | 'all' + getDefaultState(): VisibilityState + setStateForElement(elementIndex: number, state: VisibilityState): void + setStateForAll(state: VisibilityState): void + replaceState(fromState: VisibilityState | VisibilityState[], toState: VisibilityState): void + reapplyStates(): void +} + /** * A class that wraps a StateTracker and is responsible for synchronizing its state updates with the remote RPCs. * It batches updates to optimize performance and handles the communication with the remote system. + * @internal */ -export class VisibilitySynchronizer { +export class VisibilitySynchronizer implements IVisibilitySynchronizer { //TODO: Take advantage of the new rpcs that can take multiple states at once private _tracker: VisibilityTracker; private _rpc: RpcSafeClient; diff --git a/src/vim-web/core-viewers/webgl/index.ts b/src/vim-web/core-viewers/webgl/index.ts index d37ae1d9a..2f827b50c 100644 --- a/src/vim-web/core-viewers/webgl/index.ts +++ b/src/vim-web/core-viewers/webgl/index.ts @@ -1,13 +1,29 @@ // Links files to generate package type exports import './style.css' -// Useful definitions from vim-format -import { BFastSource } from 'vim-format' -export type VimSource = BFastSource -export { IProgressLogs } from 'vim-format' +// Loader +export { MaterialSet } from './loader' +export type { VimSettings, VimPartialSettings } from './loader' +export type { RequestSource, IWebglLoadRequest } from './loader' +export type { TransparencyMode } from './loader' +export type { IElement3D } from './loader' +export type { IScene } from './loader' +export type { IMaterials } from './loader' +export type { IWebglVim } from './loader' +export type { ISubset, SubsetFilter } from './loader' -export * from './loader' -export * from './viewer' - -// Not exported -// export * from './utils/boxes' +// Viewer +export type { IWebglViewer as Viewer } from './viewer' +export { createCoreWebglViewer as createViewer } from './viewer' +export type { ViewerSettings, PartialViewerSettings, MaterialSettings } from './viewer' +export type { IWebglCamera, ICameraMovement } from './viewer' +export type { IWebglRenderer, IRenderingSection } from './viewer' +export { isElement3D } from './viewer' +export type { ISelectable, IWebglSelection } from './viewer' +export type { IWebglViewport } from './viewer' +export type { IWebglRaycaster, IWebglRaycastResult } from './viewer' +export type { IGizmos, IGizmoOrbit } from './viewer' +export type { IGizmoAxes, AxesSettings } from './viewer' +export type { IMarker, IGizmoMarkers } from './viewer' +export type { IMeasure, MeasureStage } from './viewer' +export type { IWebglSectionBox } from './viewer' diff --git a/src/vim-web/core-viewers/webgl/loader/averageBoundingBox.ts b/src/vim-web/core-viewers/webgl/loader/averageBoundingBox.ts index 84c2899ad..317250c91 100644 --- a/src/vim-web/core-viewers/webgl/loader/averageBoundingBox.ts +++ b/src/vim-web/core-viewers/webgl/loader/averageBoundingBox.ts @@ -1,6 +1,7 @@ import * as THREE from 'three' /** + * @internal * Returns the bounding box of the average center of all meshes. * Less precise but is more stable against outliers. */ diff --git a/src/vim-web/core-viewers/webgl/loader/colorAttribute.ts b/src/vim-web/core-viewers/webgl/loader/colorAttribute.ts index ff6845a21..d2c7dd41d 100644 --- a/src/vim-web/core-viewers/webgl/loader/colorAttribute.ts +++ b/src/vim-web/core-viewers/webgl/loader/colorAttribute.ts @@ -1,26 +1,32 @@ /** * @module vim-loader + * + * Applies color overrides via the quantized color palette. + * + * - Merged meshes: changes per-vertex `colorIndex` directly (saves/restores originals) + * - Instanced meshes: sets per-instance `instanceColorIndex` palette index + * + * Both paths use the same palette texture — colorToIndex() maps any RGB to a palette slot. */ import * as THREE from 'three' import { MergedSubmesh } from './mesh' -import { Vim } from './vim' -import { InsertableSubmesh } from './progressive/insertableSubmesh' import { WebglAttributeTarget } from './webglAttribute' +import { colorToIndex } from './materials/colorPalette' +/** @internal */ export class WebglColorAttribute { - readonly vim: Vim private _meshes: WebglAttributeTarget[] | undefined private _value: THREE.Color | undefined + /** Saved original colorIndex values per merged submesh (keyed by submesh identity) */ + private _savedIndices = new Map() constructor ( meshes: WebglAttributeTarget[] | undefined, - value: THREE.Color | undefined, - vim: Vim | undefined + value: THREE.Color | undefined ) { this._meshes = meshes this._value = value - this.vim = vim } updateMeshes (meshes: WebglAttributeTarget[] | undefined) { @@ -49,135 +55,74 @@ export class WebglColorAttribute { } /** - * Writes new color to the appropriate section of merged mesh color buffer. - * @param index index of the merged mesh instance - * @param color rgb representation of the color to apply + * Merged meshes: change the per-vertex colorIndex to the override palette entry. + * Saves original values on first override, restores on clear. */ private applyMergedColor (sub: MergedSubmesh, color: THREE.Color | undefined) { - if (!color) { - this.resetMergedColor(sub) - return - } + const geometry = sub.three.geometry + const attribute = geometry.getAttribute('colorIndex') as THREE.BufferAttribute + if (!attribute) return const start = sub.meshStart const end = sub.meshEnd + const indices = geometry.index - const colors = sub.three.geometry.getAttribute( - 'color' - ) as THREE.BufferAttribute - - const indices = sub.three.geometry.index + if (color) { + // Save originals if not already saved + if (!this._savedIndices.has(sub)) { + const saved = new Uint16Array(end - start) + for (let i = start; i < end; i++) { + const v = indices.getX(i) + saved[i - start] = attribute.getX(v) + } + this._savedIndices.set(sub, saved) + } - // Save colors to be able to reset. - if (sub instanceof InsertableSubmesh) { - let c = 0 - const previous = new Float32Array((end - start) * 3) + // Write override palette index + const palIdx = colorToIndex(color.r, color.g, color.b) 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) + attribute.setX(v, palIdx) } - sub.saveColors(previous) - } - - for (let i = start; i < end; i++) { - const v = indices.getX(i) - // alpha is left to its current value - colors.setXYZ(v, color.r, color.g, color.b) - } - colors.needsUpdate = true - 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++ + } else { + // Restore originals + const saved = this._savedIndices.get(sub) + if (saved) { + for (let i = start; i < end; i++) { + const v = indices.getX(i) + attribute.setX(v, saved[i - start]) + } + this._savedIndices.delete(sub) } } - colors.needsUpdate = true - colors.clearUpdateRanges() - } - - private resetMergedInsertableColor (sub: InsertableSubmesh) { - const previous = sub.popColors() - if (previous === undefined) return - - const indices = sub.three.geometry.index - const colors = sub.three.geometry.getAttribute( - 'color' - ) as THREE.BufferAttribute - let c = 0 - for (let i = sub.meshStart; i < sub.meshEnd; i++) { - const v = indices.getX(i) - colors.setXYZ(v, previous[c], previous[c + 1], previous[c + 2]) - c += 3 - } - - colors.needsUpdate = true - colors.clearUpdateRanges() + attribute.needsUpdate = true + attribute.clearUpdateRanges() } /** - * Adds an instanceColor buffer to the instanced mesh and sets new color for given instance - * @param index index of the instanced instance - * @param color rgb representation of the color to apply + * Instanced meshes: set per-instance palette index via instanceColorIndex attribute. + * The `colored` flag (set separately) tells the shader to use this instead of per-vertex colorIndex. */ private applyInstancedColor (sub: WebglAttributeTarget, color: THREE.Color | undefined) { - const colors = this.getOrAddInstanceColorAttribute( - sub.three as THREE.InstancedMesh - ) - if (color) { - // Set instance to use instance color provided - colors.setXYZ(sub.index, color.r, color.g, color.b) - // Set attributes dirty - colors.needsUpdate = true - colors.clearUpdateRanges() + const mesh = sub.three as THREE.InstancedMesh + const geometry = mesh.geometry + + let attribute = geometry.getAttribute('instanceColorIndex') as THREE.BufferAttribute + if (!attribute || attribute.count < mesh.instanceMatrix.count) { + const count = mesh.instanceMatrix.count + const array = new Float32Array(count) + attribute = new THREE.InstancedBufferAttribute(array, 1) + geometry.setAttribute('instanceColorIndex', attribute) } - } - private getOrAddInstanceColorAttribute (mesh: THREE.InstancedMesh) { - if (mesh.instanceColor && - mesh.instanceColor.count <= mesh.instanceMatrix.count - ) { - return mesh.instanceColor + if (color) { + const palIdx = colorToIndex(color.r, color.g, color.b) + attribute.setX(sub.index, palIdx) + } else { + attribute.setX(sub.index, 0) } - - // mesh.count is not always === to capacity so we use instanceMatrix.count - const count = mesh.instanceMatrix.count - // Add color instance attribute - const colors = new Float32Array(count * 3) - const attribute = new THREE.InstancedBufferAttribute(colors, 3) - mesh.instanceColor = attribute - return attribute + attribute.needsUpdate = true + attribute.clearUpdateRanges() } } diff --git a/src/vim-web/core-viewers/webgl/loader/element3d.ts b/src/vim-web/core-viewers/webgl/loader/element3d.ts index 1fe741f6a..512271f1b 100644 --- a/src/vim-web/core-viewers/webgl/loader/element3d.ts +++ b/src/vim-web/core-viewers/webgl/loader/element3d.ts @@ -6,21 +6,72 @@ import * as THREE from 'three' // Vim -import { Vim } from './vim' +import { Vim, type IWebglVim } from './vim' +import { Scene } from './scene' import { IElement, VimHelpers } from 'vim-format' import { WebglAttribute } from './webglAttribute' import { WebglColorAttribute } from './colorAttribute' import { Submesh } from './mesh' -import { IVimElement } from '../../shared/vim' +import { MappedG3d } from './progressive/mappedG3d' +import { ISelectable } from '../viewer/selection' /** - * High level api to interact with the loaded vim ometry and data. + * Public interface for a loaded BIM element with geometry and visual state. + * + * Obtained via `vim.getElementFromIndex(index)` or `vim.getAllElements()`. + * + * @example + * ```ts + * const element = vim.getElementFromIndex(301) + * element.color = new THREE.Color(0xff0000) + * element.visible = false + * const params = await element.getBimParameters() + * ``` */ -export class Element3D implements IVimElement { +export interface IElement3D extends ISelectable { + readonly type: 'Element3D' + /** The vim from which this element came. */ + readonly vim: IWebglVim + /** The BIM element index. */ + readonly element: number + /** The unique element ID. */ + readonly elementId: bigint + /** The geometry instances associated with this element. */ + readonly instances: number[] | undefined + /** True if this element has associated geometry. */ + readonly hasMesh: boolean + /** True if this element is a room. */ + readonly isRoom: boolean + /** Whether to render selection outline for this element. */ + outline: boolean + /** Whether to render focus highlight for this element. */ + focused: boolean + /** Whether to render this element. */ + visible: boolean + /** The display color override. Set to undefined to revert to default. */ + color: THREE.Color | undefined + /** Retrieves BIM data for this element. */ + getBimElement(): Promise + /** + * Retrieves all BIM parameters for this element. + * @returns Array of `{ name: string, value: string, group: string }` objects. + * Type is `VimHelpers.ElementParameter` from vim-format (accessible via `VIM.BIM.VimHelpers`). + */ + getBimParameters(): Promise + /** Retrieves the bounding box in Z-up world space (X = right, Y = forward, Z = up), or undefined if the element has no geometry. */ + getBoundingBox(): Promise + /** Retrieves the center position in Z-up world space, or undefined if the element has no geometry. */ + getCenter(target?: THREE.Vector3): Promise +} + +/** + * High level api to interact with the loaded vim geometry and data. + */ +export class Element3D implements IElement3D { private _color: THREE.Color | undefined private _boundingBox: THREE.Box3 | undefined private _meshes: Submesh[] | undefined - + private readonly _g3d: MappedG3d | undefined private readonly _outlineAttribute: WebglAttribute private readonly _visibleAttribute: WebglAttribute @@ -36,7 +87,12 @@ export class Element3D implements IVimElement { /** * The vim object from which this object came from. */ - readonly vim: Vim + readonly vim: IWebglVim + + /** @internal */ + private get _vim (): Vim { + return this.vim as Vim + } /** * The bim element index associated with this object. @@ -47,7 +103,7 @@ export class Element3D implements IVimElement { * The ID of the element associated with this object. */ get elementId () : bigint { - return this.vim.map.getElementId(this.element) + return this._vim.map.getElementId(this.element)! } /** @@ -65,7 +121,7 @@ export class Element3D implements IVimElement { get isRoom(){ const instance = this.instances[0] ?? -1 - return this.vim.g3d.getInstanceHasFlag(instance, 1) + return this._g3d?.getInstanceHasFlag(instance, 1) ?? false } /** @@ -79,7 +135,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() } } @@ -106,13 +162,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 + }) + } } } @@ -133,26 +189,24 @@ export class Element3D implements IVimElement { } private get renderer(){ - return this.vim.scene.renderer + return (this._vim.scene as Scene).renderer } /** - * Constructs a new instance of Object. - * @param {Vim} vim The Vim instance. - * @param {number} element The element index. - * @param {number[] | undefined} instances An optional array of instance numbers. - * @param {Submesh[] | undefined} meshes An optional array of submeshes. + * @internal */ constructor ( vim: Vim, element: number, instances: number[] | undefined, - meshes: Submesh[] | undefined + meshes: Submesh[] | undefined, + g3d: MappedG3d | undefined ) { this.vim = vim this.element = element this.instances = instances this._meshes = meshes + this._g3d = g3d this._outlineAttribute = new WebglAttribute( false, @@ -186,7 +240,7 @@ export class Element3D implements IVimElement { (v) => (v ? 1 : 0) ) - this._colorAttribute = new WebglColorAttribute(meshes, undefined, vim) + this._colorAttribute = new WebglColorAttribute(meshes, undefined) } /** @@ -194,7 +248,7 @@ export class Element3D implements IVimElement { * @returns {IElement} An object containing the bim data for this element. */ async getBimElement (): Promise { - return this.vim.bim.element.get(this.element) + return this._vim.bim.element.get(this.element) } /** @@ -202,7 +256,7 @@ export class Element3D implements IVimElement { * @returns {VimHelpers.ElementParameter[]} An array of all bim parameters for this elements. */ async getBimParameters (): Promise { - return VimHelpers.getElementParameters(this.vim.bim, this.element) + return VimHelpers.getElementParameters(this._vim.bim, this.element) } /** @@ -221,7 +275,7 @@ export class Element3D implements IVimElement { box = box ? box.union(b) : b.clone() }) if (box) { - box.applyMatrix4(this.vim.getMatrix()) + box.applyMatrix4(this.vim.scene.matrix) this._boundingBox = box } @@ -240,11 +294,12 @@ export class Element3D implements IVimElement { } /** - * Internal method used to replace this object's meshes and apply color as needed. + * @internal + * Replaces this object's meshes and apply color as needed. * @param {Submesh} mesh The new mesh to be added. * @throws {Error} Throws an error if the provided mesh instance does not match any existing instances. */ - _addMesh (mesh: Submesh) { + addMesh (mesh: Submesh) { if (this.instances.findIndex((i) => i === mesh.instance) < 0) { throw new Error('Cannot update mismatched instance') } @@ -262,7 +317,7 @@ export class Element3D implements IVimElement { private updateMeshes (meshes: Submesh[] | undefined) { this._meshes = meshes - this.renderer.needsUpdate = true + this.renderer.requestRender() this._outlineAttribute.updateMeshes(meshes) this._visibleAttribute.updateMeshes(meshes) diff --git a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts index c1cb97740..f6d959625 100644 --- a/src/vim-web/core-viewers/webgl/loader/elementMapping.ts +++ b/src/vim-web/core-viewers/webgl/loader/elementMapping.ts @@ -2,9 +2,26 @@ * @module vim-loader */ -import { G3d, G3dScene, VimDocument } from 'vim-format' +import { VimDocument } from 'vim-format' + +/** Public-facing interface for BIM-to-geometry mapping. */ +export interface IElementMapping { + /** Returns element indices associated with element ID. */ + getElementsFromElementId(id: number | bigint): number[] | undefined + /** Returns true if element exists in the vim. */ + hasElement(element: number): boolean + /** Returns all element indices. */ + getElements(): Iterable + /** Returns instance indices for a given element. */ + getInstancesFromElement(element: number): number[] | undefined + /** Returns the element index for a given instance. */ + getElementFromInstance(instance: number): number | undefined + /** Returns the element ID for a given element index. */ + getElementId(element: number): bigint | undefined +} -export class ElementNoMapping { +/** @internal */ +export class ElementNoMapping implements IElementMapping { getElementsFromElementId (id: number) { return undefined } @@ -30,40 +47,36 @@ export class ElementNoMapping { } } -export class ElementMapping { - private _instanceToElement: Map - private _instanceMeshes: Int32Array - private _elementToInstances: Map +/** @internal */ +export class ElementMapping implements IElementMapping { + private _instanceToElement: number[] | Int32Array + private _elementToInstances: (number[] | undefined)[] private _elementIds: BigInt64Array - private _elementIdToElements: Map + private _elementIdToElements: Map | null = null constructor ( - instances: number[], - instanceToElement: number[], - elementIds: BigInt64Array, - instanceMeshes?: Int32Array + instanceToElement: number[] | Int32Array, + elementIds: BigInt64Array ) { - 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._instanceMeshes = instanceMeshes } - static async fromG3d (g3d: G3d, bim: VimDocument) { + static async fromG3d (bim: VimDocument) { const instanceToElement = await bim.node.getAllElementIndex() const elementIds = await bim.element.getAllId() return new ElementMapping( - Array.from(g3d.instanceNodes), - instanceToElement, - elementIds, - g3d.instanceMeshes + instanceToElement, // No conversion - use directly to avoid memory duplication + elementIds ) } @@ -72,6 +85,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)) } @@ -82,17 +98,6 @@ export class ElementMapping { return element >= 0 && element < this._elementIds.length } - hasMesh (element: number) { - if (!this._instanceMeshes) return true - const instances = this._elementToInstances.get(element) - for (const i of instances) { - if (this._instanceMeshes[i] >= 0) { - return true - } - } - return false - } - /** * Returns all element indices of the vim */ @@ -106,7 +111,7 @@ export class ElementMapping { */ getInstancesFromElement (element: number): number[] | undefined { if (!this.hasElement(element)) return - return this._elementToInstances.get(element) ?? [] + return this._elementToInstances[element] ?? [] } /** @@ -115,7 +120,7 @@ export class ElementMapping { * @returns element index or undefined if not found */ getElementFromInstance (instance: number) { - return this._instanceToElement.get(instance) + return this._instanceToElement[instance] } /** @@ -128,17 +133,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 @@ -147,122 +155,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()) { - const list = result.get(value) - if (list) { - list.push(key) - } else { - result.set(value, [key]) - } - } - 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()) { + 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 diff --git a/src/vim-web/core-viewers/webgl/loader/geometry.ts b/src/vim-web/core-viewers/webgl/loader/geometry.ts index 1e24e745a..b132ab75d 100644 --- a/src/vim-web/core-viewers/webgl/loader/geometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/geometry.ts @@ -3,61 +3,36 @@ */ import * as THREE from 'three' -import { G3d, MeshSection } from 'vim-format' +import { MeshSection } from 'vim-format' +import { MappedG3d } from './progressive/mappedG3d' -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 - */ - export type Mode = 'opaqueOnly' | 'transparentOnly' | 'allAsOpaque' | 'all' - - /** - * Returns true if the transparency mode is one of the valid values - */ - export function isValid (value: string | undefined | null): value is Mode { - if (!value) return false - return ['all', 'opaqueOnly', 'transparentOnly', 'allAsOpaque'].includes( - value - ) - } - - /** - * Returns true if the transparency mode requires to use RGBA colors - */ - export function requiresAlpha (mode: Mode) { - return mode === 'all' || mode === 'transparentOnly' - } -} +/** + * Determines how to draw (or not) transparent and opaque objects + */ +export type TransparencyMode = 'opaqueOnly' | 'transparentOnly' | 'allAsOpaque' | 'all' /** - * Creates a BufferGeometry with all given instances merged - * @param instances indices of the instances from the g3d to merge - * @returns a BufferGeometry + * @internal + * Returns true if the transparency mode is one of the valid values */ -export function createGeometryFromInstances (g3d: G3d, args: MergeArgs) { - return mergeInstanceMeshes(g3d, args)?.geometry +export function isTransparencyModeValid (value: string | undefined | null): value is TransparencyMode { + if (!value) return false + return ['all', 'opaqueOnly', 'transparentOnly', 'allAsOpaque'].includes( + value + ) } /** + * @internal * 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: G3d, + g3d: MappedG3d, mesh: number, - section: MeshSection, - transparent: boolean + section: MeshSection ): THREE.BufferGeometry { - const colors = createVertexColors(g3d, mesh, transparent) + const colorIndices = createColorIndices(g3d, mesh, section) const positions = g3d.positions.subarray( g3d.getMeshVertexStart(mesh) * 3, g3d.getMeshVertexEnd(mesh) * 3 @@ -66,71 +41,55 @@ export function createGeometryFromMesh ( const start = g3d.getMeshIndexStart(mesh, section) const end = g3d.getMeshIndexEnd(mesh, section) const indices = g3d.indices.subarray(start, end) + return createGeometryFromArrays( positions, indices, - colors, - transparent ? 4 : 3 + colorIndices ) } + /** - * Expands submesh colors into vertex colors as RGB or RGBA + * Creates color palette indices for each vertex */ -function createVertexColors ( - g3d: G3d, +function createColorIndices ( + g3d: MappedG3d, mesh: number, - useAlpha: boolean -): Float32Array { - const colorSize = useAlpha ? 4 : 3 - const result = new Float32Array(g3d.getMeshVertexCount(mesh) * colorSize) + section: MeshSection +): Uint16Array { + const vertexCount = g3d.getMeshVertexCount(mesh) + const result = new Uint16Array(vertexCount) - const subStart = g3d.getMeshSubmeshStart(mesh) - const subEnd = g3d.getMeshSubmeshEnd(mesh) + const subStart = g3d.getMeshSubmeshStart(mesh, section) + const subEnd = g3d.getMeshSubmeshEnd(mesh, section) for (let submesh = subStart; submesh < subEnd; submesh++) { - const color = g3d.getSubmeshColor(submesh) const start = g3d.getSubmeshIndexStart(submesh) const end = g3d.getSubmeshIndexEnd(submesh) + const index = g3d.colorIndices[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] + const vertexIndex = g3d.indices[i] + result[vertexIndex] = index } } - 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 + return result } /** + * @internal * 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 colorIndices color palette index per vertex for texture-based color lookup * @returns a BufferGeometry */ export function createGeometryFromArrays ( vertices: Float32Array, indices: Uint32Array, - vertexColors: Float32Array | undefined = undefined, - colorSize: number = 3 + colorIndices: Uint16Array | undefined = undefined ): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() @@ -140,271 +99,13 @@ export function createGeometryFromArrays ( // Indices geometry.setIndex(new THREE.Uint32BufferAttribute(indices, 1)) - // Colors with alpha if transparent - if (vertexColors) { + // Color palette indices + if (colorIndices) { geometry.setAttribute( - 'color', - new THREE.BufferAttribute(vertexColors, colorSize) + 'colorIndex', + new THREE.Uint16BufferAttribute(colorIndices, 1) ) } 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 1f290246d..7bfaf983c 100644 --- a/src/vim-web/core-viewers/webgl/loader/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/index.ts @@ -1,33 +1,11 @@ -// Full export -export * from './vimSettings'; -export {requestVim as request, type RequestSource, type VimRequest} from './progressive/vimRequest'; -export * as Materials from './materials'; - // Types -export type {Transparency} from './geometry'; -export type * from './webglAttribute'; -export type * from './colorAttribute'; -export type * from './element3d'; -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'; -export type * from './progressive/insertableGeometry'; -export type * from './progressive/insertableMesh'; -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/subsetBuilder'; -export type * from './progressive/subsetRequest'; - -// Not exported -// export * from './progressive/open'; -// export * from './averageBoundingBox'; +export type { VimSettings, VimPartialSettings } from './vimSettings'; +export type { RequestSource, IWebglLoadRequest } from './progressive/loadRequest'; +export type { TransparencyMode } from './geometry'; +export type { IElement3D } from './element3d'; +export type { IScene } from './scene'; +export type { IMaterials } from './materials/materials'; +export { MaterialSet } from './materials/materialSet'; +export type { IWebglVim } from './vim'; +export type { ISubset, SubsetFilter } from './progressive/g3dSubset'; 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..c361c79cc --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/materials/colorPalette.ts @@ -0,0 +1,69 @@ +/** + * @module vim-loader/materials + * + * Fixed quantized color palette for all scene coloring. + * + * Every color (model materials AND user overrides) maps to a palette index via colorToIndex(). + * The palette is deterministic: 25 levels per RGB channel = 25³ = 15,625 entries, + * stored in a 128×128 RGBA texture (16,384 slots, 15,625 used). + * + * Max color error: ~4% per channel (10.6 steps out of 255) — negligible for BIM. + */ + +import { MappedG3d } from '../progressive/mappedG3d' + +const LEVELS = 24 // 0..24 inclusive = 25 values per channel +const PALETTE_SIZE = 128 // 128×128 texture + +/** + * Maps an RGB color (0-1 per channel) to a palette index. + * The index is deterministic: same color always maps to same index. + */ +export function colorToIndex (r: number, g: number, b: number): number { + const ri = Math.round(r * LEVELS) + const gi = Math.round(g * LEVELS) + const bi = Math.round(b * LEVELS) + return ri * 625 + gi * 25 + bi // 25² = 625 +} + +/** + * Builds the fixed 128×128 RGBA palette texture data. + * Always the same — 15,625 quantized colors. + */ +export function buildPaletteTexture (): Uint8Array { + const data = new Uint8Array(PALETTE_SIZE * PALETTE_SIZE * 4) + const total = (LEVELS + 1) * (LEVELS + 1) * (LEVELS + 1) // 15,625 + + for (let i = 0; i < total; i++) { + const ri = Math.floor(i / 625) + const gi = Math.floor((i % 625) / 25) + const bi = i % 25 + + const offset = i * 4 + data[offset] = Math.round((ri / LEVELS) * 255) + data[offset + 1] = Math.round((gi / LEVELS) * 255) + data[offset + 2] = Math.round((bi / LEVELS) * 255) + data[offset + 3] = 255 + } + + return data +} + +/** + * Maps each submesh to its nearest palette index. + * + * @param g3d - The mapped G3d geometry with material colors + * @param submeshCount - Total number of submeshes + * @returns Uint16Array mapping submesh index → palette color index + */ +export function buildColorIndices ( + g3d: MappedG3d, + submeshCount: number +): Uint16Array { + const indices = new Uint16Array(submeshCount) + for (let i = 0; i < submeshCount; i++) { + const color = g3d.getSubmeshColor(i) + indices[i] = colorToIndex(color[0], color[1], color[2]) + } + return indices +} diff --git a/src/vim-web/core-viewers/webgl/loader/materials/ghostMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/ghostMaterial.ts index cb8b408cc..1836c6387 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/ghostMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/ghostMaterial.ts @@ -6,39 +6,86 @@ import * as THREE from 'three' /** - * Creates a material for the ghost effect in isolation mode. - * + * @internal + * Material wrapper for the ghost effect in isolation mode. + * Non-visible items are rendered as transparent objects using a customizable fill color. + * Visible items are completely excluded from rendering. + */ +export class GhostMaterial { + three: THREE.ShaderMaterial + private _onUpdate?: () => void + + constructor (material?: THREE.ShaderMaterial, onUpdate?: () => void) { + this.three = material ?? createGhostShader() + this._onUpdate = onUpdate + } + + get opacity () { + return this.three.uniforms.opacity.value + } + + set opacity (value: number) { + this.three.uniforms.opacity.value = value + this.three.uniformsNeedUpdate = true + this._onUpdate?.() + } + + get color (): THREE.Color { + return this.three.uniforms.fillColor.value + } + + set color (value: THREE.Color) { + this.three.uniforms.fillColor.value = value + this.three.uniformsNeedUpdate = true + this._onUpdate?.() + } + + get clippingPlanes () { + return this.three.clippingPlanes + } + + set clippingPlanes (value: THREE.Plane[] | null) { + this.three.clippingPlanes = value + this._onUpdate?.() + } + + dispose () { + this.three.dispose() + } +} + +/** + * @internal + * Creates a GhostMaterial for isolation mode. + */ +export function createGhostMaterial(): GhostMaterial { + return new GhostMaterial(createGhostShader()) +} + +/** + * Creates the shader material for the ghost effect. + * * - **Non-visible items**: Rendered as transparent objects using a customizable fill color. * - **Visible items**: Completely excluded from rendering. * - Designed for use with instanced or merged meshes. - * - Includes clipping plane support, vertex colors, and transparency. - * - * @returns {THREE.ShaderMaterial} A custom shader material for the ghost effect. + * - Includes clipping plane support and transparency. */ -export function createGhostMaterial() { +function createGhostShader() { return new THREE.ShaderMaterial({ userData: { isGhost: true }, uniforms: { - // Uniform controlling the overall transparency of the non-visible objects. - opacity: { value: 0.25 }, - // Uniform specifying the fill color for non-visible objects. - fillColor: { value: new THREE.Vector3(14/255, 14/255, 14/255) } + // Overall transparency for non-visible objects. + opacity: { value: 7 / 255 }, + // Fill color for non-visible objects. Pre-computed to avoid per-uniform divisions. + fillColor: { value: new THREE.Vector3(0.0549, 0.0549, 0.0549) } }, - - /* - blending: THREE.CustomBlending, - blendSrc: THREE.SrcAlphaFactor, - blendEquation: THREE.AddEquation, - blendDst: THREE.OneMinusDstColorFactor, - */ - - - // Render only the front side of faces to prevent drawing internal geometry. + + // Draw only front faces for performance, ghost are approximate anyway. side: THREE.FrontSide, - // Enable support for vertex colors. - vertexColors: true, + // Use GLSL ES 3.0 for WebGL 2 + glslVersion: THREE.GLSL3, // Enable transparency for the material. transparent: true, // Enable clipping planes for geometry slicing. @@ -52,7 +99,7 @@ export function createGhostMaterial() { // Attribute to determine if an object or vertex should be visible. // Used as an instance attribute for instanced meshes or a vertex attribute for merged meshes. - attribute float ignore; + in float ignore; void main() { // Standard transformations to calculate vertex position. @@ -60,10 +107,12 @@ export function createGhostMaterial() { #include #include - // Hide objects or vertices where the 'ignore' attribute is set to 0. + // Hide objects where ignore == 0.0 (visible items are excluded from ghost rendering). + // Placing behind near plane (z = -2 in clip space) clips the triangle. + // Faster than fragment discard since no fragments are generated. if (ignore == 0.0) { - // Push the vertex far out of view, effectively hiding it. - gl_Position = vec4(1e20, 1e20, 1e20, 1.0); + gl_Position = vec4(0.0, 0.0, -2.0, 1.0); + return; } } `, @@ -75,12 +124,13 @@ export function createGhostMaterial() { // Uniform specifying the fill color for non-visible objects. uniform vec3 fillColor; + out vec4 fragColor; + void main() { // Handle clipping planes to discard fragments outside the defined planes. #include - // Set the fragment color to the specified fill color and opacity. - // Divided by 10 just to match Ultra ghost opacity at 0.25 - gl_FragColor = vec4(fillColor, opacity / 10.0); + // Output fill color with pre-divided opacity (no per-fragment division needed). + fragColor = vec4(fillColor, opacity); } ` }); 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..0aff9ab61 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/index.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/index.ts @@ -1,9 +1,3 @@ -export * from './ghostMaterial'; -export * from './maskMaterial'; -export * from './materials'; -export * from './mergeMaterial'; -export * from './outlineMaterial'; -export * from './simpleMaterial'; -export * from './skyboxMaterial'; -export * from './standardMaterial'; -export * from './transferMaterial'; +export type { IMaterials } from './materials' +export { MaterialSet } from './materialSet' +export { applyMaterial } from './materials' diff --git a/src/vim-web/core-viewers/webgl/loader/materials/maskMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/maskMaterial.ts index 83604011d..c459171de 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/maskMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/maskMaterial.ts @@ -12,36 +12,40 @@ export function createMaskMaterial () { side: THREE.DoubleSide, uniforms: {}, clipping: true, - vertexShader: ` + // Use GLSL ES 3.0 for WebGL 2 + glslVersion: THREE.GLSL3, + // Only write depth, not color (outline shader only reads depth) + colorWrite: false, + vertexShader: /* glsl */ ` #include - #include #include - - // Used as instance attribute for instanced mesh and as vertex attribute for merged meshes. - attribute float selected; - varying float vKeep; + // Used as instance attribute for instanced mesh and as vertex attribute for merged meshes. + in float selected; void main() { #include #include #include - #include - // SELECTION - // selected - vKeep = selected; + // Place non-selected vertices behind near plane to clip them. + // Faster than fragment discard since no fragments are generated. + if (selected < 0.5) { + gl_Position = vec4(0.0, 0.0, -2.0, 1.0); + return; + } } `, - fragmentShader: ` + fragmentShader: /* glsl */ ` #include - varying float vKeep; + + out vec4 fragColor; void main() { #include - if(vKeep == 0.0f) discard; - gl_FragColor = vec4(1.0f,1.0f,1.0f,1.0f); + // All fragments reaching here are selected (non-selected culled in vertex shader) + fragColor = vec4(1.0, 1.0, 1.0, 1.0); } ` }) 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..b8e2e138c --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/materials/materialSet.ts @@ -0,0 +1,89 @@ +/** + * @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 (undefined = don't render opaque meshes) + * - transparent: For see-through geometry (undefined = don't render transparent meshes) + * - hidden: For ghosted/hidden objects (undefined = don't render ghost) + */ +export class MaterialSet { + readonly opaque?: THREE.Material + readonly transparent?: THREE.Material + readonly hidden?: THREE.Material + + /** @internal */ + private _cachedOpaqueArray?: THREE.Material[] + /** @internal */ + private _cachedTransparentArray?: THREE.Material[] + + constructor( + opaque?: THREE.Material, + transparent?: THREE.Material, + hidden?: THREE.Material + ) { + this.opaque = opaque + this.transparent = transparent + this.hidden = hidden + } + + /** + * Get material for opaque meshes. + * Returns a single material, or a `[visible, hidden]` array when a ghost material is set. + * Returns `undefined` if no opaque material exists (mesh should be hidden). + */ + getOpaque(): THREE.Material | THREE.Material[] | undefined { + return this._resolve(this.opaque, this._cachedOpaqueArray, (arr) => { this._cachedOpaqueArray = arr }) + } + + /** + * Get material for transparent meshes. + * Returns a single material, or a `[visible, hidden]` array when a ghost material is set. + * Returns `undefined` if no transparent material exists (mesh should be hidden). + */ + getTransparent(): THREE.Material | THREE.Material[] | undefined { + return this._resolve(this.transparent, this._cachedTransparentArray, (arr) => { this._cachedTransparentArray = arr }) + } + + private _resolve( + visibleMat: THREE.Material | undefined, + cached: THREE.Material[] | undefined, + setCache: (arr: THREE.Material[]) => void, + ): THREE.Material | THREE.Material[] | undefined { + if (!visibleMat) return undefined + + if (this.hidden) { + if (!cached) { + cached = [visibleMat, this.hidden] + setCache(cached) + } + return cached + } + + return visibleMat + } + + /** + * 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 d0bbfd2c3..48a5531ad 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/materials.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/materials.ts @@ -5,20 +5,96 @@ import * as THREE from 'three' import { StandardMaterial, createOpaque, createTransparent } from './standardMaterial' import { createMaskMaterial } from './maskMaterial' -import { createGhostMaterial as createGhostMaterial } from './ghostMaterial' +import { GhostMaterial } from './ghostMaterial' import { OutlineMaterial } from './outlineMaterial' -import { ViewerSettings } from '../../viewer/settings/viewerSettings' import { MergeMaterial } from './mergeMaterial' -import { createSimpleMaterial } from './simpleMaterial' +import { ModelMaterial, createModelOpaque, createModelTransparent } from './modelMaterial' +import { buildPaletteTexture } from './colorPalette' + import { SignalDispatcher } from 'ste-signals' -import { SkyboxMaterial } from './skyboxMaterial' +import { MaterialSettings } from '../../viewer/settings/viewerSettings' +import { MaterialSet } from './materialSet' + +export type { MaterialSet } + +/** + * Public API for material configuration. + * Users interact with this interface via `viewer.materials`. + * + * Raw THREE materials are exposed for building MaterialSet. + * All property mutation goes through flat proxy getters/setters. + */ +export interface IMaterials { + /** The opaque model material. Used as the opaque slot when building a MaterialSet. */ + readonly modelOpaqueMaterial: THREE.Material + /** The transparent model material. Used as the transparent slot when building a MaterialSet. */ + readonly modelTransparentMaterial: THREE.Material + /** The ghost material used to render hidden/ghosted elements. */ + readonly ghostMaterial: THREE.Material + + /** Base color tint applied to opaque and transparent model materials. */ + modelColor: THREE.Color + /** Opacity of the ghost material (0 = invisible, 1 = fully opaque). */ + ghostOpacity: number + /** Color of the ghost material. */ + ghostColor: THREE.Color + /** Intensity of the selection outline post-process effect. */ + outlineIntensity: number + /** Color of the selection outline post-process effect. */ + outlineColor: THREE.Color + /** Width of the stroke rendered where the section box intersects the model. */ + sectionStrokeWidth: number + /** Gradient falloff of the section box intersection stroke. */ + sectionStrokeFalloff: number + /** Color of the section box intersection stroke. */ + sectionStrokeColor: THREE.Color + /** Clipping planes applied to all materials. Set to undefined to disable clipping. */ + clippingPlanes: THREE.Plane[] | undefined + + /** Applies a full set of material settings from the viewer configuration. */ + applySettings (settings: MaterialSettings): void +} + +/** + * Applies a MaterialSet to a THREE.Mesh. + * Converts MaterialSet to the appropriate THREE.Material or array based on mesh properties. + * This is the only place where MaterialSet.get() is called to extract actual materials. + * + * @param mesh The mesh to apply material to + * @param value The MaterialSet containing opaque/transparent/hidden materials + */ +export function applyMaterial( + mesh: THREE.Mesh, + value: MaterialSet, +) { + const isTransparent = mesh.userData.transparent === true + const mat = isTransparent ? value.getTransparent() : value.getOpaque() -export type ModelMaterial = THREE.Material | THREE.Material[] | undefined + if (!mat) { + mesh.visible = false + return + } + + if (mesh.material === mat) return // No-op if same material + + mesh.material = mat + mesh.geometry.clearGroups() + + // Set up geometry groups for material arrays (ghost rendering) + if (Array.isArray(mat)) { + mat.forEach((_, i) => { + mesh.geometry.addGroup(0, Infinity, i) + }) + } + + mesh.visible = true // Only visible after material applied +} /** + * @internal * Defines the materials to be used by the vim loader and allows for material injection. */ -export class Materials { +export class Materials implements IMaterials { // eslint-disable-next-line no-use-before-define static instance: Materials @@ -33,210 +109,119 @@ export class Materials { return this.instance } - /** - * Material used for opaque model geometry. - */ - readonly opaque: StandardMaterial - /** - * Material used for transparent model geometry. - */ - readonly transparent: StandardMaterial - /** - * Material used for maximum performance. - */ - readonly simple: THREE.Material - /** - * Material used when creating wireframe geometry of the model. - */ - readonly wireframe: THREE.LineBasicMaterial - /** - * Material used to show traces of hidden objects. - */ - readonly ghost: THREE.Material - /** - * Material used to filter out what is not selected for selection outline effect. - */ - readonly mask: THREE.ShaderMaterial - /** - * Material used for selection outline effect. - */ - readonly outline: OutlineMaterial + private readonly _opaque: StandardMaterial + private readonly _transparent: StandardMaterial + private readonly _modelOpaque: ModelMaterial + private readonly _modelTransparent: ModelMaterial + private readonly _ghost: GhostMaterial - /** - * Material used for the skybox effect. - */ - readonly skyBox: SkyboxMaterial + // System materials — used by rendering pipeline only, not part of public API + private readonly _mask: THREE.ShaderMaterial + private readonly _outline: OutlineMaterial + private readonly _merge: MergeMaterial - /** - * Material used to merge outline effect with scene render. - */ - readonly merge: MergeMaterial + /** @internal Rendering pipeline access to system materials */ + get system () { + return { mask: this._mask, outline: this._outline, merge: this._merge } + } private _clippingPlanes: THREE.Plane[] | undefined - private _sectionStrokeWitdh: number = 0.01 - private _sectionStrokeFallof: number = 0.75 + private _sectionStrokeWidth: number = 0.01 + private _sectionStrokeFalloff: number = 0.75 private _sectionStrokeColor: THREE.Color = new THREE.Color(0xf6f6f6) - private _focusIntensity: number = 0.75 - private _focusColor: THREE.Color = new THREE.Color(0xffffff) private _onUpdate = new SignalDispatcher() + // Shared color palette texture for all scene materials + private _colorPaletteTexture: THREE.DataTexture | undefined + constructor ( opaque?: StandardMaterial, transparent?: StandardMaterial, - simple?: THREE.Material, - wireframe?: THREE.LineBasicMaterial, - ghost?: THREE.Material, + modelOpaque?: ModelMaterial, + modelTransparent?: ModelMaterial, + ghost?: GhostMaterial, mask?: THREE.ShaderMaterial, outline?: OutlineMaterial, merge?: MergeMaterial, - skyBox?: SkyboxMaterial ) { - this.opaque = opaque ?? createOpaque() - this.transparent = transparent ?? createTransparent() - this.simple = simple ?? createSimpleMaterial() - this.wireframe = wireframe ?? createWireframe() - this.ghost = ghost ?? createGhostMaterial() - this.mask = mask ?? createMaskMaterial() - this.outline = outline ?? new OutlineMaterial() - this.merge = merge ?? new MergeMaterial() - this.skyBox = skyBox ?? new SkyboxMaterial() - } + this._opaque = opaque ?? createOpaque() + this._transparent = transparent ?? createTransparent() + const onUpdate = () => this._onUpdate.dispatch() + this._modelOpaque = modelOpaque ?? createModelOpaque(onUpdate) + this._modelTransparent = modelTransparent ?? createModelTransparent(onUpdate) + this._ghost = ghost ?? new GhostMaterial(undefined, onUpdate) + this._mask = mask ?? createMaskMaterial() + this._outline = outline ?? new OutlineMaterial(undefined, onUpdate) + this._merge = merge ?? new MergeMaterial(onUpdate) + } + + /** The opaque model material. */ + get modelOpaqueMaterial (): THREE.Material { return this._modelOpaque.three } + /** The transparent model material. */ + get modelTransparentMaterial (): THREE.Material { return this._modelTransparent.three } + /** The ghost material used to render hidden/ghosted elements. */ + get ghostMaterial (): THREE.Material { return this._ghost.three } + + /** Opacity of the ghost material (0 = invisible, 1 = fully opaque). */ + get ghostOpacity () { return this._ghost.opacity } + set ghostOpacity (value: number) { this._ghost.opacity = value } + + /** Color of the ghost material. */ + get ghostColor () { return this._ghost.color } + set ghostColor (value: THREE.Color) { this._ghost.color = value } /** * Updates material settings based on the provided configuration. - * @param {ViewerSettings} settings - The settings to apply to the materials. */ - applySettings (settings: ViewerSettings) { - this.opaque.color = settings.materials.standard.color - this.transparent.color = settings.materials.standard.color - - this.ghostOpacity = settings.materials.ghost.opacity - this.ghostColor = settings.materials.ghost.color + applySettings (settings: MaterialSettings) { + this.modelColor = settings.standard.color - this.wireframeColor = settings.materials.highlight.color - this.wireframeOpacity = settings.materials.highlight.opacity + this._ghost.opacity = settings.ghost.opacity + this._ghost.color = settings.ghost.color - this.sectionStrokeWitdh = settings.materials.section.strokeWidth - this.sectionStrokeFallof = settings.materials.section.strokeFalloff - this.sectionStrokeColor = settings.materials.section.strokeColor + this.sectionStrokeWidth = settings.section.strokeWidth + this.sectionStrokeFalloff = settings.section.strokeFalloff + this.sectionStrokeColor = settings.section.strokeColor - this.outlineIntensity = settings.materials.outline.intensity - this.outlineFalloff = settings.materials.outline.falloff - this.outlineBlur = settings.materials.outline.blur - this.outlineColor = settings.materials.outline.color - // outline.antialias is applied in the rendering composer + this.outlineIntensity = settings.outline.intensity + this.outlineColor = settings.outline.color } - /** - * A signal dispatched whenever a material is modified. - */ + /** @internal Signal dispatched whenever a material is modified. */ get onUpdate () { return this._onUpdate.asEvent() } - /** - * Determines the color of the model regular opaque and transparent materials. - */ + /** Base color tint applied to opaque and transparent model materials. */ get modelColor () { - return this.opaque.color + return this._opaque.color } set modelColor (color: THREE.Color) { - this.opaque.color = color - this.transparent.color = color - this._onUpdate.dispatch() - } - - /** - * Determines the opacity of the ghost material. - */ - get ghostOpacity () { - const mat = this.ghost as THREE.ShaderMaterial - return mat.uniforms.opacity.value - } - - set ghostOpacity (opacity: number) { - const mat = this.ghost as THREE.ShaderMaterial - mat.uniforms.opacity.value = opacity - mat.uniformsNeedUpdate = true + this._opaque.color = color + this._transparent.color = color this._onUpdate.dispatch() } - /** - * Determines the color of the ghost material. - */ - get ghostColor (): THREE.Color { - const mat = this.ghost as THREE.ShaderMaterial - return mat.uniforms.fillColor.value - } - - set ghostColor (color: THREE.Color) { - const mat = this.ghost as THREE.ShaderMaterial - mat.uniforms.fillColor.value = color - mat.uniformsNeedUpdate = true - this._onUpdate.dispatch() - } - - /** - * Determines the color intensity of the highlight effect on mouse hover. - */ - get focusIntensity () { - return this._focusIntensity - } - - set focusIntensity (value: number) { - if (this._focusIntensity === value) return - this._focusIntensity = value - this.opaque.focusIntensity = value - this.transparent.focusIntensity = value - this._onUpdate.dispatch() - } - - /** - * Determines the color of the highlight effect on mouse hover. - */ - get focusColor () { - return this._focusColor - } - - set focusColor (value: THREE.Color) { - if (this._focusColor === value) return - this._focusColor = value - this.opaque.focusColor = value - this.transparent.focusColor = value - this._onUpdate.dispatch() - } - - /** - * Determines the color of wireframe meshes. - */ - get wireframeColor () { - return this.wireframe.color + /** Intensity of the selection outline post-process effect. */ + get outlineIntensity () { + return this._outline.intensity } - set wireframeColor (value: THREE.Color) { - if (this.wireframe.color === value) return - this.wireframe.color = value - this._onUpdate.dispatch() + set outlineIntensity (value: number) { + this._outline.intensity = value } - /** - * Determines the opacity of wireframe meshes. - */ - get wireframeOpacity () { - return this.wireframe.opacity + /** Color of the selection outline post-process effect. */ + get outlineColor () { + return this._merge.color } - set wireframeOpacity (value: number) { - if (this.wireframe.opacity === value) return - - this.wireframe.opacity = value - this._onUpdate.dispatch() + set outlineColor (value: THREE.Color) { + this._merge.color = value } - /** - * The clipping planes applied to all relevent materials - */ + /** Clipping planes applied to all materials. Set to undefined to disable clipping. */ get clippingPlanes () { return this._clippingPlanes } @@ -244,48 +229,42 @@ export class Materials { set clippingPlanes (value: THREE.Plane[] | undefined) { // THREE Materials will break if assigned undefined this._clippingPlanes = value - this.simple.clippingPlanes = value ?? null - this.opaque.clippingPlanes = value ?? null - this.transparent.clippingPlanes = value ?? null - this.wireframe.clippingPlanes = value ?? null - this.ghost.clippingPlanes = value ?? null - this.mask.clippingPlanes = value ?? null + this._modelOpaque.clippingPlanes = value ?? null + this._modelTransparent.clippingPlanes = value ?? null + this._opaque.clippingPlanes = value ?? null + this._transparent.clippingPlanes = value ?? null + this._ghost.clippingPlanes = value ?? null + this._mask.clippingPlanes = value ?? null this._onUpdate.dispatch() } - /** - * The width of the stroke effect where the section box intersects the model. - */ - get sectionStrokeWitdh () { - return this._sectionStrokeWitdh + /** Width of the stroke rendered where the section box intersects the model. */ + get sectionStrokeWidth () { + return this._sectionStrokeWidth } - set sectionStrokeWitdh (value: number) { - if (this._sectionStrokeWitdh === value) return - this._sectionStrokeWitdh = value - this.opaque.sectionStrokeWitdh = value - this.transparent.sectionStrokeWitdh = value + set sectionStrokeWidth (value: number) { + if (this._sectionStrokeWidth === value) return + this._sectionStrokeWidth = value + this._opaque.sectionStrokeWidth = value + this._transparent.sectionStrokeWidth = value this._onUpdate.dispatch() } - /** - * Gradient of the stroke effect where the section box intersects the model. - */ - get sectionStrokeFallof () { - return this._sectionStrokeFallof + /** Gradient falloff of the section box intersection stroke. */ + get sectionStrokeFalloff () { + return this._sectionStrokeFalloff } - set sectionStrokeFallof (value: number) { - if (this._sectionStrokeFallof === value) return - this._sectionStrokeFallof = value - this.opaque.sectionStrokeFallof = value - this.transparent.sectionStrokeFallof = value + set sectionStrokeFalloff (value: number) { + if (this._sectionStrokeFalloff === value) return + this._sectionStrokeFalloff = value + this._opaque.sectionStrokeFalloff = value + this._transparent.sectionStrokeFalloff = value this._onUpdate.dispatch() } - /** - * Color of the stroke effect where the section box intersects the model. - */ + /** Color of the section box intersection stroke. */ get sectionStrokeColor () { return this._sectionStrokeColor } @@ -293,120 +272,53 @@ export class Materials { set sectionStrokeColor (value: THREE.Color) { if (this._sectionStrokeColor === value) return this._sectionStrokeColor = value - this.opaque.sectionStrokeColor = value - this.transparent.sectionStrokeColor = value - this._onUpdate.dispatch() - } - - /** - * Color of the selection outline effect. - */ - get outlineColor () { - return this.merge.color - } - - set outlineColor (value: THREE.Color) { - if (this.merge.color === value) return - this.merge.color = value - this._onUpdate.dispatch() - } - - get outlineAntialias () { - return this.outline.antialias - } - - set outlineAntialias (value: boolean) { - this.outline.antialias = value - this._onUpdate.dispatch() - } - - /** - * Size of the blur convolution on the selection outline effect. Minimum 2. - */ - get outlineBlur () { - return this.outline.strokeBlur - } - - set outlineBlur (value: number) { - if (this.outline.strokeBlur === value) return - this.outline.strokeBlur = Math.max(value, 2) - this._onUpdate.dispatch() - } - - /** - * Gradient of the the selection outline effect. - */ - get outlineFalloff () { - return this.outline.strokeBias - } - - set outlineFalloff (value: number) { - if (this.outline.strokeBias === value) return - this.outline.strokeBias = value + this._opaque.sectionStrokeColor = value + this._transparent.sectionStrokeColor = value this._onUpdate.dispatch() } /** - * Intensity of the the selection outline effect. + * Creates the fixed quantized color palette texture if it doesn't exist. + * The palette is deterministic (25³ = 15,625 quantized colors in 128×128 texture) + * and shared across all scene materials. */ - get outlineIntensity () { - return this.outline.strokeMultiplier - } + ensureColorPalette () { + if (this._colorPaletteTexture) return + + const textureData = buildPaletteTexture() + this._colorPaletteTexture = new THREE.DataTexture( + textureData, + 128, + 128, + THREE.RGBAFormat, + THREE.UnsignedByteType + ) + this._colorPaletteTexture.needsUpdate = true + this._colorPaletteTexture.minFilter = THREE.NearestFilter + this._colorPaletteTexture.magFilter = THREE.NearestFilter + + this._opaque.setColorPaletteTexture(this._colorPaletteTexture) + this._transparent.setColorPaletteTexture(this._colorPaletteTexture) + this._modelOpaque.setColorPaletteTexture(this._colorPaletteTexture) + this._modelTransparent.setColorPaletteTexture(this._colorPaletteTexture) - set outlineIntensity (value: number) { - if (this.outline.strokeMultiplier === value) return - this.outline.strokeMultiplier = value - this._onUpdate.dispatch() - } - - get skyboxSkyColor () { - return this.skyBox.skyColor - } - - set skyboxSkyColor (value: THREE.Color) { - this.skyBox.skyColor = value - this._onUpdate.dispatch() - } - - get skyboxGroundColor () { - return this.skyBox.groundColor - } - - set skyboxGroundColor (value: THREE.Color) { - this.skyBox.groundColor = value - this._onUpdate.dispatch() - } - - get skyboxSharpness () { - return this.skyBox.sharpness - } - - set skyboxSharpness (value: number) { - this.skyBox.sharpness = value this._onUpdate.dispatch() } /** dispose all materials. */ dispose () { - this.opaque.dispose() - this.transparent.dispose() - this.wireframe.dispose() - this.ghost.dispose() - this.mask.dispose() - this.outline.dispose() - } -} + if (this._colorPaletteTexture) { + this._colorPaletteTexture.dispose() + this._colorPaletteTexture = undefined + } -/** - * Creates a new instance of the default wireframe material. - * @returns {THREE.LineBasicMaterial} A new instance of LineBasicMaterial. - */ -export function createWireframe () { - const material = new THREE.LineBasicMaterial({ - depthTest: false, - opacity: 1, - color: new THREE.Color(0x0000ff), - transparent: true - }) - return material + this._opaque.dispose() + this._transparent.dispose() + this._modelOpaque.dispose() + this._modelTransparent.dispose() + this._ghost.dispose() + this._mask.dispose() + this._outline.dispose() + this._merge.three.dispose() + } } diff --git a/src/vim-web/core-viewers/webgl/loader/materials/mergeMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/mergeMaterial.ts index 88f108c97..9f4678b4f 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/mergeMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/mergeMaterial.ts @@ -4,69 +4,87 @@ import * as THREE from 'three' +/** @internal */ export class MergeMaterial { - material: THREE.ShaderMaterial + three: THREE.ShaderMaterial + private _onUpdate?: () => void - constructor () { - this.material = createMergeMaterial() + constructor (onUpdate?: () => void) { + this.three = createMergeMaterial() + this._onUpdate = onUpdate } get color () { - return this.material.uniforms.color.value + return this.three.uniforms.color.value } set color (value: THREE.Color) { - this.material.uniforms.color.value.copy(value) - this.material.uniformsNeedUpdate = true + this.three.uniforms.color.value.copy(value) + this.three.uniformsNeedUpdate = true + this._onUpdate?.() } get sourceA () { - return this.material.uniforms.sourceA.value + return this.three.uniforms.sourceA.value } set sourceA (value: THREE.Texture) { - this.material.uniforms.sourceA.value = value - this.material.uniformsNeedUpdate = true + this.three.uniforms.sourceA.value = value + this.three.uniformsNeedUpdate = true + this._onUpdate?.() } get sourceB () { - return this.material.uniforms.sourceB.value + return this.three.uniforms.sourceB.value } set sourceB (value: THREE.Texture) { - this.material.uniforms.sourceB.value = value - this.material.uniformsNeedUpdate = true + this.three.uniforms.sourceB.value = value + this.three.uniformsNeedUpdate = true + this._onUpdate?.() } } /** * Material that Merges current fragment with a source texture. + * Optimized with GLSL3, texelFetch, and early-out for pixels without outlines. */ export function createMergeMaterial () { return new THREE.ShaderMaterial({ + glslVersion: THREE.GLSL3, uniforms: { sourceA: { value: null }, sourceB: { value: null }, color: { value: new THREE.Color(0xffffff) } }, - vertexShader: ` - varying vec2 vUv; + vertexShader: /* glsl */ ` + out vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, - fragmentShader: ` + fragmentShader: /* glsl */ ` uniform vec3 color; uniform sampler2D sourceA; uniform sampler2D sourceB; - varying vec2 vUv; - + in vec2 vUv; + out vec4 fragColor; + void main() { - vec4 A = texture2D(sourceA, vUv); - vec4 B = texture2D(sourceB, vUv); + // Fetch outline intensity first (cheaper to check) + // Use texture() for proper handling of different resolutions + vec4 B = texture(sourceB, vUv); + + // Early-out: if no outline, just copy scene directly + if (B.x < 0.01) { + fragColor = texture(sourceA, vUv); + return; + } - gl_FragColor = vec4(mix(A.xyz, color, B.x),1.0f); + // Fetch scene and blend with outline color + vec4 A = texture(sourceA, vUv); + fragColor = vec4(mix(A.xyz, color, B.x), 1.0); } ` }) diff --git a/src/vim-web/core-viewers/webgl/loader/materials/modelMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/modelMaterial.ts new file mode 100644 index 000000000..19cf974da --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/materials/modelMaterial.ts @@ -0,0 +1,153 @@ +/** + * @module vim-loader/materials + * This module provides custom materials for visualizing and isolating objects in VIM. + */ + +import * as THREE from 'three' + +/** + * @internal + * Material wrapper for fast rendering mode (ModelMaterial). + * Uses screen-space derivative normals instead of vertex normals for faster performance. + */ +export class ModelMaterial { + three: THREE.ShaderMaterial + private _onUpdate?: () => void + + // Color palette texture (shared, owned by Materials singleton) + _colorPaletteTexture: THREE.DataTexture | undefined + + constructor (material?: THREE.ShaderMaterial, onUpdate?: () => void) { + this.three = material ?? createModelMaterialShader() + this._onUpdate = onUpdate + } + + /** + * Sets the color palette texture for indexed color lookup. + * The texture is shared between materials (created in Materials singleton). + */ + setColorPaletteTexture(texture: THREE.DataTexture | undefined) { + this._colorPaletteTexture = texture + if (this.three.uniforms) { + this.three.uniforms.colorPaletteTexture.value = texture ?? null + } + this._onUpdate?.() + } + + get clippingPlanes () { + return this.three.clippingPlanes + } + + set clippingPlanes (value: THREE.Plane[] | null) { + this.three.clippingPlanes = value + this._onUpdate?.() + } + + dispose () { + this.three.dispose() + } +} + +/** + * @internal + * Creates an opaque ModelMaterial for fast rendering mode. + */ +export function createModelOpaque(onUpdate?: () => void): ModelMaterial { + return new ModelMaterial(createModelMaterialShader(false), onUpdate) +} + +/** + * @internal + * Creates a transparent ModelMaterial for fast rendering mode. + */ +export function createModelTransparent(onUpdate?: () => void): ModelMaterial { + return new ModelMaterial(createModelMaterialShader(true), onUpdate) +} + +/** + * Creates the shader material for isolation/fast mode. + * + * Uses screen-space derivative normals for per-pixel lighting. + * Color lookup is palette-based: per-vertex colorIndex for default, + * per-instance instanceColorIndex for overrides (instanced meshes). + */ +function createModelMaterialShader (transparent: boolean = false) { + + return new THREE.ShaderMaterial({ + side: THREE.DoubleSide, + glslVersion: THREE.GLSL3, + uniforms: { + colorPaletteTexture: { value: null }, + }, + clipping: true, + transparent: transparent, + opacity: transparent ? 0.25 : 1.0, + depthWrite: !transparent, + vertexShader: /* glsl */ ` + #include + #include + #include + + // VISIBILITY + in float ignore; + + // COLORING + out vec3 vColor; + out vec3 vViewPosition; + + in float colorIndex; + in float instanceColorIndex; + in float colored; + uniform sampler2D colorPaletteTexture; + + void main() { + #include + #include + #include + #include + + if (ignore > 0.5) { + gl_Position = vec4(0.0, 0.0, -2.0, 1.0); + return; + } + + // COLORING — unified palette lookup + int palIdx = int(colorIndex); + #ifdef USE_INSTANCING + if (colored > 0.5) palIdx = int(instanceColorIndex); + #endif + int x = palIdx % 128; + int y = palIdx / 128; + vColor = texelFetch(colorPaletteTexture, ivec2(x, y), 0).rgb; + + vViewPosition = -mvPosition.xyz; + } + `, + fragmentShader: /* glsl */ ` + #include + #include + #include + + in vec3 vColor; + in vec3 vViewPosition; + + out vec4 fragColor; + + void main() { + #include + #include + + // LIGHTING (Screen-space derivatives - per pixel) + vec3 fdx = dFdx(vViewPosition); + vec3 fdy = dFdy(vViewPosition); + vec3 normal = normalize(cross(fdx, fdy)); + const vec3 LIGHT_DIR = vec3(0.447214, 0.547723, 0.707107); + float light = dot(normal, LIGHT_DIR); + light = 0.5 + (light * 0.5); + vec3 finalColor = vColor * light; + + fragColor = vec4(finalColor, ${transparent ? '0.25' : '1.0'}); + } + ` + }) +} diff --git a/src/vim-web/core-viewers/webgl/loader/materials/outlineMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/outlineMaterial.ts index bdac4c137..bad6f7f1d 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/outlineMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/outlineMaterial.ts @@ -4,9 +4,12 @@ import * as THREE from 'three' -/** Outline Material based on edge detection. */ +/** + * @internal + * Outline Material based on edge detection. + */ export class OutlineMaterial { - material: THREE.ShaderMaterial + three: THREE.ShaderMaterial private _camera: | THREE.PerspectiveCamera | THREE.OrthographicCamera @@ -14,19 +17,19 @@ export class OutlineMaterial { private _resolution: THREE.Vector2 private _precision: number = 1 - private _antialias: boolean = false + private _onUpdate?: () => void constructor ( options?: Partial<{ sceneBuffer: THREE.Texture resolution: THREE.Vector2 precision: number - antialias: boolean camera: THREE.PerspectiveCamera | THREE.OrthographicCamera - }> + }>, + onUpdate?: () => void ) { - this.material = createOutlineMaterial() - this._antialias = options?.antialias ?? false + this.three = createOutlineMaterial() + this._onUpdate = onUpdate this._precision = options?.precision ?? 1 this._resolution = options?.resolution ?? new THREE.Vector2(1, 1) this.resolution = this._resolution @@ -36,19 +39,6 @@ export class OutlineMaterial { this.camera = options?.camera } - /** - * Enable antialiasing for the outline. - * This is actually applied in the rendering composer. - */ - get antialias () { - return this._antialias - } - - set antialias (value: boolean) { - this._antialias = value - this.material.uniformsNeedUpdate = true - } - /** * Precision of the outline. This is used to scale the resolution of the outline. */ @@ -59,6 +49,7 @@ export class OutlineMaterial { set precision (value: number) { this._precision = value this.resolution = this._resolution + this._onUpdate?.() } /** @@ -69,7 +60,7 @@ export class OutlineMaterial { } set resolution (value: THREE.Vector2) { - this.material.uniforms.screenSize.value.set( + this.three.uniforms.screenSize.value.set( value.x * this._precision, value.y * this._precision, 1 / (value.x * this._precision), @@ -77,7 +68,8 @@ export class OutlineMaterial { ) this._resolution = value - this.material.uniformsNeedUpdate = true + this.three.uniformsNeedUpdate = true + this._onUpdate?.() } /** @@ -91,97 +83,80 @@ export class OutlineMaterial { value: THREE.PerspectiveCamera | THREE.OrthographicCamera | undefined ) { this._camera = value - this.material.uniforms.cameraNear.value = value?.near ?? 1 - this.material.uniforms.cameraFar.value = value?.far ?? 1000 - this.material.uniformsNeedUpdate = true - } - - /** - * Blur of the outline. This is used to smooth the outline. - */ - get strokeBlur () { - return this.material.uniforms.strokeBlur.value - } - - set strokeBlur (value: number) { - this.material.uniforms.strokeBlur.value = value - this.material.uniformsNeedUpdate = true + this.three.uniforms.cameraNear.value = value?.near ?? 1 + this.three.uniforms.cameraFar.value = value?.far ?? 1000 + this.three.uniformsNeedUpdate = true + this._onUpdate?.() } /** - * Bias of the outline. This is used to control the strength of the outline. + * Intensity of the outline. Controls the strength of the edge detection. */ - get strokeBias () { - return this.material.uniforms.strokeBias.value + get intensity () { + return this.three.uniforms.intensity.value } - set strokeBias (value: number) { - this.material.uniforms.strokeBias.value = value - this.material.uniformsNeedUpdate = true - } - - /** - * Multiplier of the outline. This is used to control the strength of the outline. - */ - get strokeMultiplier () { - return this.material.uniforms.strokeMultiplier.value - } - - set strokeMultiplier (value: number) { - this.material.uniforms.strokeMultiplier.value = value - this.material.uniformsNeedUpdate = true + set intensity (value: number) { + this.three.uniforms.intensity.value = value + this.three.uniformsNeedUpdate = true + this._onUpdate?.() } /** * Color of the outline. */ get color () { - return this.material.uniforms.outlineColor.value + return this.three.uniforms.outlineColor.value } set color (value: THREE.Color) { - this.material.uniforms.outlineColor.value.set(value) - this.material.uniformsNeedUpdate = true + this.three.uniforms.outlineColor.value.set(value) + this.three.uniformsNeedUpdate = true + this._onUpdate?.() } /** * Scene buffer used to render the outline. */ get sceneBuffer () { - return this.material.uniforms.sceneBuffer.value + return this.three.uniforms.sceneBuffer.value } set sceneBuffer (value: THREE.Texture) { - this.material.uniforms.sceneBuffer.value = value - this.material.uniformsNeedUpdate = true + this.three.uniforms.sceneBuffer.value = value + this.three.uniformsNeedUpdate = true + this._onUpdate?.() } /** * Depth buffer used to render the outline. */ get depthBuffer () { - return this.material.uniforms.depthBuffer.value + return this.three.uniforms.depthBuffer.value } set depthBuffer (value: THREE.Texture) { - this.material.uniforms.depthBuffer.value = value - this.material.uniformsNeedUpdate = true + this.three.uniforms.depthBuffer.value = value + this.three.uniformsNeedUpdate = true + this._onUpdate?.() } /** * Dispose of the outline material. */ dispose () { - this.material.dispose() + this.three.dispose() } } /** - * This material =computes outline using the depth buffer and combines it with the scene buffer to create a final scene. + * Creates outline material using depth-based edge detection. */ export function createOutlineMaterial () { return new THREE.ShaderMaterial({ lights: false, + glslVersion: THREE.GLSL3, + depthWrite: false, uniforms: { // Input buffers sceneBuffer: { value: null }, @@ -196,12 +171,10 @@ export function createOutlineMaterial () { // Options outlineColor: { value: new THREE.Color(0xffffff) }, - strokeMultiplier: { value: 2 }, - strokeBias: { value: 2 }, - strokeBlur: { value: 3 } + intensity: { value: 2 } }, vertexShader: ` - varying vec2 vUv; + out vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); @@ -209,38 +182,23 @@ export function createOutlineMaterial () { `, fragmentShader: ` #include - // The above include imports "perspectiveDepthToViewZ" - // and other GLSL functions from ThreeJS we need for reading depth. + uniform sampler2D depthBuffer; uniform float cameraNear; uniform float cameraFar; uniform vec4 screenSize; uniform vec3 outlineColor; - uniform float strokeMultiplier; - uniform float strokeBias; - uniform int strokeBlur; - - varying vec2 vUv; - - // Helper functions for reading from depth buffer. - float readDepth (sampler2D depthSampler, vec2 coord) { - float fragCoordZ = texture2D(depthSampler, coord).x; - float viewZ = perspectiveDepthToViewZ( fragCoordZ, cameraNear, cameraFar ); - return viewZToOrthographicDepth( viewZ, cameraNear, cameraFar ); - } - float getLinearDepth(vec3 pos) { - return -(viewMatrix * vec4(pos, 1.0)).z; - } - - float getLinearScreenDepth(sampler2D map) { - vec2 uv = gl_FragCoord.xy * screenSize.zw; - return readDepth(map,uv); - } - // Helper functions for reading normals and depth of neighboring pixels. + uniform float intensity; + + in vec2 vUv; + out vec4 fragColor; + + // Use texelFetch for faster indexed access (WebGL 2) float getPixelDepth(int x, int y) { - // screenSize.zw is pixel size - // vUv is current position - return readDepth(depthBuffer, vUv + screenSize.zw * vec2(x, y)); + ivec2 pixelCoord = ivec2(vUv * screenSize.xy) + ivec2(x, y); + float fragCoordZ = texelFetch(depthBuffer, pixelCoord, 0).x; + float viewZ = perspectiveDepthToViewZ(fragCoordZ, cameraNear, cameraFar); + return viewZToOrthographicDepth(viewZ, cameraNear, cameraFar); } float saturate(float num) { @@ -249,27 +207,26 @@ export function createOutlineMaterial () { void main() { float depth = getPixelDepth(0, 0); - - // Get the difference between depth of neighboring pixels and current. - float depthDiff = 0.0; - int start = -strokeBlur / 2; - for(int i=0; i < strokeBlur; i ++){ - for(int j=0; j < strokeBlur; j ++){ - depthDiff += abs(depth - getPixelDepth(start +i, start + j)); - } + + // Early-out: skip for background pixels (no geometry) + if (depth >= 0.99) { + fragColor = vec4(0.0, 0.0, 0.0, 0.0); + return; } - - depthDiff = depthDiff / (float(strokeBlur*strokeBlur) -1.0); - - depthDiff = depthDiff * strokeMultiplier; - depthDiff = saturate(depthDiff); - depthDiff = pow(depthDiff, strokeBias); - - float outline = depthDiff; - - // Combine outline with scene color. - vec4 outlineColor = vec4(outlineColor, 1.0f); - gl_FragColor = vec4(mix(vec4(0.0,0.0,0.0,0.0), outlineColor, outline)); + + // Cross edge detection: 4 neighbors at distance 1. + // step() converts depth diff to binary (edge or not). + // Thickness is controlled by outlineScale (render target resolution). + float outline = 0.0; + outline += step(0.001, abs(depth - getPixelDepth( 0, -1))); + outline += step(0.001, abs(depth - getPixelDepth( 0, 1))); + outline += step(0.001, abs(depth - getPixelDepth(-1, 0))); + outline += step(0.001, abs(depth - getPixelDepth( 1, 0))); + outline = saturate(outline * 0.25 * intensity); + + // Output outline intensity to R channel only (RedFormat texture) + // Merge pass will use this to blend outline color with scene + fragColor = vec4(outline, 0.0, 0.0, 0.0); } ` }) 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..fcb4a1a25 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/materials/pickingMaterial.ts @@ -0,0 +1,154 @@ +/** + * @module vim-loader/materials + * 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 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 - 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. + */ +export function createPickingMaterial() { + return new THREE.ShaderMaterial({ + uniforms: { + uCameraPos: { value: new THREE.Vector3() }, + uCameraDir: { value: new THREE.Vector3() } + }, + side: THREE.DoubleSide, + clipping: true, + glslVersion: THREE.GLSL3, + vertexShader: /* glsl */ ` + #include + #include + #include + + // Visibility attribute (used by VIM meshes) + in float ignore; + // Pre-packed ID: (vimIndex << 24) | elementIndex + in uint packedId; + + flat out uint vPackedId; + out float vIgnore; + out vec3 vWorldPos; + + void main() { + #include + #include + #include + #include + + vIgnore = ignore; + + // Place ignored vertices behind near plane to clip them. + if (ignore > 0.0) { + gl_Position = vec4(0.0, 0.0, -2.0, 1.0); + return; + } + + vPackedId = packedId; + + // Compute world position for depth calculation and normal computation + #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; + + flat in uint vPackedId; + in float vIgnore; + in vec3 vWorldPos; + + out vec4 fragColor; + + 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); + + // 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 + fragColor = vec4(packedIdFloat, depth, normal.x, normal.y); + } + ` + }) +} + +/** + * @internal + * PickingMaterial class that wraps the shader material with camera update functionality. + */ +export class PickingMaterial { + readonly three: THREE.ShaderMaterial + private static _tempDir = new THREE.Vector3() + + constructor() { + this.three = createPickingMaterial() + } + + /** + * Updates the camera uniforms for depth calculation. + * Must be called before rendering. + */ + updateCamera(camera: THREE.Camera): void { + camera.getWorldDirection(PickingMaterial._tempDir) + this.three.uniforms.uCameraPos.value.copy(camera.position) + this.three.uniforms.uCameraDir.value.copy(PickingMaterial._tempDir) + } + + /** + * Gets or sets the clipping planes for section box support. + */ + get clippingPlanes(): THREE.Plane[] { + return this.three.clippingPlanes ?? [] + } + + set clippingPlanes(planes: THREE.Plane[]) { + this.three.clippingPlanes = planes + } + + /** + * Disposes of the material resources. + */ + dispose(): void { + this.three.dispose() + } +} diff --git a/src/vim-web/core-viewers/webgl/loader/materials/simpleMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/simpleMaterial.ts deleted file mode 100644 index 05b3dd09e..000000000 --- a/src/vim-web/core-viewers/webgl/loader/materials/simpleMaterial.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * @module vim-loader/materials - * This module provides custom materials for visualizing and isolating objects in VIM. - */ - -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. - * - * This material is optimized for both instanced and merged meshes, with support for clipping planes. - * - * @returns {THREE.ShaderMaterial} A custom shader material for isolation mode. - */ -export function createSimpleMaterial () { - return new THREE.ShaderMaterial({ - side: THREE.DoubleSide, - // No uniforms are needed for this shader. - uniforms: {}, - // Enable vertex colors for both instanced and merged meshes. - vertexColors: true, - // Enable support for clipping planes. - clipping: true, - vertexShader: /* glsl */ ` - #include - #include - #include - - // VISIBILITY - // Determines if an object or vertex should be visible. - // Used as an instance attribute for instanced meshes or as a vertex attribute for merged meshes. - attribute float ignore; - - // LIGHTING - // Passes the vertex position to the fragment shader for lighting calculations. - varying vec3 vPosition; - - // COLORING - // Passes the color of the vertex or instance to the fragment shader. - varying vec3 vColor; - - // 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. - attribute float colored; - - // Fix for a known issue where setting mesh.instanceColor does not properly enable USE_INSTANCING_COLOR. - // This ensures that instance colors are always used when required. - #ifndef USE_INSTANCING_COLOR - attribute vec3 instanceColor; - #endif - - void main() { - #include - #include - #include - #include - - // If ignore is greater than 0, hide the object by moving it far out of view. - if (ignore > 0.0) { - gl_Position = vec4(1e20, 1e20, 1e20, 1.0); - return; - } - - // COLORING - // Default to the vertex color. - vColor = color.xyz; - - // 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; - #endif - - // LIGHTING - // Pass the model-view position to the fragment shader for lighting calculations. - vPosition = vec3(mvPosition) / mvPosition.w; - } - `, - fragmentShader: /* glsl */ ` - #include - #include - #include - - - // Position and color data passed from the vertex shader. - varying vec3 vPosition; - varying vec3 vColor; - - void main() { - #include - #include - - // Set the fragment color to the interpolated vertex or instance color. - gl_FragColor = vec4(vColor, 1.0); - - // LIGHTING - // Compute a pseudo-normal using screen-space derivatives of the vertex position. - vec3 normal = normalize(cross(dFdx(vPosition), dFdy(vPosition))); - - // Apply simple directional lighting. - // Normalize the light direction for consistent shading. - float light = dot(normal, normalize(vec3(1.4142, 1.732, 2.236))); - light = 0.5 + (light * 0.5); // Adjust light intensity to range [0.5, 1.0]. - - // Modulate the fragment color by the lighting intensity. - gl_FragColor.xyz *= light; - } - ` - }) -} diff --git a/src/vim-web/core-viewers/webgl/loader/materials/skyboxMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/skyboxMaterial.ts deleted file mode 100644 index 395567499..000000000 --- a/src/vim-web/core-viewers/webgl/loader/materials/skyboxMaterial.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @module vim-loader/materials - */ - -import * as THREE from 'three' - -/** - * Material for the skybox - */ -export class SkyboxMaterial extends THREE.ShaderMaterial { - get skyColor (): THREE.Color { - return this.uniforms.skyColor.value - } - - set skyColor (value: THREE.Color) { - this.uniforms.skyColor.value = value - this.uniformsNeedUpdate = true - } - - get groundColor () { - return this.uniforms.groundColor.value - } - - set groundColor (value: THREE.Color) { - this.uniforms.groundColor.value = value - this.uniformsNeedUpdate = true - } - - get sharpness () { - return this.uniforms.sharpness.value - } - - set sharpness (value: number) { - this.uniforms.sharpness.value = value - this.uniformsNeedUpdate = true - } - - constructor ( - skyColor: THREE.Color = new THREE.Color(0.68, 0.85, 0.9), - groundColor: THREE.Color = new THREE.Color(0.8, 0.7, 0.5), - sharpness: number = 2) { - super({ - depthWrite: false, - uniforms: { - skyColor: { value: skyColor }, - groundColor: { value: groundColor }, - sharpness: { value: sharpness } - }, - vertexShader: /* glsl */ ` - varying vec3 vPosition; - varying vec3 vCameraPosition; - - void main() { - // Compute vertex position - vec4 mvPosition = modelViewMatrix * vec4(position, 1.0 ); - gl_Position = projectionMatrix * mvPosition; - - // Set z to camera.far so that the skybox is always rendered behind everything else - gl_Position.z = gl_Position.w; - - // Pass the vertex world position to the fragment shader - vPosition = (modelMatrix * vec4(position, 1.0)).xyz; - - // Pass the camera position to the fragment shader - mat4 inverseViewMatrix = inverse(viewMatrix); - vCameraPosition = (inverseViewMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz; - } - `, - fragmentShader: /* glsl */ ` - uniform vec3 skyColor; - uniform vec3 groundColor; - uniform float sharpness; - - varying vec3 vPosition; - varying vec3 vCameraPosition; - - void main() { - // Define the up vector - vec3 up = vec3(0.0, 0.0, 1.0); - - // Calculate the direction from the pixel to the camera - vec3 directionToCamera = normalize(vCameraPosition - vPosition); - - // Calculate the dot product between the normal and the up vector - float dotProduct = dot(directionToCamera, up); - - // Normalize the dot product to be between 0 and 1 - float t = (dotProduct + 1.0) / 2.0; - - // Apply a power function to create a sharper transition - t = pow(t, sharpness); - - // Interpolate between colors - vec3 pastelSkyBlue = vec3(0.68, 0.85, 0.9); // Light sky blue pastel - vec3 pastelEarthyBrown = vec3(0.8, 0.7, 0.5); // Light earthy brown pastel - vec3 color = mix(skyColor, groundColor, t); - - // Output the final color - gl_FragColor = vec4(color, 1.0); - } - ` - }) - } -} 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..79220512b 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/standardMaterial.ts @@ -5,115 +5,110 @@ import * as THREE from 'three' /** + * @internal * Type alias for THREE uniforms */ export type ShaderUniforms = { [uniform: string]: THREE.IUniform } +/** @internal */ export function createOpaque () { return new StandardMaterial(createBasicOpaque()) } +/** @internal */ export function createTransparent () { return new StandardMaterial(createBasicTransparent()) } /** + * @internal * Creates a new instance of the default loader opaque material. * @returns {THREE.MeshLambertMaterial} A new instance of MeshLambertMaterial with transparency. */ export function createBasicOpaque () { return new THREE.MeshLambertMaterial({ color: 0xcccccc, - vertexColors: true, flatShading: true, side: THREE.DoubleSide, }) } /** + * @internal * Creates a new instance of the default loader transparent material. * @returns {THREE.MeshPhongMaterial} A new instance of MeshPhongMaterial with transparency. */ export function createBasicTransparent () { const mat = createBasicOpaque() mat.transparent = true + mat.opacity = 0.25 return mat } /** + * @internal * Material used for both opaque and tranparent surfaces of a VIM model. */ export class StandardMaterial { - material: THREE.Material + three: THREE.Material uniforms: ShaderUniforms | undefined // Parameters - _focusIntensity: number = 0.5 - _focusColor: THREE.Color = new THREE.Color(0xffffff) - - _sectionStrokeWitdh: number = 0.01 - _sectionStrokeFallof: number = 0.75 + _sectionStrokeWidth: number = 0.01 + _sectionStrokeFalloff: number = 0.75 _sectionStrokeColor: THREE.Color = new THREE.Color(0xf6f6f6) + // Color palette texture (shared, owned by Materials singleton) + _colorPaletteTexture: THREE.DataTexture | undefined + constructor (material: THREE.Material) { - this.material = material + this.three = material this.patchShader(material) } - get color () { - if (this.material instanceof THREE.MeshLambertMaterial) { - return this.material.color - } - return new THREE.Color(0xffffff) - } - - set color (color: THREE.Color) { - if (this.material instanceof THREE.MeshLambertMaterial) { - this.material.color = color - } - } - - get focusIntensity () { - return this._focusIntensity - } - - set focusIntensity (value: number) { - this._focusIntensity = value + /** + * Sets the color palette texture for indexed color lookup. + * The texture is shared between opaque and transparent materials (created in Materials singleton). + */ + setColorPaletteTexture(texture: THREE.DataTexture | undefined) { + this._colorPaletteTexture = texture if (this.uniforms) { - this.uniforms.focusIntensity.value = value + this.uniforms.colorPaletteTexture.value = texture ?? null } } - get focusColor () { - return this._focusColor + get color () { + if (this.three instanceof THREE.MeshLambertMaterial) { + return this.three.color + } + return new THREE.Color(0xffffff) } - set focusColor (value: THREE.Color) { - this._focusColor = value - if (this.uniforms) { - this.uniforms.focusColor.value = value + set color (color: THREE.Color) { + if (this.three instanceof THREE.MeshLambertMaterial) { + this.three.color = color } } - get sectionStrokeWitdh () { - return this._sectionStrokeWitdh + get sectionStrokeWidth () { + return this._sectionStrokeWidth } - set sectionStrokeWitdh (value: number) { - this._sectionStrokeWitdh = value + set sectionStrokeWidth (value: number) { + this._sectionStrokeWidth = value if (this.uniforms) { - this.uniforms.sectionStrokeWitdh.value = value + this.uniforms.sectionStrokeWidth.value = value } } - get sectionStrokeFallof () { - return this._sectionStrokeFallof + get sectionStrokeFalloff () { + return this._sectionStrokeFalloff } - set sectionStrokeFallof (value: number) { - this._sectionStrokeFallof = value + set sectionStrokeFalloff (value: number) { + this._sectionStrokeFalloff = value if (this.uniforms) { - this.uniforms.sectionStrokeFallof.value = value + this.uniforms.sectionStrokeFalloff.value = value } } @@ -129,15 +124,16 @@ export class StandardMaterial { } get clippingPlanes () { - return this.material.clippingPlanes + return this.three.clippingPlanes } set clippingPlanes (value: THREE.Plane[] | null) { - this.material.clippingPlanes = value + this.three.clippingPlanes = value } dispose () { - this.material.dispose() + // Don't dispose texture - it's owned by Materials singleton + this.three.dispose() } /** @@ -149,11 +145,10 @@ export class StandardMaterial { patchShader (material: THREE.Material) { material.onBeforeCompile = (shader) => { this.uniforms = shader.uniforms - this.uniforms.focusIntensity = { value: this._focusIntensity } - this.uniforms.focusColor = { value: this._focusColor } - this.uniforms.sectionStrokeWidth = { value: this._sectionStrokeWitdh } - this.uniforms.sectionStrokeFalloff = { value: this._sectionStrokeFallof } + this.uniforms.sectionStrokeWidth = { value: this._sectionStrokeWidth } + this.uniforms.sectionStrokeFalloff = { value: this._sectionStrokeFalloff } this.uniforms.sectionStrokeColor = { value: this._sectionStrokeColor } + this.uniforms.colorPaletteTexture = { value: this._colorPaletteTexture ?? null } shader.vertexShader = shader.vertexShader // VERTEX DECLARATIONS @@ -161,38 +156,30 @@ export class StandardMaterial { '#include ', ` #include - + // COLORING - // attribute for color override - // merged meshes use it as vertex attribute - // instanced meshes use it as an instance attribute + // Per-vertex color palette index + attribute float colorIndex; + // Per-instance palette override index (instanced meshes only) + attribute float instanceColorIndex; + // 1 = use instanceColorIndex, 0 = use per-vertex colorIndex attribute float colored; + // 128×128 quantized color palette (25³ = 15,625 entries) + uniform sampler2D colorPaletteTexture; - // There seems to be an issue where setting mehs.instanceColor - // doesn't properly set USE_INSTANCING_COLOR - // so we always use it as a fix - #ifndef USE_INSTANCING_COLOR - attribute vec3 instanceColor; - #endif - - // Passed to fragment to ignore phong model + // Passed to fragment to control lighting model varying float vColored; - + // VISIBILITY // Instance or vertex attribute to hide objects - // Used as instance attribute for instanced mesh and as vertex attribute for merged meshes. - attribute float ignore; + // Used as instance attribute for instanced mesh and as vertex attribute for merged meshes. + attribute float ignore; // Passed to fragment to discard them varying float vIgnore; - // FOCUS - // Instance or vertex attribute to higlight objects - // Used as instance attribute for instanced mesh and as vertex attribute for merged meshes. - attribute float focused; - varying float vHighlight; ` ) // VERTEX IMPLEMENTATION @@ -200,20 +187,17 @@ export class StandardMaterial { '#include ', ` // COLORING - vColor = color; vColored = colored; - - // colored == 1 -> instance color - // colored == 0 -> vertex color + int palIdx = int(colorIndex); #ifdef USE_INSTANCING - vColor.xyz = colored * instanceColor.xyz + (1.0f - colored) * color.xyz; + if (colored > 0.5) palIdx = int(instanceColorIndex); #endif + int x = palIdx % 128; + int y = palIdx / 128; + vColor.xyz = texelFetch(colorPaletteTexture, ivec2(x, y), 0).rgb; // VISIBILITY vIgnore = ignore; - - // FOCUS - vHighlight = focused; ` ) // FRAGMENT DECLARATIONS @@ -234,10 +218,6 @@ export class StandardMaterial { uniform float sectionStrokeFalloff; uniform vec3 sectionStrokeColor; - // FOCUS - varying float vHighlight; - uniform float focusIntensity; - uniform vec3 focusColor; ` ) // FRAGMENT IMPLEMENTATION @@ -256,9 +236,6 @@ export class StandardMaterial { float d = length(outgoingLight); gl_FragColor = vec4(vColored * vColor.xyz * d + (1.0f - vColored) * outgoingLight.xyz, diffuseColor.a); - // FOCUS - gl_FragColor = mix(gl_FragColor, vec4(focusColor,1.0f), vHighlight * focusIntensity); - // STROKES WHERE GEOMETRY INTERSECTS CLIPPING PLANE #if NUM_CLIPPING_PLANES > 0 vec4 strokePlane; diff --git a/src/vim-web/core-viewers/webgl/loader/materials/transferMaterial.ts b/src/vim-web/core-viewers/webgl/loader/materials/transferMaterial.ts index 242900cb1..47dc9485a 100644 --- a/src/vim-web/core-viewers/webgl/loader/materials/transferMaterial.ts +++ b/src/vim-web/core-viewers/webgl/loader/materials/transferMaterial.ts @@ -5,26 +5,29 @@ import * as THREE from 'three' /** - * This material simply sample and returns the value at each texel position of the texture. + * This material simply samples and returns the value at each texel position of the texture. + * Optimized with GLSL3 for better performance. */ export function createTransferMaterial () { return new THREE.ShaderMaterial({ + glslVersion: THREE.GLSL3, uniforms: { source: { value: null } }, - vertexShader: ` - varying vec2 vUv; + vertexShader: /* glsl */ ` + out vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, - fragmentShader: ` + fragmentShader: /* glsl */ ` uniform sampler2D source; - varying vec2 vUv; - + in vec2 vUv; + out vec4 fragColor; + void main() { - gl_FragColor = texture2D(source, vUv); + fragColor = texture(source, vUv); } ` }) diff --git a/src/vim-web/core-viewers/webgl/loader/mesh.ts b/src/vim-web/core-viewers/webgl/loader/mesh.ts index 667dc5801..6a8583543 100644 --- a/src/vim-web/core-viewers/webgl/loader/mesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/mesh.ts @@ -4,197 +4,14 @@ 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 +/** @internal */ +export type MergedSubmesh = InsertableSubmesh +/** @internal */ export type Submesh = MergedSubmesh | InstancedSubmesh +/** @internal */ export class SimpleInstanceSubmesh { mesh: THREE.InstancedMesh get three () { return this.mesh } @@ -206,68 +23,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/g3dOffsets.ts b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts index 3482548f9..da0454903 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dOffsets.ts @@ -2,9 +2,17 @@ * @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' +/** @internal */ export class G3dMeshCounts { instances: number = 0 meshes: number = 0 @@ -13,6 +21,7 @@ export class G3dMeshCounts { } /** + * @internal * Holds the offsets needed to preallocate geometry for a given meshIndexSubset */ export class G3dMeshOffsets { @@ -26,7 +35,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' */ @@ -34,23 +43,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() - for (let i = 1; i < meshCount; i++) { - offsets[i] = offsets[i - 1] + getter(i - 1) + let indexOffset = 0 + let vertexOffset = 0 + + for (let i = 0; i < meshCount; i++) { + indexOffsets[i] = indexOffset + vertexOffsets[i] = vertexOffset + + 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 } /** @@ -74,26 +93,10 @@ export class G3dMeshOffsets { } /** - * Returns instance counts of given mesh. - * @param mesh subset-based mesh index + * Returns true if this offset has any geometry (indices > 0). */ - getMeshInstanceCount (mesh: number) { - return this.subset.getMeshInstanceCount(mesh) + any () { + return this.counts.indices > 0 } - /** - * 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 d34efc663..1811279f5 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/g3dSubset.ts @@ -2,18 +2,57 @@ * @module vim-loader */ -import { G3d, MeshSection, G3dScene, FilterMode } from 'vim-format' -import { G3dMeshOffsets, G3dMeshCounts } from './g3dOffsets' -import * as THREE from 'three' +import { MeshSection } from 'vim-format' +import { G3dMeshOffsets } from './g3dOffsets' +import { MappedG3d } from './mappedG3d' + +/** Filter mode for subset operations. Only exports modes that are actually implemented. */ +export type SubsetFilter = 'instance' | 'mesh' + +/** + * A filtered view of geometry instances for progressive loading. + * Obtained via `vim.subset()`, then refined with `filter()`, `except()`, and `chunks()`. + * + * @example + * ```ts + * // Load all geometry + * await vim.load() + * + * // Or load progressively in chunks + * const all = vim.subset() + * const chunks = all.chunks(500_000) // Split into ~500K index chunks + * for (const chunk of chunks) { + * await vim.load(chunk) + * } + * + * // Or load a filtered subset + * const sub = vim.subset().filter('instance', [0, 1, 2, 3]) + * await vim.load(sub) + * ``` + */ +export interface ISubset { + /** Total instance count in this subset. */ + getInstanceCount(): number + /** Split into smaller subsets by index count threshold (for chunked loading). */ + chunks(count: number): ISubset[] + /** Return a new subset excluding instances matching the filter. */ + except(mode: SubsetFilter, filter: number[] | Set): ISubset + /** Return a new subset including only instances matching the filter. */ + filter(mode: SubsetFilter, filter: number[] | Set): ISubset +} /** + * @internal * 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 - // source-based indices of included instanced - private _instances: number[] +export class G3dSubset implements ISubset { + private _source: MappedG3d + + /** 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,100 +60,90 @@ export class G3dSubset { private _meshInstances: Array> /** - * @param source Underlying data source for the subset - * @param instances source-based instance indices of included instances. + * Creates a full set containing all instances from the source. */ - constructor ( - source: G3dScene | G3d, - // 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 + } - // Consider removing this if too slow. - if (!instances) { - instances = [] - for (let i = 0; i < source.instanceMeshes.length; i++) { - if (source.instanceMeshes[i] >= 0) { - instances.push(i) - } - } - } - this._instances = instances - - // Compute mesh data. - this._meshes = [] - const map = new Map>() - for (const instance of instances) { - const mesh = source.instanceMeshes[instance] - if (!map.has(mesh)) { - this._meshes.push(mesh) - map.set(mesh, [instance]) - } else { - map.get(mesh)?.push(instance) - } - } - - this._meshInstances = new Array>(this._meshes.length) - for (let i = 0; i < this._meshes.length; i++) { - this._meshInstances[i] = map.get(this._meshes[i]) - } + /** + * 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 } + /** + * 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[] = [] + const result: G3dSubset[] = [] let currentSize = 0 - let currentInstances: number[] = [] - for(let i = 0; i < this.getMeshCount(); i++) { - - // Get mesh size and instances + let currentInstanceCount = 0 + let currentMeshes: number[] = [] + let currentMeshInstances: number[][] = [] + + for (let i = 0; i < this._meshes.length; i++) { const meshSize = this.getMeshIndexCount(i, 'all') - const instances = this.getMeshInstances(i) + const instances = this._meshInstances[i] currentSize += meshSize - currentInstances.push(...instances) - // 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] } - /** - * 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 @@ -152,8 +181,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 } @@ -184,59 +212,57 @@ export class G3dSubset { } /** - * Returns a new subset that only contains unique meshes. + * 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) */ - filterUniqueMeshes () { - 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]) + filterByCount (predicate: (i: number) => boolean) { + 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 + } } - // 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) + return G3dSubset._fromPrebuilt(this._source, instanceCount, meshes, meshInstances) } /** - * Returns a new subset that only contains non-unique meshes. + * Splits subset into two based on instance count threshold. + * Builds mesh arrays directly in a single pass — no Set construction or re-filtering. + * @param threshold Instance count threshold + * @returns [low (<=threshold), high (>threshold)] */ - filterNonUniqueMeshes () { - return this.filterByCount((count) => count > 1) - } + splitByCount (threshold: number): [G3dSubset, G3dSubset] { + const lowMeshes: number[] = [] + const lowMeshInstances: number[][] = [] + let lowCount = 0 + const highMeshes: number[] = [] + const highMeshInstances: number[][] = [] + let highCount = 0 - filterByCount (predicate: (i: number) => boolean) { - const set = new Set() - this._meshInstances.forEach((instances, i) => { - if (predicate(instances.length)) { - set.add(this._meshes[i]) + for (let i = 0; i < this._meshes.length; i++) { + const instances = this._meshInstances[i] + if (instances.length <= threshold) { + lowMeshes.push(this._meshes[i]) + lowMeshInstances.push(instances) + lowCount += instances.length + } else { + highMeshes.push(this._meshes[i]) + highMeshInstances.push(instances) + highCount += instances.length } - }) - const instances = this._instances.filter((instance) => - set.has(this._source.instanceMeshes[instance]) - ) + } - return new G3dSubset(this._source, instances) + return [ + G3dSubset._fromPrebuilt(this._source, lowCount, lowMeshes, lowMeshInstances), + G3dSubset._fromPrebuilt(this._source, highCount, highMeshes, highMeshInstances) + ] } /** @@ -246,36 +272,15 @@ 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.getMeshCount() - for (let i = 0; i < count; i++) { - result.instances += this.getMeshInstanceCount(i) - result.indices += this.getMeshIndexCount(i, section) - result.vertices += this.getMeshVertexCount(i, section) - } - result.meshes = count - - 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. + * 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 { + except (mode: SubsetFilter, filter: number[] | Set): G3dSubset { return this._filter(mode, filter, false) } @@ -284,88 +289,60 @@ export class G3dSubset { * @param mode Defines which field the filter will be applied to. * @param filter Array of all values to match for. */ - filter (mode: FilterMode, filter: number[] | Set): G3dSubset { + filter (mode: SubsetFilter, filter: number[] | Set): G3dSubset { return this._filter(mode, filter, true) } private _filter ( - mode: FilterMode, + mode: SubsetFilter, filter: number[] | Set, has: boolean ): G3dSubset { - if (filter === undefined || mode === undefined) { - return new G3dSubset(this._source, undefined) + // 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 === '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) - } + // Filter per-mesh directly — no flat instance list needed + const set = filter instanceof Set ? filter : new Set(filter) + const array = mode === 'instance' + ? this._source.instanceNodes + : this._source.instanceMeshes - if (mode === 'tag' || mode === 'group') { - throw new Error('Filter Mode Not implemented') - } - } + const meshes: number[] = [] + const meshInstances: number[][] = [] + let instanceCount = 0 - private filterOnArray ( - filter: number[] | Set, - array: Int32Array, - has: boolean = true - ) { - 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) + 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) } - /** - * 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)) + /** 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) + } } - return box + this._flatInstances = result } + return this._flatInstances } -} - -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 6e8cdbde6..90a4ca9e6 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableGeometry.ts @@ -2,12 +2,31 @@ * @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) + * - colorIndex: 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 + * 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, G3dMesh, G3dMaterial } from 'vim-format' +import { G3d, G3dMaterial } from 'vim-format' import { Scene } from '../scene' import { G3dMeshOffsets } from './g3dOffsets' +import { ElementMapping } from '../elementMapping' +import { packPickingId } from '../../viewer/rendering/gpuPicker' +import { MappedG3d } from './mappedG3d' -// TODO Merge both submeshes class. +/** @internal */ export class GeometrySubmesh { instance: number start: number @@ -21,6 +40,7 @@ export class GeometrySubmesh { } } +/** @internal */ export class InsertableGeometry { _scene: Scene materials: G3dMaterial @@ -32,7 +52,10 @@ export class InsertableGeometry { private _computeBoundingBox = false private _indexAttribute: THREE.Uint32BufferAttribute private _vertexAttribute: THREE.BufferAttribute - private _colorAttribute: THREE.BufferAttribute + private _colorIndexAttribute: THREE.Uint16BufferAttribute + private _packedIdAttribute: THREE.Uint32BufferAttribute + private _mapping: ElementMapping + private _vimIndex: number private _updateStartMesh = 0 private _updateEndMesh = 0 @@ -41,10 +64,14 @@ export class InsertableGeometry { constructor ( offsets: G3dMeshOffsets, materials: G3dMaterial, - transparent: boolean + transparent: boolean, + 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, @@ -56,156 +83,52 @@ export class InsertableGeometry { G3d.POSITION_SIZE ) - const colorSize = transparent ? 4 : 3 - this._colorAttribute = new THREE.Float32BufferAttribute( - offsets.counts.vertices * colorSize, - colorSize + // Per-vertex color palette index (uint16 → 128×128 texture lookup) + this._colorIndexAttribute = new THREE.Uint16BufferAttribute( + offsets.counts.vertices, + 1 ) - // this._indexAttribute.count = 0 - // this._vertexAttribute.count = 0 - // this._colorAttribute.count = 0 + // Packed ID attribute for GPU picking: (vimIndex << 24) | elementIndex + this._packedIdAttribute = new THREE.Uint32BufferAttribute( + offsets.counts.vertices, + 1 + ) this.geometry = new THREE.BufferGeometry() this.geometry.setIndex(this._indexAttribute) this.geometry.setAttribute('position', this._vertexAttribute) - this.geometry.setAttribute('color', this._colorAttribute) - - 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 - } - } - - get progress () { - return this._indexAttribute.count / this._indexAttribute.array.length - } + this.geometry.setAttribute('colorIndex', this._colorIndexAttribute) + this.geometry.setAttribute('packedId', this._packedIdAttribute) - // 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) + // Initialize with inverted bounds (min = +∞, max = -∞) so any point naturally expands it + this.boundingBox = new THREE.Box3() + this.boundingBox.makeEmpty() - 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 + this._computeBoundingBox = true } - 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 + get progress () { + return this._indexAttribute.count / this._indexAttribute.array.length } - insertFromG3d (g3d: G3d, mesh: number) { - const added: number[] = [] - const meshG3dIndex = this.offsets.getSourceMesh(mesh) + /** + * 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: MappedG3d, mesh: number) { + const meshG3dIndex = this.offsets.subset.getSourceMesh(mesh) const subStart = g3d.getMeshSubmeshStart(meshG3dIndex, this.offsets.section) const subEnd = g3d.getMeshSubmeshEnd(meshG3dIndex, this.offsets.section) // Skip empty mesh if (subStart === subEnd) { this._meshToUpdate.add(mesh) - return added + return } - // Reusable matrix and vector3 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) const vertexOffset = this.offsets.getVertexOffset(mesh) @@ -215,72 +138,113 @@ export class InsertableGeometry { const vertexEnd = g3d.getMeshVertexEnd(meshG3dIndex) const vertexCount = vertexEnd - vertexStart + // 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.getMeshInstanceCount(mesh) + const instanceCount = this.offsets.subset.getMeshInstanceCount(mesh) for (let instance = 0; instance < instanceCount; instance++) { - const g3dInstance = this.offsets.getMeshInstance(mesh, instance) - matrix.fromArray(g3d.getInstanceMatrix(g3dInstance)) + const g3dInstance = this.offsets.subset.getMeshInstance(mesh, instance) + + // 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++) { + matrixElements[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 + // Direct array access for performance (avoid function call overhead) + const indices = this._indexAttribute.array as Uint32Array + const colorIndices = this._colorIndexAttribute.array as Uint16Array + 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) + const colorIndex = g3d.colorIndices[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) + + // Direct array writes (no function calls, no bounds checking) + indices[indexOffset + indexOut] = v + colorIndices[v] = colorIndex indexOut++ } } + // 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) + + // 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 + submesh.boundingBox.makeEmpty() + const boxMin = submesh.boundingBox.min + const boxMax = submesh.boundingBox.max + // 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) - submesh.expandBox(vector) + 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 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] + + // Direct array writes + const dstIdx = (vertexOffset + vertexOut) * 3 + positions[dstIdx] = tx + positions[dstIdx + 1] = ty + positions[dstIdx + 2] = tz + + packedIds[vertexOffset + vertexOut] = packedId + + // 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++ } 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 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 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 expandBox (box: THREE.Box3) { - if (!box) return - this.boundingBox = this.boundingBox?.union(box) ?? box.clone() + // 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 () { @@ -288,6 +252,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)) { @@ -316,15 +285,22 @@ 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 + // update color palette indices (itemSize is 1) + this._colorIndexAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) + this._colorIndexAttribute.needsUpdate = true + + // update packed IDs (itemSize is 1) + this._packedIdAttribute.addUpdateRange(vertexStart, vertexEnd - vertexStart) + this._packedIdAttribute.needsUpdate = true if (this._computeBoundingBox) { - this.geometry.computeBoundingBox() - this.geometry.computeBoundingSphere() + // 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 d2bdd5c40..99a1f1b89 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,17 @@ */ import * as THREE from 'three' -import { G3d, G3dMesh, 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 } from '../materials/materials' +import { Scene } from '../scene' +import { MaterialSet, Materials, applyMaterial } from '../materials/materials' +import { ElementMapping } from '../elementMapping' +import { MappedG3d } from './mappedG3d' +/** @internal */ export class InsertableMesh { offsets: G3dMeshOffsets mesh: THREE.Mesh @@ -34,34 +38,31 @@ export class InsertableMesh { return this.geometry.boundingBox } - /** - * Set to true to ignore SetMaterial calls. - */ - ignoreSceneMaterial: boolean - /** * initial material. */ - private _material: ModelMaterial + private _material: THREE.Material geometry: InsertableGeometry constructor ( offsets: G3dMeshOffsets, materials: G3dMaterial, - transparent: boolean + transparent: boolean, + mapping: ElementMapping, + vimIndex: number = 0 ) { this.offsets = offsets this.transparent = transparent - this.geometry = new InsertableGeometry(offsets, materials, transparent) + this.geometry = new InsertableGeometry(offsets, materials, transparent, mapping, vimIndex) - this._material = transparent - ? Materials.getInstance().transparent.material - : Materials.getInstance().opaque.material + const m = Materials.getInstance() + this._material = transparent ? m.modelTransparentMaterial : m.modelOpaqueMaterial this.mesh = new THREE.Mesh(this.geometry.geometry, this._material) this.mesh.userData.vim = this + this.mesh.userData.transparent = transparent // this.mesh.frustumCulled = false } @@ -69,23 +70,15 @@ 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) { + insertFromVim (g3d: MappedG3d, mesh: number) { this.geometry.insertFromG3d(g3d, mesh) } update () { this.geometry.update() + if (this.vim) { + (this.vim.scene as Scene).updateBox(this.geometry.boundingBox) + } } clearUpdate () { @@ -106,7 +99,6 @@ export class InsertableMesh { } /** - * * @returns Returns all submeshes */ getSubmeshes () { @@ -119,6 +111,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. @@ -129,49 +127,8 @@ 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: MaterialSet) { + applyMaterial(this.mesh, value) + } + } 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..ead708520 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableMeshFactory.ts @@ -0,0 +1,69 @@ +/** + * @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 { G3dMaterial } from 'vim-format' +import { InsertableMesh } from './insertableMesh' +import { G3dMeshOffsets } from './g3dOffsets' +import { G3dSubset } from './g3dSubset' +import { ElementMapping } from '../elementMapping' +import { MappedG3d } from './mappedG3d' + +/** @internal */ +export class InsertableMeshFactory { + private _materials: G3dMaterial + private _mapping: ElementMapping + private _vimIndex: number + + constructor (materials: G3dMaterial, mapping: ElementMapping, vimIndex: number = 0) { + this._materials = materials + this._mapping = mapping + this._vimIndex = vimIndex + } + + createOpaqueFromVim (g3d: MappedG3d, subset: G3dSubset) { + const offsets = subset.getOffsets('opaque') + if (!offsets.any()) return undefined + return this.createFromVim(g3d, offsets, false) + } + + createTransparentFromVim (g3d: MappedG3d, subset: G3dSubset) { + const offsets = subset.getOffsets('transparent') + if (!offsets.any()) return undefined + return this.createFromVim(g3d, offsets, 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: MappedG3d, + offsets: G3dMeshOffsets, + transparent: boolean + ) { + const mesh = new InsertableMesh(offsets, this._materials, transparent, this._mapping, this._vimIndex) + + const count = offsets.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/insertableSubmesh.ts b/src/vim-web/core-viewers/webgl/loader/progressive/insertableSubmesh.ts index e36e17a75..6e14819c0 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/insertableSubmesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/insertableSubmesh.ts @@ -5,10 +5,10 @@ import { Submesh } from '../mesh' import { InsertableMesh } from './insertableMesh' +/** @internal */ export class InsertableSubmesh { mesh: InsertableMesh index: number - private _colors: Float32Array constructor (mesh: InsertableMesh, index: number) { this.mesh = mesh @@ -72,14 +72,4 @@ export class InsertableSubmesh { return this.mesh.vim.getElement(this.instance) } - saveColors (colors: Float32Array) { - if (this._colors) return - this._colors = colors - } - - popColors () { - const result = this._colors - this._colors = undefined - return result - } } 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..6bbe38c30 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMesh.ts @@ -5,46 +5,39 @@ import * as THREE from 'three' import { Vim } from '../vim' import { InstancedSubmesh } from './instancedSubmesh' -import { G3d, G3dMesh } from 'vim-format' -import { ModelMaterial } from '../materials/materials' +import { MaterialSet, applyMaterial } from '../materials/materials' +/** @internal */ export class InstancedMesh { - g3dMesh: G3dMesh | G3d vim: Vim mesh: THREE.InstancedMesh - - // instances - bimInstances: ArrayLike - meshInstances: ArrayLike + instances: ArrayLike boundingBox: THREE.Box3 - boxes: THREE.Box3[] + private _boxes?: THREE.Box3[] // State - ignoreSceneMaterial: boolean - - private _material: ModelMaterial + transparent: boolean + + private _material: THREE.Material | THREE.Material[] readonly size: number = 0 constructor ( - g3d: G3dMesh | G3d, mesh: THREE.InstancedMesh, - instances: Array + instances: Array, + transparent: boolean = false ) { - 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.meshInstances = instances - - this.boxes = - g3d instanceof G3dMesh - ? this.importBoundingBoxes() - : this.computeBoundingBoxes() - this.size = this.boxes[0]?.getSize(new THREE.Vector3()).length() ?? 0 - this.boundingBox = this.computeBoundingBox(this.boxes) + this.mesh.userData.transparent = transparent + this.instances = instances + this.transparent = transparent + + // 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 } @@ -52,6 +45,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. */ @@ -60,61 +64,25 @@ export class InstancedMesh { } /** - * Returns all submeshes for given index. + * Returns all submeshes. */ 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 } - /** - * 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); - }); - } + forEachSubmesh (callback: (submesh: InstancedSubmesh) => void) { + for (let i = 0; i < this.instances.length; i++) { + callback(new InstancedSubmesh(this, 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: MaterialSet) { + applyMaterial(this.mesh, value) + } private computeBoundingBoxes () { this.mesh.geometry.computeBoundingBox() @@ -129,24 +97,21 @@ 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 - } + /** + * Computes overall bounding box without allocating per-instance boxes. + */ + private computeBoundingBox (): THREE.Box3 { + const geoBBox = this.mesh.geometry.boundingBox + const matrix = new THREE.Matrix4() + const tempBox = new THREE.Box3() + const result = new THREE.Box3().makeEmpty() - computeBoundingBox (boxes: THREE.Box3[]) { - const box = boxes[0].clone() - for (let i = 1; i < boxes.length; i++) { - box.union(boxes[i]) + for (let i = 0; i < this.mesh.count; i++) { + this.mesh.getMatrixAt(i, matrix) + tempBox.copy(geoBBox).applyMatrix4(matrix) + result.union(tempBox) } - return box + + 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 5b4f1fa11..7ab5bd4e9 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedMeshFactory.ts @@ -2,165 +2,111 @@ * @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, G3dMesh, G3dMaterial, MeshSection, G3dScene } 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' +/** @internal */ export class InstancedMeshFactory { - materials: G3dMaterial + private _mapping: ElementMapping + private _vimIndex: number - constructor (materials: G3dMaterial) { - this.materials = materials + constructor (mapping: ElementMapping, vimIndex: number = 0) { + this._mapping = mapping + 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[]) { + createOpaqueFromVim (g3d: MappedG3d, 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[]) { - return this.createFromVim(g3d, mesh, instances, 'transparent', true) - } - - createFromVimx ( - mesh: G3dMesh, - instances: number[], - section: MeshSection, - transparent: boolean - ) { - if (mesh.getIndexCount(section) <= 1) { + createTransparentFromVim (g3d: MappedG3d, mesh: number, instances: number[]) { + // Skip if no transparent geometry + if (g3d.getMeshIndexEnd(mesh, 'transparent') <= g3d.getMeshIndexStart(mesh, 'transparent')) { 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 + 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, + g3d: MappedG3d, mesh: number, instances: number[] | undefined, section: MeshSection, transparent: boolean ) { - const geometry = Geometry.createGeometryFromMesh( - g3d, - mesh, - section, - transparent - ) - const material = transparent - ? Materials.getInstance().transparent - : Materials.getInstance().opaque + const geometry = Geometry.createGeometryFromMesh(g3d, mesh, section) + + const m = Materials.getInstance() + const material = transparent ? m.modelTransparentMaterial : m.modelOpaqueMaterial const threeMesh = new THREE.InstancedMesh( geometry, - material.material, + material, instances?.length ?? g3d.getMeshInstanceCount(mesh) ) - this.setMatricesFromVimx(threeMesh, g3d, instances) - 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) - } + const instanceArray = instances ?? g3d.meshInstances[mesh] + this.setMatrices(threeMesh, g3d, instanceArray) + this.setPackedIds(threeMesh, instanceArray) - 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) + return new InstancedMesh(threeMesh, instanceArray, transparent) } - private computeColors ( - mesh: G3dMesh, - section: MeshSection, - colorSize: number + private setMatrices ( + three: THREE.InstancedMesh, + source: MappedG3d, + instances: 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 + const dst = three.instanceMatrix.array as Float32Array + const src = source.instanceTransforms + for (let i = 0; i < instances.length; i++) { + const srcOffset = instances[i] * 16 + const dstOffset = i * 16 + for (let j = 0; j < 16; j++) { + dst[dstOffset + j] = src[srcOffset + j] } } - return new THREE.Float32BufferAttribute(colors, colorSize) } - private setMatricesFromVimx ( + /** + * 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, - source: G3dScene | G3d, instances: number[] ) { - const matrix = new THREE.Matrix4() + const packedIds = new Uint32Array(instances.length) for (let i = 0; i < instances.length; i++) { - const array = source.getInstanceMatrix(instances[i]) - matrix.fromArray(array) - three.setMatrixAt(i, matrix) + const elementIndex = this._mapping.getElementFromInstance(instances[i]) ?? -1 + packedIds[i] = packPickingId(this._vimIndex, elementIndex) } + three.geometry.setAttribute( + 'packedId', + new THREE.InstancedBufferAttribute(packedIds, 1) + ) } } 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..f8c3ef848 100644 --- a/src/vim-web/core-viewers/webgl/loader/progressive/instancedSubmesh.ts +++ b/src/vim-web/core-viewers/webgl/loader/progressive/instancedSubmesh.ts @@ -5,6 +5,7 @@ import { Submesh } from '../mesh' import { InstancedMesh } from './instancedMesh' +/** @internal */ export class InstancedSubmesh { mesh: InstancedMesh index: number @@ -36,7 +37,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/legacyMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts deleted file mode 100644 index d937397f7..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/legacyMeshFactory.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @module vim-loader - */ - -import { InsertableMesh } from './insertableMesh' -import { Scene } from '../scene' -import { G3dMaterial, G3d, MeshSection } from 'vim-format' -import { InstancedMeshFactory } from './instancedMeshFactory' -import { G3dSubset } from './g3dSubset' - -/** - * Mesh factory to load a standard vim using the progressive pipeline. - */ -export class VimMeshFactory { - readonly g3d: G3d - private _materials: G3dMaterial - private _instancedFactory: InstancedMeshFactory - private _scene: Scene - - constructor (g3d: G3d, materials: G3dMaterial, scene: Scene) { - this.g3d = g3d - this._materials = materials - this._scene = scene - this._instancedFactory = new InstancedMeshFactory(materials) - } - - /** - * Adds all instances from subset to the scene - */ - public add (subset: G3dSubset) { - const uniques = subset.filterByCount((count) => count <= 5) - const nonUniques = subset.filterByCount((count) => count > 5) - - // Create and add meshes to scene - this.addInstancedMeshes(this._scene, nonUniques) - const chunks = uniques.chunks(4_000_000) - for(const chunk of chunks) { - this.addMergedMesh(this._scene, chunk) - } - } - - 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) - - const count = subset.getMeshCount() - for (let m = 0; m < count; m++) { - opaque.insertFromVim(this.g3d, m) - } - - opaque.update() - return opaque - } - - private addInstancedMeshes (scene: Scene, subset: G3dSubset) { - 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 opaque = this._instancedFactory.createOpaqueFromVim( - this.g3d, - mesh, - instances - ) - const transparent = this._instancedFactory.createTransparentFromVim( - this.g3d, - mesh, - instances - ) - scene.addMesh(opaque) - scene.addMesh(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 new file mode 100644 index 000000000..1f153513a --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/progressive/loadRequest.ts @@ -0,0 +1,121 @@ +/** + * 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.load() or vim.load(subset) + * separately to build Three.js meshes. + */ + +import { createVimSettings, VimPartialSettings } from '../vimSettings' +import { Vim, IWebglVim } from '../vim' +import { Scene } from '../scene' +import { ElementMapping } from '../elementMapping' +import { VimMeshFactory } from './vimMeshFactory' +import { LoadRequest as BaseLoadRequest, ILoadRequest as BaseILoadRequest, LoadError, LoadSuccess } from '../../../shared/loadResult' +import { + BFast, + BFastSource, + RemoteBuffer, + requestHeader, + VimDocument, + G3d, + G3dMaterial +} from 'vim-format' +import { DefaultLog } from 'vim-format/dist/logging' +import { createMappedG3d } from './mappedG3d' +import { Materials } from '../materials/materials' + +export type RequestSource = { + url?: string, + buffer?: ArrayBuffer, + headers?: Record, +} + +export type IWebglLoadRequest = BaseILoadRequest + +/** + * @internal + * 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 { + private _bfast: BFast + + constructor (source: BFastSource, settings: VimPartialSettings, vimIndex: number) { + super() + this._bfast = new BFast(source) + this.startRequest(settings, vimIndex) + } + + private async startRequest (settings: VimPartialSettings, vimIndex: number) { + try { + const vim = await this.loadFromVim(this._bfast, settings, vimIndex) + this.complete(new LoadSuccess(vim)) + } catch (err: any) { + const message = err.message ?? JSON.stringify(err) + console.error('Error loading VIM:', err) + this.complete(new LoadError(message)) + } + } + + /** + * Parses a VIM file into a Vim object. Steps: + * 1. Parse G3d geometry from the BFast 'geometry' buffer + * 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 vim.load() to build meshes + */ + private async loadFromVim ( + bfast: BFast, + settings: VimPartialSettings, + vimIndex: number + ): Promise { + const fullSettings = createVimSettings(settings) + + if (bfast.source instanceof RemoteBuffer) { + bfast.source.onProgress = (p) => this.pushProgress({ type: 'bytes', current: p.loaded, total: p.total }) + if (fullSettings.verboseHttp) { + bfast.source.logs = new DefaultLog() + } + } + + const geometry = await bfast.getBfast('geometry') + const g3d = await G3d.createFromBfast(geometry) + const mappedG3d = createMappedG3d(g3d) + const materials = new G3dMaterial(mappedG3d.materialColors) + + const doc = await VimDocument.createFromBfast(bfast) + const mapping = await ElementMapping.fromG3d(doc) + + const scene = new Scene(fullSettings.matrix) + const factory = new VimMeshFactory(mappedG3d, materials, scene, mapping, vimIndex) + Materials.getInstance().ensureColorPalette() + + const header = await requestHeader(bfast) + + // Step 6: Create Vim — geometry will be built later via vim.load() + const vim = new Vim( + header, + doc, + mappedG3d, + scene, + fullSettings, + vimIndex, + mapping, + factory, + bfast.url + ) + + if (bfast.source instanceof RemoteBuffer) { + bfast.source.onProgress = undefined + } + + return vim + } + + abort (): void { + this._bfast.abort() + super.abort() + } +} 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/mappedG3d.ts b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts new file mode 100644 index 000000000..c72e007b9 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/progressive/mappedG3d.ts @@ -0,0 +1,68 @@ +/** + * @module vim-loader + */ + +import { G3d } from 'vim-format' +import { buildColorIndices } from '../materials/colorPalette' + +/** + * @internal + * G3d augmented with a pre-computed mesh→instances map and color palette indices. + * The map is computed once during loading and shared by all G3dSubsets, + * eliminating O(N) iterations on every subset construction. + * + * Color indices: Each submesh maps to a palette index via colorToIndex(), + * enabling texture-based color lookup instead of per-vertex color attributes. + */ +export interface MappedG3d extends G3d { + /** 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 + + /** Per-submesh palette color index */ + colorIndices: Uint16Array +} + +/** + * Augments a G3d instance with pre-computed mesh→instances map and color indices. + * 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 with color optimization + */ +export function createMappedG3d(g3d: G3d): MappedG3d { + const mapped = g3d as MappedG3d + + // 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] + if (mesh >= 0) { + if (!map.has(mesh)) { + map.set(mesh, []) + } + map.get(mesh)!.push(i) + } + } + + 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 per-submesh color palette indices + mapped.colorIndices = buildColorIndices(mapped, mapped.submeshMaterial.length) + + return mapped +} 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 7596ad2e3..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/open.ts +++ /dev/null @@ -1,171 +0,0 @@ -// loader -import { - createVimSettings, - VimPartialSettings, - VimSettings -} 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 {(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, - 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, onProgress) - } - - if (type === 'vimx') { - return loadFromVimX(bfast, fullSettings, onProgress) - } - - throw new Error('Cannot determine the appropriate loading strategy.') -} - -async function determineFileType ( - bfast: BFast, - settings: VimSettings -) { - 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: VimSettings, - 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) - - const vim = new Vim( - vimx.header, - undefined, - undefined, - scene, - settings, - 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: VimSettings, - 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 scene - const scene = new Scene(settings.matrix) - const factory = new VimMeshFactory(g3d, materials, scene) - - // Create legacy mapping - const doc = await VimDocument.createFromBfast(bfast) - const mapping = await ElementMapping.fromG3d(g3d, doc) - const header = await requestHeader(bfast) - - // Return legacy vim - const builder = new VimSubsetBuilder(factory) - const vim = new Vim( - header, - doc, - g3d, - scene, - fullSettings, - 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/subsetBuilder.ts b/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts deleted file mode 100644 index eb64382e0..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/subsetBuilder.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * @module vim-loader - */ - -import { VimMeshFactory } from './legacyMeshFactory' -import { LoadPartialSettings, LoadSettings, SubsetRequest } from './subsetRequest' -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' - -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 with given options */ - loadSubset(subset: G3dSubset, settings?: LoadPartialSettings) - - /** Stops and clears all loading processes */ - clear() - - dispose() -} - -/** - * Loads and builds subsets from a Vim file. - */ -export class VimSubsetBuilder implements SubsetBuilder { - factory: VimMeshFactory - - private _onUpdate = new SignalDispatcher() - - get onUpdate () { - return this._onUpdate.asEvent() - } - - get isLoading () { - return false - } - - constructor (factory: VimMeshFactory) { - this.factory = factory - } - - getFullSet () { - return new G3dSubset(this.factory.g3d) - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - loadSubset (subset: G3dSubset, settings?: LoadPartialSettings) { - this.factory.add(subset) - this._onUpdate.dispatch() - } - - clear () { - this._onUpdate.dispatch() - } - - dispose () {} -} - -/** - * Loads and builds subsets from a VimX file. - */ -export class VimxSubsetBuilder { - private _localVimx: Vimx - private _scene: Scene - 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) { - this._localVimx = localVimx - this._scene = scene - } - - getFullSet () { - return new G3dSubset(this._localVimx.scene) - } - - async loadSubset (subset: G3dSubset, settings?: LoadPartialSettings) { - const request = new SubsetRequest(this._scene, this._localVimx, subset) - 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 c3f54bc04..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/subsetRequest.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * @module vim-loader - */ - -import { InsertableMesh } from './insertableMesh' -import { InstancedMeshFactory } from './instancedMeshFactory' -import { Vimx, Scene } from '../..' - -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) { - 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 - ) - this._opaqueMesh.mesh.name = 'Opaque_Merged_Mesh' - - const transparentOffsets = this._uniques.getOffsets('transparent') - this._transparentMesh = new InsertableMesh( - transparentOffsets, - localVimx.materials, - true - ) - this._transparentMesh.mesh.name = 'Transparent_Merged_Mesh' - - this._scene.addMesh(this._transparentMesh) - this._scene.addMesh(this._opaqueMesh) - - this._meshFactory = new InstancedMeshFactory(localVimx.materials) - - 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/vimMeshFactory.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts new file mode 100644 index 000000000..6808d6b65 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/loader/progressive/vimMeshFactory.ts @@ -0,0 +1,75 @@ +/** + * @module vim-loader + */ + +/** + * 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 chunked at 16M indices (GPU picking allows larger chunks without raycast penalty). + */ + +import { Scene } from '../scene' +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' + +/** @internal */ +export class VimMeshFactory { + readonly g3d: MappedG3d + private _insertableFactory: InsertableMeshFactory + private _instancedFactory: InstancedMeshFactory + private _scene: Scene + + 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) + this._instancedFactory = new InstancedMeshFactory(mapping, vimIndex) + } + + /** + * 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 [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) { + this.addMergedMesh(this._scene, chunk) + } + } + + private addMergedMesh (scene: Scene, subset: G3dSubset) { + 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) { + 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 opaque = this._instancedFactory.createOpaqueFromVim(this.g3d, mesh, instances) + if (opaque) scene.addMesh(opaque) + + const transparent = this._instancedFactory.createTransparentFromVim(this.g3d, mesh, instances) + if (transparent) scene.addMesh(transparent) + } + } +} diff --git a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts b/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts deleted file mode 100644 index 59cce8b1c..000000000 --- a/src/vim-web/core-viewers/webgl/loader/progressive/vimRequest.ts +++ /dev/null @@ -1,104 +0,0 @@ -// loader -import { - VimPartialSettings -} from '../vimSettings' - -import { Vim } from '../vim' -import { Result, ErrorResult, SuccessResult } from '../../../../utils/result' -import { open } from './open' - -import { VimSource } from '../..' -import { - BFast, IProgressLogs -} from 'vim-format' -import { ControllablePromise } from '../../../../utils/promise' - -export type RequestSource = { - url?: string, - buffer?: ArrayBuffer, - headers?: Record, -} - -/** - * 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. - * @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) -} - -/** - * 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 _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() - - constructor (source: VimSource, settings: VimPartialSettings) { - this._source = source - this._settings = settings - - this.startRequest() - } - - /** - * Initiates the asynchronous request and handles progress updates. - */ - private async startRequest () { - try { - this._bfast = new BFast(this._source) - - const vim: Vim = await open(this._bfast, this._settings, (progress: IProgressLogs) => { - this._progress = progress - this._progressPromise.resolve(progress) - this._progressPromise = new ControllablePromise() - }) - this._vimResult = vim - } catch (err: any) { - this._error = err.message ?? JSON.stringify(err) - console.error('Error loading VIM:', err) - } finally { - this.end() - } - } - - 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) - } - - /** - * Async generator that yields progress updates. - * @returns An AsyncGenerator yielding IProgressLogs. - */ - async * getProgress (): AsyncGenerator { - while (!this._isDone) { - yield await this._progressPromise.promise - } - } - - abort () { - this._bfast.abort() - this._error = 'Request aborted' - this.end() - } -} 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/scene.ts b/src/vim-web/core-viewers/webgl/loader/scene.ts index 67d496516..e7ed8e237 100644 --- a/src/vim-web/core-viewers/webgl/loader/scene.ts +++ b/src/vim-web/core-viewers/webgl/loader/scene.ts @@ -3,19 +3,19 @@ */ 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' import { InstancedMesh } from './progressive/instancedMesh' import { getAverageBoundingBox } from './averageBoundingBox' -import { ModelMaterial } from './materials/materials' +import { MaterialSet } from './materials/materials' import { Renderer } from '../viewer/rendering/renderer' /** - * Interface for a renderer object, providing methods to add and remove objects from a scene, update bounding boxes, and notify scene updates. + * @internal + * Internal renderer callback interface used by Scene to notify the renderer of changes. */ -export interface IRenderer { +export interface ISceneRenderer { // eslint-disable-next-line no-use-before-define add(scene: Scene | THREE.Object3D) // eslint-disable-next-line no-use-before-define @@ -24,30 +24,45 @@ export interface IRenderer { notifySceneUpdate() } +/** Public-facing interface for vim.scene. Represents loaded geometry in the renderer. */ +export interface IScene { + /** The world transform matrix applied to all meshes in this scene. */ + readonly matrix: THREE.Matrix4 + /** Bounding box of currently loaded geometry in Z-up world space (X = right, Y = forward, Z = up). Undefined if nothing loaded yet. */ + getBoundingBox(target?: THREE.Box3): THREE.Box3 | undefined + /** Bounding box using average mesh centers, in Z-up world space. More stable against outliers. */ + getAverageBoundingBox(): THREE.Box3 + /** Material override for all meshes in this scene. */ + material: MaterialSet +} + /** + * @internal * Represents a scene that contains multiple meshes. * It tracks the global bounding box as meshes are added and maintains a mapping between g3d instance indices and meshes. */ -// TODO: Only expose what should be public to vim.scene -export class Scene { +export class Scene implements IScene { // Dependencies private _renderer: Renderer private _vim: Vim | undefined private _matrix = new THREE.Matrix4() + get matrix (): THREE.Matrix4 { return this._matrix } // State insertables: InsertableMesh[] = [] - meshes: (Mesh | InsertableMesh | InstancedMesh)[] = [] + meshes: (InsertableMesh | InstancedMesh)[] = [] private _boundingBox: THREE.Box3 private _averageBoundingBox: THREE.Box3 | undefined - private _instanceToMeshes: Map = new Map() - private _material: ModelMaterial + // Array-based lookup for O(1) access (instance indices are dense 0..N) + private _instanceToMeshes: Array = [] + private _material: MaterialSet constructor (matrix: THREE.Matrix4) { this._matrix = matrix + // Material will be set when Scene is added to renderer via renderScene.add() } setDirty () { @@ -95,19 +110,13 @@ export class Scene { } } - getMemory () { - return this.meshes - .map((m) => estimateBytesUsed(m.mesh.geometry)) - .reduce((n1, n2) => n1 + n2, 0) - } - /** * Returns the THREE.Mesh in which this instance is represented along with index * For merged mesh, index refers to submesh index * 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 +127,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 @@ -144,24 +153,27 @@ export class Scene { this.meshes.forEach((m) => (m.vim = value)) } - addSubmesh (submesh: Submesh) { - const meshes = this._instanceToMeshes.get(submesh.instance) ?? [] - meshes.push(submesh) - this._instanceToMeshes.set(submesh.instance, meshes) - this.setDirty() - if (this.vim) { - const obj = this.vim.getElement(submesh.instance) - obj._addMesh(submesh) + /** + * Registers a submesh in the instance → submesh map. + */ + private registerSubmesh (submesh: Submesh) { + let meshes = this._instanceToMeshes[submesh.instance] + if (!meshes) { + meshes = [] + this._instanceToMeshes[submesh.instance] = meshes } + meshes.push(submesh) } /** - * 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) + * 5. Apply current material override if any */ - addMesh (mesh: Mesh | InsertableMesh | InstancedMesh) { + addMesh (mesh: InsertableMesh | InstancedMesh) { this.renderer?.add(mesh.mesh) mesh.vim = this.vim @@ -169,7 +181,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) @@ -183,11 +195,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 = @@ -210,8 +230,8 @@ export class Scene { /** * Sets and apply a material override to the scene, set to undefined to remove override. */ - set material (value: ModelMaterial) { - if (this._material === value) return + set material (value: MaterialSet) { + // Always update - don't check equality to ensure materials propagate this.setDirty() this._material = value this.meshes.forEach((m) => m.setMaterial(value)) @@ -227,7 +247,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 diff --git a/src/vim-web/core-viewers/webgl/loader/vim.ts b/src/vim-web/core-viewers/webgl/loader/vim.ts index 7b40ab3df..d311c2121 100644 --- a/src/vim-web/core-viewers/webgl/loader/vim.ts +++ b/src/vim-web/core-viewers/webgl/loader/vim.ts @@ -3,38 +3,91 @@ */ import * as THREE from 'three' -import { VimDocument, G3d, VimHeader, FilterMode } from 'vim-format' -import { Scene } from './scene' +import { VimDocument, VimHeader } from 'vim-format' +import { Scene, IScene } from './scene' import { VimSettings } from './vimSettings' -import { Element3D } from './element3d' +import { Element3D, type IElement3D } from './element3d' import { + IElementMapping, 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 { G3dSubset, ISubset } from './progressive/g3dSubset' +import { VimMeshFactory } from './progressive/vimMeshFactory' import { IVim } from '../../shared/vim' - -type VimFormat = 'vim' | 'vimx' +import { MappedG3d } from './progressive/mappedG3d' /** - * 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. + * Public API for a loaded VIM model, accessed via `viewer.vims`. + * + * Provides element queries, BIM data access, scene/material control, + * and progressive geometry loading. + * + * **Cleanup:** Do not call dispose directly — use `viewer.unload(vim)` to remove + * a vim from the viewer. Use `vim.clear()` only to remove loaded geometry + * while keeping the vim (e.g., before reloading a different subset). + * + * @example + * ```ts + * const vim = await viewer.load({ url }).getVim() + * + * // Query elements + * const element = vim.getElementFromIndex(301) + * const all = vim.getAllElements() + * + * // Modify visibility + * element.visible = false + * element.color = new THREE.Color(0xff0000) + * + * // BIM data (types from vim-format, accessible via VIM.BIM) + * const bimElement = await element.getBimElement() // VIM.BIM.IElement + * const params = await element.getBimParameters() // VIM.BIM.VimHelpers.ElementParameter[] + * + * // Progressive loading + * const sub = vim.subset().filter('instance', [0, 1, 2]) + * await vim.load(sub) + * + * // Cleanup + * viewer.unload(vim) // Remove from viewer (do NOT call vim.dispose()) + * ``` */ -export class Vim implements IVim { +export interface IWebglVim extends IVim { + readonly type: 'webgl' + /** The URL this vim was loaded from, if applicable. */ + readonly source: string | undefined + /** The VIM file header (from vim-format, accessible via `VIM.BIM.VimHeader`). */ + readonly header: VimHeader | undefined + /** BIM document for querying element properties, categories, levels, etc. (from vim-format, accessible via `VIM.BIM.VimDocument`). */ + readonly bim: VimDocument | undefined + /** The scene containing this vim's geometry. */ + readonly scene: IScene + /** The bounding box of all loaded geometry in Z-up world space (X = right, Y = forward, Z = up), or undefined if nothing loaded. */ + getBoundingBox(): Promise + /** Returns a subset representing all instances, for use with {@link load} and filtering. */ + subset(): ISubset + /** + * Loads geometry for the given subset, or all geometry if no subset is provided. + * Caller is responsible for not loading the same subset twice. + * @param subset - The subset to load. Omit to load everything. + */ + load(subset?: ISubset): Promise + /** Removes all loaded geometry from the renderer (does NOT unload the vim from the viewer). */ + clear(): void +} + +/** @internal */ +export class Vim implements IWebglVim { /** * 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'; /** - * Indicates whether the vim was opened from a vim or vimx file. + * 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 format: VimFormat + readonly vimIndex: number /** * Indicates the url this vim came from if applicable. @@ -51,105 +104,54 @@ export class Vim implements IVim { */ readonly bim: VimDocument | undefined - /** - * The raw g3d geometry scene definition. - */ - readonly g3d: G3d | undefined + private readonly _g3d: MappedG3d | undefined /** * The settings used when this vim was opened. */ readonly settings: VimSettings - /** - * Mostly Internal - The scene in which the vim geometry is added. - */ - readonly scene: Scene + private readonly _scene: Scene /** - * The mapping from Bim to Geometry for this vim. + * The scene in which the vim geometry is added. */ - readonly map: ElementMapping | ElementNoMapping | ElementMapping2 + get scene (): IScene { return this._scene } - private readonly _builder: SubsetBuilder - private readonly _loadedInstances = new Set() - private readonly _elementToObject = new Map() + /** @internal */ + readonly map: IElementMapping - /** - * 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 - } - - /** - * Getter for accessing the signal dispatched when the object is disposed. - * @returns {ISignal} The signal for disposal events. - */ - get onDispose () { - return this._onDispose as ISignal - } - - private _onDispose = new SignalDispatcher() + private readonly _factory: VimMeshFactory + private readonly _elementToObject = new Map() - /** - * 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 {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, document: VimDocument, - g3d: G3d | undefined, + g3d: MappedG3d | undefined, scene: Scene, settings: VimSettings, - map: ElementMapping | ElementNoMapping | ElementMapping2, - builder: SubsetBuilder, - source: string, - format: VimFormat) { + vimIndex: number, + map: ElementMapping | ElementNoMapping, + factory: VimMeshFactory, + source: string) { this.header = header this.bim = document - this.g3d = g3d + this._g3d = g3d scene.vim = this - this.scene = scene + this._scene = scene this.settings = settings + this.vimIndex = vimIndex this.map = map ?? new ElementNoMapping() - this._builder = builder + this._factory = factory this.source = source - this.format = format } - getBoundingBox(): Promise { - const box = this.scene.getBoundingBox() + getBoundingBox(): Promise { + const box = this._scene.getBoundingBox() return Promise.resolve(box) } - /** - * Retrieves the matrix representation of the Vim object's position, rotation, and scale. - * @returns {THREE.Matrix4} The matrix representing the Vim object's transformation. - */ - getMatrix () { - return this.settings.matrix - } - /** * Retrieves the object associated with the specified instance number. * @param {number} instance - The instance number of the object. @@ -166,7 +168,7 @@ export class Vim implements IVim { * @param {number} id - The element ID to retrieve objects for. * @returns {THREE.Object3D[]} An array of objects corresponding to the element ID, or an empty array if none are found. */ - getElementsFromId (id: number) { + getElementsFromId (id: number | bigint) { const elements = this.map.getElementsFromElementId(id) return elements ?.map((e) => this.getElementFromIndex(e)) @@ -186,9 +188,9 @@ export class Vim implements IVim { } const instances = this.map.getInstancesFromElement(element) - const meshes = this.scene.getMeshesFromInstances(instances) + const meshes = this._scene.getMeshesFromInstances(instances) - const result = new Element3D(this, element, instances, meshes) + const result = new Element3D(this, element, instances, meshes, this._g3d) this._elementToObject.set(element, result) return result } @@ -206,79 +208,23 @@ export class Vim implements IVim { return result } - /** - * Retrieves an array containing all objects within the specified subset. - * @param {G3dSubset} subset - The subset to retrieve objects from. - * @returns {WebglElement3D[]} An array of objects within the specified subset. - */ - getObjectsInSubset (subset: G3dSubset) { - const set = new Set() - const result: Element3D[] = [] - const count = subset.getInstanceCount() - for (let i = 0; i < count; i++) { - const instance = subset.getVimInstance(i) - const obj = this.getElement(instance) - if (!set.has(obj)) { - result.push(obj) - set.add(obj) - } - } - return result - } - /** * Retrieves all instances as a subset. - * @returns {G3dSubset} A subset containing all instances. + * @returns {ISubset} A subset containing all instances. */ - getFullSet (): G3dSubset { - return this._builder.getFullSet() + subset (): ISubset { + return new G3dSubset(this._factory.g3d) } /** - * Asynchronously loads all geometry according to the provided settings. - * @param {LoadPartialSettings} [settings] - Optional settings for the loading process. + * Loads geometry for the given subset, or all geometry if no subset is provided. + * Caller is responsible for not loading the same subset twice. + * @param subset - The subset to load. Omit to load everything. */ - async loadAll (settings?: LoadPartialSettings) { - return this.loadSubset(this.getFullSet(), settings) - } - - /** - * Asynchronously loads geometry for the specified subset according to the provided settings. - * @param {G3dSubset} subset - The subset to load resources for. - * @param {LoadPartialSettings} [settings] - Optional settings for the loading process. - */ - async loadSubset (subset: G3dSubset, settings?: LoadPartialSettings) { - 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) - } - - /** - * 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 - ) { - const subset = this.getFullSet().filter(filterMode, filter) - await this.loadSubset(subset, settings) + async load (subset?: ISubset) { + subset ??= this.subset() + if (subset.getInstanceCount() === 0) return + this._factory.add(subset as G3dSubset) } /** @@ -286,19 +232,11 @@ export class Vim implements IVim { */ clear () { this._elementToObject.clear() - this._loadedInstances.clear() - this.scene.clear() - // Clearing this one last because it dispatches the signal - this._builder.clear() + this._scene.clear() } - /** - * Cleans up and releases resources associated with the vim. - */ + /** @internal Called by Viewer.remove() — do not call directly. */ dispose () { - this._builder.dispose() - this._onDispose.dispatch() - this._onDispose.clear() - this.scene.dispose() + this._scene.dispose() } } diff --git a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts index cf7e0a472..ca4e3f784 100644 --- a/src/vim-web/core-viewers/webgl/loader/vimSettings.ts +++ b/src/vim-web/core-viewers/webgl/loader/vimSettings.ts @@ -3,23 +3,26 @@ */ import deepmerge from 'deepmerge' -import { Transparency } from './geometry' +import { TransparencyMode, isTransparencyModeValid } from './geometry' import * as THREE from 'three' -export type FileType = 'vim' | 'vimx' | undefined - /** - * Represents settings for configuring the behavior and rendering of a vim object. + * Per-model transform and rendering settings, passed to `viewer.load(source, settings)`. + * Controls how an individual VIM file is positioned, rotated, and scaled in the scene. + * Not to be confused with {@link ViewerSettings} (renderer config) or WebglSettings (UI toggles). + * + * @example + * viewer.load({ url }, { position: new THREE.Vector3(100, 0, 0), scale: 2 }) */ export type VimSettings = { /** - * The positional offset for the vim object. + * The positional offset for the vim object, in Z-up space (X = right, Y = forward, Z = up). */ position: THREE.Vector3 /** - * The XYZ rotation applied to the vim object. + * The XYZ rotation applied to the vim object, in degrees. */ rotation: THREE.Vector3 @@ -37,47 +40,26 @@ export type VimSettings = { /** * Determines whether objects are drawn based on their transparency. */ - transparency: Transparency.Mode + transparency: TransparencyMode /** * Set to true to enable verbose HTTP logging. */ verboseHttp: boolean - - // VIMX - - /** - * 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 } /** + * @internal * Default configuration settings for a vim object. */ export function getDefaultVimSettings(): VimSettings { -return { + return { position: new THREE.Vector3(), rotation: new THREE.Vector3(), scale: 1, matrix: undefined, transparency: 'all', - verboseHttp: false, - - // progressive - fileType: undefined, - progressive: false, - progressiveInterval: 1000 + verboseHttp: false } } @@ -87,16 +69,17 @@ return { export type VimPartialSettings = Partial /** + * @internal * 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. */ -export function createVimSettings (options?: VimPartialSettings) { - const merge = options +export function createVimSettings (options?: VimPartialSettings): VimSettings { + const merge = (options ? deepmerge(getDefaultVimSettings(), options, undefined) - : getDefaultVimSettings() + : getDefaultVimSettings()) as VimSettings - merge.transparency = Transparency.isValid(merge.transparency) + merge.transparency = isTransparencyModeValid(merge.transparency) ? merge.transparency : 'all' diff --git a/src/vim-web/core-viewers/webgl/loader/webglAttribute.ts b/src/vim-web/core-viewers/webgl/loader/webglAttribute.ts index ac11ddc3d..d07723848 100644 --- a/src/vim-web/core-viewers/webgl/loader/webglAttribute.ts +++ b/src/vim-web/core-viewers/webgl/loader/webglAttribute.ts @@ -5,8 +5,10 @@ import * as THREE from 'three' import { MergedSubmesh, SimpleInstanceSubmesh, Submesh } from './mesh' +/** @internal */ export type WebglAttributeTarget = Submesh | SimpleInstanceSubmesh +/** @internal */ export class WebglAttribute { readonly vertexAttribute: string readonly instanceAttribute: string 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..9d45881ad 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/camera.ts @@ -4,11 +4,12 @@ import * as THREE from 'three' -import { ISignal, SignalDispatcher } from 'ste-signals' +import type { ISignal } from '../../../shared/events' +import { SignalDispatcher } from 'ste-signals' import { RenderScene } from '../rendering/renderScene' import { ViewerSettings } from '../settings/viewerSettings' import { Viewport } from '../viewport' -import { CameraSaveState, ICamera } from './cameraInterface' +import { CameraSaveState, IWebglCamera } from './cameraInterface' import { CameraMovement } from './cameraMovement' import { CameraLerp } from './cameraMovementLerp' import { CameraMovementSnap } from './cameraMovementSnap' @@ -16,14 +17,18 @@ import { OrthographicCamera } from './cameraOrthographic' import { PerspectiveCamera } from './cameraPerspective' /** + * @internal * Manages viewer camera movement and position */ -export class Camera implements ICamera { - camPerspective: PerspectiveCamera - camOrthographic: OrthographicCamera +export class Camera implements IWebglCamera { + 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 _scene: RenderScene private _lerp: CameraLerp private _movement: CameraMovementSnap @@ -34,13 +39,16 @@ export class Camera implements ICamera { // orbit private _orthographic: boolean = false private _target = new THREE.Vector3() + private _screenTarget = new THREE.Vector2(0.5, 0.5) + private _isTargetFloating = false + private _cachedFrustumLength = 0 // updates private _lastPosition = new THREE.Vector3() 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() @@ -82,36 +90,36 @@ 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 () { - return this._force ? new THREE.Vector3(1, 1, 1) : this._allowedMovement + private _lockMovement = new THREE.Vector3(1, 1, 1) + get lockMovement () { + return this._force ? Camera._ALL_MOVEMENT : this._lockMovement } - set allowedMovement (axes: THREE.Vector3) { - this._allowedMovement.copy(axes) - this._allowedMovement.x = this._allowedMovement.x === 0 ? 0 : 1 - this._allowedMovement.y = this._allowedMovement.y === 0 ? 0 : 1 - this._allowedMovement.z = this._allowedMovement.z === 0 ? 0 : 1 + set lockMovement (axes: THREE.Vector3) { + this._lockMovement.copy(axes) + this._lockMovement.x = this._lockMovement.x === 0 ? 0 : 1 + this._lockMovement.y = this._lockMovement.y === 0 ? 0 : 1 + this._lockMovement.z = this._lockMovement.z === 0 ? 0 : 1 } /** - * 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 = yaw (around Z), y = pitch (up/down). + * Each component should be 0 (locked) or 1 (free). */ - get allowedRotation () { - return this._force ? new THREE.Vector2(1, 1) : this._allowedRotation + get lockRotation () { + return this._force ? Camera._ALL_ROTATION : this._lockRotation } - set allowedRotation (axes: THREE.Vector2) { - this._allowedRotation.copy(axes) - this._allowedRotation.x = this._allowedRotation.x === 0 ? 0 : 1 - this._allowedRotation.y = this._allowedRotation.y === 0 ? 0 : 1 + set lockRotation (axes: THREE.Vector2) { + this._lockRotation.copy(axes) + this._lockRotation.x = this._lockRotation.x === 0 ? 0 : 1 + this._lockRotation.y = this._lockRotation.y === 0 ? 0 : 1 } - private _allowedRotation = new THREE.Vector2(1, 1) + private _lockRotation = new THREE.Vector2(1, 1) /** * The default forward direction that can be used to initialize the camera. @@ -152,13 +160,15 @@ export class Camera implements ICamera { this.defaultForward = settings.camera.forward this._orthographic = settings.camera.orthographic - this.allowedMovement = settings.camera.allowedMovement - this.allowedRotation = settings.camera.allowedRotation + this.lockMovement = settings.camera.lockMovement + this.lockRotation = settings.camera.lockRotation // 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() } @@ -176,7 +186,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. */ @@ -192,10 +202,22 @@ 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. */ - 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) + } + + /** + * Returns the world-space direction from the camera through the given screen position. + * @param screenPos Screen position in 0-1 range (0,0 is top-left). + */ + screenToDirection (screenPos: THREE.Vector2): THREE.Vector3 { + const cam = this.camPerspective.camera + cam.updateMatrixWorld(true) + const ndc = new THREE.Vector3(screenPos.x * 2 - 1, -(screenPos.y * 2 - 1), 1) + ndc.unproject(cam) + return ndc.sub(this.position).normalize() } /** @@ -208,7 +230,9 @@ export class Camera implements ICamera { } /** - * The quaternion representing the orientation of the object. + * 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 @@ -216,6 +240,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 @@ -223,6 +249,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() @@ -231,13 +259,15 @@ 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()) } /** - * The current or target velocity of the camera. + * 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() @@ -247,7 +277,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() @@ -265,16 +295,42 @@ 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. + * @returns Live reference to internal state. Mutations affect the camera. + * Call `.clone()` if you need an independent copy. */ get target () { return this._target } - private applySettings (settings: ViewerSettings) { - // Camera + /** + * 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) + } + /** + * 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 isTargetFloating () { + return this._isTargetFloating + } + set isTargetFloating (value: boolean) { + if (value && !this._isTargetFloating) { + this._cachedFrustumLength = this.frustumSizeAt(this._target).length() + } + this._isTargetFloating = value } /** @@ -329,7 +385,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) @@ -365,15 +421,19 @@ 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) + // 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 } 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._isTargetFloating && this._cachedFrustumLength > 0 + ? this._cachedFrustumLength + : 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..1f30970b1 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraInterface.ts @@ -1,12 +1,123 @@ -import { ISignal } from 'ste-signals'; +/** + * ## Coordinate System + * All camera operations use **Z-up**: X = right, Y = forward, Z = up. + * This differs from Three.js's default Y-up convention. + * + * @module camera + */ + +import type { ISignal } from '../../../shared/events'; import * as THREE from 'three'; -import { CameraMovement } from './cameraMovement'; +import type { IElement3D } from '../../loader/element3d'; +import type { IWebglVim } from '../../loader/vim'; +import type { ISelectable } from '../selection'; + +/** + * Public interface for camera movement operations. + * + * Obtained via `camera.snap()` (instant) or `camera.lerp(duration)` (animated). + * + * @example + * ```ts + * camera.lerp(1).frame(element) // Animate to frame element + * camera.snap().set(position, target) // Instant position/target + * camera.lerp(0.5).orbit(new THREE.Vector2(45, 0)) // Animated orbit + * ``` + */ +export interface ICameraMovement { + /** + * 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. + */ + move(axis: 'X' | 'Y' | 'Z', amount: number, space: 'local' | 'world'): void + /** + * 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. + */ + move(axes: 'XY' | 'XZ' | 'YZ', vector: THREE.Vector2, space: 'local' | 'world'): void + /** + * 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. + */ + move(axes: 'XYZ', vector: THREE.Vector3, space: 'local' | 'world'): void + + /** + * Rotates the camera in place by the given angles. + * @param angle - x: yaw (around Z), y: pitch (up/down), in degrees. + */ + rotate(angle: THREE.Vector2): void + + /** + * Changes the distance between the camera and its target by a specified factor. + * @param amount - The zoom factor (e.g., 2 to zoom in / halve the distance, 0.5 to zoom out / double the distance). + */ + 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 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 (Z-up). + * @param screenPoint - Screen position of the world point, used to keep the target pinned under the cursor. + */ + zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void + + /** + * Orbits the camera around its target while maintaining the distance. + * @param angle - x: azimuth change, y: elevation change, in degrees. + */ + orbit(angle: THREE.Vector2): void + + /** + * Orbits the camera around its target to align with the given direction. + * @param direction - The direction towards which the camera should be oriented (Z-up). + */ + orbitTowards(direction: THREE.Vector3): void + + /** + * Orients the camera to look at the given point. The orbit target is updated. + * @param target - The target element or world position (Z-up) to look at. + */ + lookAt(target: IElement3D | THREE.Vector3): Promise + + /** + * Moves the orbit target without moving the camera or changing orientation. + * @param target - The new orbit target (element or world position in Z-up). + */ + setTarget(target: IElement3D | THREE.Vector3): Promise + + /** + * Resets the camera to its last saved position and orientation. + */ + reset(): void + + /** + * 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 (Z-up). + * @param target - The new orbit target (Z-up). Defaults to the current target. + */ + set(position: THREE.Vector3, target?: THREE.Vector3): void + + /** + * Sets the camera's orientation and position to focus on the specified target. + * @param target - The target to frame, or 'all' to frame everything. + * @param forward - Optional forward direction after framing (Z-up). + */ + frame(target: ISelectable | IWebglVim | THREE.Sphere | THREE.Box3 | 'all', forward?: THREE.Vector3): Promise +} /** * Interface representing a camera with various properties and methods for controlling its behavior. */ -export interface ICamera { +export interface IWebglCamera { /** * A signal that is dispatched when camera settings change. */ @@ -23,43 +134,49 @@ export interface ICamera { get hasMoved(): boolean; /** - * 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. + * Movement lock per axis in Z-up space (X = right, Y = forward, Z = up). + * Each component should be 0 (locked) or 1 (free). */ - allowedMovement: THREE.Vector3; + lockMovement: THREE.Vector3; /** - * 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. + * Rotation lock per axis. x = yaw (around Z), y = pitch (up/down). + * Each component should be 0 (locked) or 1 (free). */ - allowedRotation: THREE.Vector2; + lockRotation: THREE.Vector2; /** - * The default forward direction that can be used to initialize the camera. + * The default forward direction in Z-up space (X = right, Y = forward, Z = up). */ defaultForward: THREE.Vector3; /** * Interface for instantaneously moving the camera. * @param {boolean} [force=false] - Set to true to ignore locked axis and rotation. - * @returns {CameraMovement} The camera movement api. + * @returns {ICameraMovement} The camera movement api. */ - snap(force?: boolean): CameraMovement; + snap(force?: boolean): ICameraMovement; /** * Interface for smoothly moving the camera over time. * @param {number} [duration=1] - The duration of the camera movement animation. * @param {boolean} [force=false] - Set to true to ignore locked axis and rotation. - * @returns {CameraMovement} The camera movement api. + * @returns {ICameraMovement} The camera movement api. */ - lerp(duration: number, force?: boolean): CameraMovement; + lerp(duration: number, force?: boolean): ICameraMovement; /** * 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; + + /** + * Returns the world-space direction from the camera through the given screen position. + * @param screenPos Screen position in 0-1 range (0,0 is top-left). */ - frustrumSizeAt(point: THREE.Vector3): THREE.Vector2; + screenToDirection(screenPos: THREE.Vector2): THREE.Vector3; /** * The current THREE Camera @@ -67,12 +184,12 @@ 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; /** - * The position of the camera. + * The position of the camera in Z-up world space. */ get position(): THREE.Vector3; @@ -82,7 +199,7 @@ export interface ICamera { get matrix(): THREE.Matrix4; /** - * The forward direction of the camera. + * The forward direction of the camera in Z-up world space. */ get forward(): THREE.Vector3; @@ -99,7 +216,7 @@ export interface ICamera { stop(): void; /** - * The target at which the camera is looking at and around which it rotates. + * The target at which the camera is looking at and around which it rotates, in Z-up world space. */ get target(): THREE.Vector3; @@ -119,12 +236,13 @@ export interface ICamera { orthographic: boolean; } +/** @internal */ export class CameraSaveState{ - private _camera: ICamera - private _position: THREE.Vector3 = new THREE.Vector3() + private _camera: IWebglCamera + private _position: THREE.Vector3 = new THREE.Vector3() private _target: THREE.Vector3 = new THREE.Vector3() - constructor (camera: ICamera) { + constructor (camera: IWebglCamera) { this._camera = camera } save () { 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..ccb42aec9 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovement.ts @@ -3,20 +3,29 @@ */ import { Camera } from './camera' -import { Element3D } from '../../loader/element3d' -import { Selectable } from '../selection' +import { Element3D, type IElement3D } from '../../loader/element3d' +import { ISelectable } from '../selection' import * as THREE from 'three' import { Marker } from '../gizmos/markers/gizmoMarker' -import { Vim } from '../../loader/vim' -import { CameraSaveState } from './cameraInterface' +import { type IWebglVim, Vim } from '../../loader/vim' +import { CameraSaveState, ICameraMovement } from './cameraInterface' -export abstract class CameraMovement { +/** @internal */ +export abstract class CameraMovement implements ICameraMovement { + protected static readonly MAX_PITCH = Math.PI * 0.48 + protected _camera: Camera 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 @@ -24,65 +33,103 @@ 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 move3(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. */ - move2 (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 + move(axes: 'XY' | 'XZ' | 'YZ', vector: THREE.Vector2, space: 'local' | 'world'): void + /** + * 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. + */ + 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 + this._mvDir.set(0, 0, 0) + if (value instanceof THREE.Vector3) { + this._mvDir.copy(value) + } else if (value instanceof THREE.Vector2) { + this.setComponent(this._mvDir, axes[0], value.x) + this.setComponent(this._mvDir, axes[1], value.y) + } else { + this.setComponent(this._mvDir, axes, value) + } - if (direction) this.move3(direction) + if (space === 'local') { + // Remap Z-up (x,y,z) → Three.js camera-local (x, z, -y), then to world + 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(this._mvDir) + } } - /** - * 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'). - */ - move1 (amount: number, axis: 'X' | 'Y' | 'Z'): void { - const direction = new THREE.Vector3( - axis === 'X' ? -amount : 0, - axis === 'Z' ? amount : 0, - axis === 'Y' ? amount : 0, - ) + protected abstract applyMove(worldVector: THREE.Vector3): void - this.move3(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 } /** - * 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: yaw (around Z), y: pitch (up/down), in degrees. */ abstract rotate(angle: THREE.Vector2): void /** * 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 + zoom (amount: number): void { + this.setDistance(this._camera.orbitDistance / amount) + } /** - * 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. + * 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 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 setDistance(dist: number): void + 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. + * 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(vector: 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._camera.isTargetFloating = true + } + this.applyOrbit(angle) + } + + protected abstract applyOrbit(angle: THREE.Vector2): void /** * Orbits the camera around its target to align with the given direction. @@ -117,7 +164,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. @@ -126,32 +173,55 @@ 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 target(target: Element3D | THREE.Vector3): void + async lookAt (target: IElement3D | THREE.Vector3) { + const pos = target instanceof THREE.Vector3 ? target : (await target.getCenter()) + if (!pos) return + this._camera.screenTarget.set(0.5, 0.5) + this._camera.isTargetFloating = false + this.lookAtPoint(pos) + } + + 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: IElement3D | THREE.Vector3) { + const pos = target instanceof THREE.Vector3 ? target : (await target.getCenter()) + if (!pos) return + this._camera.target.copy(pos) + this._camera.isTargetFloating = false + this.updateScreenTarget() + } /** * Resets the camera to its last saved position and orientation. */ reset () { + this._camera.screenTarget.set(0.5, 0.5) + this._camera.isTargetFloating = false this.set(this._savedState.position, this._savedState.target) } /** - * 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: ISelectable | IWebglVim | THREE.Sphere | THREE.Box3 | 'all', forward?: THREE.Vector3 ) { if ((target instanceof Marker) || (target instanceof Element3D)) { @@ -161,7 +231,6 @@ export abstract class CameraMovement { target = target.scene.getAverageBoundingBox() } if (target === 'all') { - console.log('frame all') target = this._getBoundingBox() } if (target instanceof THREE.Box3) { @@ -174,23 +243,106 @@ 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) const pos = direction.multiplyScalar(-safeDist).add(sphere.center) + this._camera.screenTarget.set(0.5, 0.5) + this._camera.isTargetFloating = false this.set(pos, sphere.center) } + protected applyRotation (quaternion: THREE.Quaternion) { + this._camera.quaternion.copy(quaternion) + 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. + * 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) + this._mvOffset.set(-sx, -sy, 1).normalize().multiplyScalar(dist) + this._mvOffset.applyQuaternion(cam.quaternion) + + this._camera.position.copy(this._camera.target).add(this._mvOffset) + } + + /** + * 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) + this._mvProjected.copy(this._camera.target).project(cam) + + if (this._mvProjected.z > 1) { + this._camera.screenTarget.set(0.5, 0.5) + 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( + (this._mvProjected.x + 1) / 2, + (1 - this._mvProjected.y) / 2 + ) + } + + protected lockVector (position: THREE.Vector3, fallback: THREE.Vector3, out: THREE.Vector3): THREE.Vector3 { + const allowed = this._camera.lockMovement + 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 2e4bfc707..5bbec5a7f 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementLerp.ts @@ -4,21 +4,26 @@ 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' +import { SphereCoord } from './sphereCoord' +/** @internal */ 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 + + 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) @@ -40,7 +45,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) } @@ -57,99 +62,129 @@ export class CameraLerp extends CameraMovement { this.onProgress?.(t) } - 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) + protected applyMove (worldVector: THREE.Vector3): void { + const startPos = this._camera.position.clone() + const endPos = this._camera.position.clone().add(worldVector) this.onProgress = (progress) => { - pos.copy(start) - pos.lerp(end, progress) - this._movement.set(pos, pos.clone().add(offset)) + this._lrTmp.copy(startPos).lerp(endPos, progress) + this._movement.reposition(this._lrTmp) } } rotate (angle: THREE.Vector2): void { - const euler = new THREE.Euler(0, 0, 0, 'YXZ') - 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 - - // Clamp X rotation 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.lockRotation) 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) rot.slerp(end, progress) - this._movement.applyRotation(rot) + this.applyRotation(rot) } } - zoom (amount: number): void { - const dist = this._camera.orbitDistance * amount - this.setDistance(dist) - } - - setDistance (dist: number): void { + protected setDistance (dist: number): void { const start = this._camera.position.clone() const end = this._camera.target .clone() .lerp(start, dist / this._camera.orbitDistance) this.onProgress = (progress) => { - this._camera.position.copy(start) - this._camera.position.lerp(end, progress) + this._lrTmp.copy(start).lerp(end, progress) + this._movement.reposition(this._lrTmp) } } - orbit (angle: THREE.Vector2): void { + zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void { const startPos = this._camera.position.clone() - const startTarget = this._camera.target.clone() - const a = new THREE.Vector2() + + // 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._camera.isTargetFloating = false + + // Update screen target so orbit pivot stays at cursor position + if (screenPoint) { + this._camera.screenTarget.copy(screenPoint) + } + + this.onProgress = (progress) => { + this._lrTmp.copy(startPos).lerp(endPos, progress) + this.lockVector(this._lrTmp, this._camera.position, this._lrTmp2) + this._camera.position.copy(this._lrTmp2) + } + } + + protected applyOrbit (angle: THREE.Vector2): void { + const locked = angle.clone().multiply(this._camera.lockRotation) + const radius = this._camera.orbitDistance + + const start = SphereCoord.fromForward(this._camera.forward, radius) + const startOffset = start.toVector3() + const endOffset = start.rotate(locked.x, locked.y).toVector3() this.onProgress = (progress) => { - a.set(0, 0) - a.lerp(angle, progress) - this._movement.set(startPos, startTarget) - this._movement.orbit(a) + this._lrTmp.copy(startOffset).lerp(endOffset, progress) + this._lrTmp.normalize().multiplyScalar(radius) + this._lrTmp.add(this._camera.target) + + 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) + this.applyScreenTargetOffset() } } - async target (target: Element3D | THREE.Vector3) { - const pos = target instanceof Element3D ? (await target.getCenter()) : target - const next = pos.clone().sub(this._camera.position) + protected lookAtPoint (point: THREE.Vector3) { 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(point) + const end = this._camera.quaternion.clone() + this._camera.quaternion.copy(savedQuat) + this.onProgress = (progress) => { - const r = start.clone().slerp(rot, progress) - this._movement.applyRotation(r) + this._lrQuat.copy(start).slerp(end, progress) + this.applyRotation(this._lrQuat) } } set (position: THREE.Vector3, target?: THREE.Vector3) { + this._camera.isTargetFloating = false 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._movement.set( - startPos.clone().lerp(position, progress), - startTarget.clone().lerp(endTarget, progress) - ) + this._lrTmp.copy(startPos).lerp(endPos, progress) + this._lrTmp2.copy(startTarget).lerp(endTarget, progress) + 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) } } } 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..704eac560 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraMovementSnap.ts @@ -3,142 +3,101 @@ */ import { CameraMovement } from './cameraMovement' -import { Element3D } from '../../loader/element3d' +import { SphereCoord } from './sphereCoord' import * as THREE from 'three' +/** @internal */ export class CameraMovementSnap extends CameraMovement { - /** - * 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) - } - - setDistance (dist: number): void { - const pos = this._camera.target - .clone() - .sub(this._camera.forward.multiplyScalar(dist)) + private static readonly _ZERO = new THREE.Vector3() + private _snTmp1 = new THREE.Vector3() + private _snTmp2 = new THREE.Vector3() - this.set(pos, this._camera.target) + protected setDistance (dist: number): void { + this._snTmp1.copy(this._camera.target).sub(this._camera.forward.multiplyScalar(dist)) + this.reposition(this._snTmp1) } rotate (angle: THREE.Vector2): void { - const locked = angle.clone().multiply(this._camera.allowedRotation) - const rotation = this.predictRotate(locked) + const locked = angle.clone().multiply(this._camera.lockRotation) + const rotation = this.computeRotation(locked) this.applyRotation(rotation) } - 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) + protected lookAtPoint (point: THREE.Vector3) { + this.set(this._camera.position, point) } - async target (target: Element3D | THREE.Vector3) { - const pos = target instanceof Element3D ? (await target.getCenter()) : target - if (!pos) return - this.set(this._camera.position, pos) - } + protected applyOrbit (angle: THREE.Vector2): void { + const locked = angle.clone().multiply(this._camera.lockRotation) - orbit (angle: THREE.Vector2): void { - const locked = angle.clone().multiply(this._camera.allowedRotation) - const pos = this.predictOrbit(locked) - this.set(pos) + const start = SphereCoord.fromForward(this._camera.forward, this._camera.orbitDistance) + 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) + this._camera.position.copy(this._snTmp2) + + this._camera.camPerspective.camera.up.set(0, 0, 1) + this._camera.camPerspective.camera.lookAt(this._camera.target) + this.applyScreenTargetOffset() } - override move3 (vector: THREE.Vector3): void { - const v = vector.clone() - 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) + protected applyMove (worldVector: THREE.Vector3): void { + this.lockVector(worldVector, CameraMovementSnap._ZERO, this._snTmp1) + if (this._camera.isTargetFloating) { + this._camera.target.add(this._snTmp1) + } + this._snTmp2.copy(this._camera.position).add(this._snTmp1) + this.reposition(this._snTmp2) } - set(position: THREE.Vector3, target?: THREE.Vector3) { - // 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 + set (position: THREE.Vector3, target?: THREE.Vector3) { + target = target ?? this._camera.target + + 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) { - // 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 - 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)); - } 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' now has the same length but is clamped in angle - // Recompute the actual camera position - position.copy(target).add(direction); + const clamped = SphereCoord.fromVector(this._snTmp1) + this._snTmp1.copy(clamped.toVector3()) + finalPos = this._snTmp2.copy(target).add(this._snTmp1) } - - // 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); - } - - 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 + this.lockVector(finalPos, this._camera.position, this._snTmp1) + this._camera.position.copy(this._snTmp1) + this._camera.target.copy(target) + this._camera.isTargetFloating = false - return new THREE.Vector3(x, y, z) + this._camera.camPerspective.camera.up.set(0, 0, 1) + this._camera.camPerspective.camera.lookAt(target) + this.applyScreenTargetOffset() } - predictOrbit (angle: THREE.Vector2) { - const rotation = this.predictRotate(angle) + 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) + this._camera.isTargetFloating = false + } + this.updateScreenTarget() + } - const delta = new THREE.Vector3(0, 0, 1) - .applyQuaternion(rotation) - .multiplyScalar(this._camera.orbitDistance) + zoomTowards(amount: number, worldPoint: THREE.Vector3, screenPoint?: THREE.Vector2): void { + this._snTmp1.copy(this._camera.position).sub(worldPoint).normalize() - const pos = this._camera.target.clone().add(delta) - + const currentDist = this._camera.position.distanceTo(worldPoint) + const newDist = currentDist / amount - return pos - } + this._snTmp2.copy(worldPoint).add(this._snTmp1.multiplyScalar(newDist)) + + this.reposition(this._snTmp2, worldPoint) - 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; + if (screenPoint) { + this._camera.screenTarget.copy(screenPoint) + } } - } 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..5269c9637 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraOrthographic.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraOrthographic.ts @@ -6,6 +6,7 @@ import * as THREE from 'three' import { ViewerSettings } from '../settings/viewerSettings' import { Layers } from '../raycaster' +/** @internal */ export class OrthographicCamera { camera: THREE.OrthographicCamera @@ -19,7 +20,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..665eb4618 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/cameraPerspective.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/cameraPerspective.ts @@ -6,6 +6,7 @@ import * as THREE from 'three' import { ViewerSettings } from '../settings/viewerSettings' import { Layers } from '../raycaster' +/** @internal */ export class PerspectiveCamera { camera: THREE.PerspectiveCamera @@ -25,7 +26,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/camera/index.ts b/src/vim-web/core-viewers/webgl/viewer/camera/index.ts index bf4961814..2e27993b4 100644 --- a/src/vim-web/core-viewers/webgl/viewer/camera/index.ts +++ b/src/vim-web/core-viewers/webgl/viewer/camera/index.ts @@ -1,8 +1 @@ -// Types only -export type * from './camera'; -export type * from './cameraInterface'; -export type * from './cameraMovement'; -export type * from './cameraMovementLerp'; -export type * from './cameraMovementSnap'; -export type * from './cameraOrthographic'; -export type * from './cameraPerspective'; +export type { IWebglCamera, ICameraMovement } from './cameraInterface' 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..4b97a0b34 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/viewer/camera/sphereCoord.ts @@ -0,0 +1,52 @@ +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 + * @internal + */ +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) + ) + } +} diff --git a/src/vim-web/core-viewers/webgl/viewer/environment/environment.ts b/src/vim-web/core-viewers/webgl/viewer/environment/environment.ts deleted file mode 100644 index df0fc902d..000000000 --- a/src/vim-web/core-viewers/webgl/viewer/environment/environment.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * @module viw-webgl-viewer - */ - -import * as THREE from 'three' -import { ViewerSettings } from '../settings/viewerSettings' -import { ICamera } from '../camera/cameraInterface' -import { Materials } from '../../loader/materials/materials' -import { Skybox } from './skybox' -import { Renderer } from '../rendering/renderer' -import { Light } from './light' -/** - * Manages ground plane and lights that are part of the THREE.Scene to render but not part of the Vims. - */ -export class Environment { - private readonly _renderer: Renderer - private readonly _camera: ICamera - - /** - * The skylight in the scene. - */ - readonly skyLight: THREE.HemisphereLight - - /** - * The array of directional lights in the scene. - */ - readonly sunLights: ReadonlyArray - - /* - * The skybox in the scene. - */ - readonly skybox: Skybox - - constructor (camera:ICamera, renderer: Renderer, materials: Materials, settings: ViewerSettings) { - this._camera = camera - this._renderer = renderer - - this.skyLight = this.createSkyLight(settings) - this.skybox = new Skybox(camera, renderer, materials, settings) - this.sunLights = this.createSunLights(settings) - - this.addObjectsToRenderer() - } - - /** - * Returns all three objects composing the environment - */ - private getObjects (): ReadonlyArray { - return [this.skyLight, ...this.sunLights.map(l => l.light), this.skybox.mesh] - } - - private createSkyLight (settings: ViewerSettings): THREE.HemisphereLight { - const { skyColor, groundColor, intensity } = settings.skylight - return new THREE.HemisphereLight(skyColor, groundColor, intensity * Math.PI) - } - - private createSunLights (settings: ViewerSettings): ReadonlyArray { - return settings.sunlights.map((s) => - new Light(this._camera, s) - ) - } - - private addObjectsToRenderer (): void { - this.getObjects().forEach((o) => this._renderer.add(o)) - } - - /** - * Dispose of all resources. - */ - dispose (): void { - this.getObjects().forEach((o) => this._renderer.remove(o)) - this.sunLights.forEach((s) => s.dispose()) - this.skyLight.dispose() - this.skybox.dispose() - } -} diff --git a/src/vim-web/core-viewers/webgl/viewer/environment/index.ts b/src/vim-web/core-viewers/webgl/viewer/environment/index.ts deleted file mode 100644 index f1977814a..000000000 --- a/src/vim-web/core-viewers/webgl/viewer/environment/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type * from './environment'; -export type * from './light'; -export type * from './skybox'; \ No newline at end of file diff --git a/src/vim-web/core-viewers/webgl/viewer/environment/light.ts b/src/vim-web/core-viewers/webgl/viewer/environment/light.ts deleted file mode 100644 index a59197da8..000000000 --- a/src/vim-web/core-viewers/webgl/viewer/environment/light.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @module viw-webgl-viewer - */ - -import * as THREE from 'three' -import { ICamera } from '../camera/cameraInterface' - -export class Light { - readonly light : THREE.DirectionalLight - private readonly _camera : ICamera - private _unsubscribe : (() => void) | undefined = undefined - - /** - * The position of the light. - */ - position: THREE.Vector3 - - /** - * The color of the light. - */ - get color () { - return this.light.color - } - - set color (value: THREE.Color) { - this.light.color = value - } - - /** - * The intensity of the light. - */ - get intensity () { - return this.light.intensity - } - - set intensity (value: number) { - this.light.intensity = value - } - - /** - * Whether the light follows the camera or not. - */ - get followCamera () { - return this._unsubscribe !== undefined - } - - set followCamera (value: boolean) { - if (this.followCamera === value) return - - this._unsubscribe?.() - this._unsubscribe = undefined - - if (value) { - this._unsubscribe = this._camera.onMoved.subscribe(() => this.updateLightPosition()) - this.updateLightPosition() - } - } - - constructor ( - camera: ICamera, - options: { - followCamera: boolean, - position: THREE.Vector3, - color: THREE.Color, - intensity: number - } - ) { - this._camera = camera - this.position = options.position.clone() - this.light = new THREE.DirectionalLight(options.color, options.intensity * Math.PI) - this.followCamera = options.followCamera - } - - /** - * Updates the light's position based on the camera's quaternion. - */ - private updateLightPosition () { - this.light.position.copy(this.position).applyQuaternion(this._camera.quaternion) - } - - /** - * Disposes of the camera light. - */ - dispose () { - this._unsubscribe?.() - this._unsubscribe = undefined - this.light.dispose() - } -} diff --git a/src/vim-web/core-viewers/webgl/viewer/environment/skybox.ts b/src/vim-web/core-viewers/webgl/viewer/environment/skybox.ts deleted file mode 100644 index 92ea843ab..000000000 --- a/src/vim-web/core-viewers/webgl/viewer/environment/skybox.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @module viw-webgl-viewer - */ - -import * as THREE from 'three' -import { ViewerSettings } from '../settings/viewerSettings' -import { ICamera } from '../camera/cameraInterface' -import { Materials } from '../../loader/materials/materials' -import { SkyboxMaterial } from '../../loader/materials/skyboxMaterial' -import { Renderer } from '../rendering/renderer' -import { Layers } from '../raycaster' - -export class Skybox { - readonly mesh : THREE.Mesh - - /** - * Whether the skybox is enabled or not. - */ - get enable () { - return this.mesh.visible - } - - /** - * Whether the skybox is enabled or not. - */ - set enable (value: boolean) { - this.mesh.visible = value - this._renderer.needsUpdate = true - } - - /** - * The color of the sky. - */ - get skyColor () { - return this._material.skyColor - } - - set skyColor (value: THREE.Color) { - this._material.skyColor = value - this._renderer.needsUpdate = true - } - - /** - * The color of the ground. - */ - get groundColor () { - return this._material.groundColor - } - - set groundColor (value: THREE.Color) { - this._material.groundColor = value - this._renderer.needsUpdate = true - } - - /** - * The sharpness of the gradient transition between the sky and the ground. - */ - get sharpness () { - return this._material.sharpness - } - - set sharpness (value: number) { - this._material.sharpness = value - this._renderer.needsUpdate = true - } - - private readonly _plane : THREE.PlaneGeometry - private readonly _material : SkyboxMaterial - private readonly _renderer: Renderer - - constructor (camera: ICamera, renderer : Renderer, materials: Materials, settings: ViewerSettings) { - this._renderer = renderer - this._plane = new THREE.PlaneGeometry() - this._material = materials.skyBox - this.mesh = new THREE.Mesh(this._plane, materials.skyBox) - this.mesh.layers.set(Layers.NoRaycast) - - // Apply settings - this.enable = settings.skybox.enable - this.skyColor = settings.skybox.skyColor - this.groundColor = settings.skybox.groundColor - this.sharpness = settings.skybox.sharpness - - 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) - this.mesh.scale.set(size.x, size.y, 1) - }) - } - - dispose () { - this._plane.dispose() - } -} diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/axes.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/axes.ts index c37a7b1c1..c85703096 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/axes.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/axes.ts @@ -1,6 +1,7 @@ import * as THREE from 'three' import { AxesSettings } from './axesSettings' +/** @internal */ export class Axis { axis: string direction: THREE.Vector3 @@ -27,6 +28,7 @@ export class Axis { } } +/** @internal */ export function createAxes (settings : AxesSettings) { return [ new Axis({ diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/axesSettings.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/axesSettings.ts index bfbde8f72..88579814f 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/axesSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/axesSettings.ts @@ -17,6 +17,7 @@ export interface AxesSettings { colorZSub: string; } +/** @internal */ export function getDefaultAxesSettings() :AxesSettings { return{ size: 84, @@ -39,6 +40,7 @@ export function getDefaultAxesSettings() :AxesSettings { }; +/** @internal */ export function createAxesSettings( init?: Partial ): AxesSettings { diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/gizmoAxes.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/gizmoAxes.ts index 0001b6117..e081af8d9 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/gizmoAxes.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/gizmoAxes.ts @@ -3,15 +3,26 @@ */ import * as THREE from 'three' -import { Camera } from '../../camera' -import { Viewport } from '../../viewport' +import { Camera } from '../../camera/camera' +import { IWebglViewport } from '../../viewport' import { AxesSettings, createAxesSettings } from './axesSettings' import { Axis, createAxes } from './axes' /** + * Public interface for the axis gizmo. + */ +export interface IGizmoAxes { + /** The canvas on which the axes are drawn. */ + readonly canvas: HTMLCanvasElement + /** Resizes the gizmo to the given pixel size. */ + resize(size: number): void +} + +/** + * @internal * The axis gizmos of the viewer. */ -export class GizmoAxes { +export class GizmoAxes implements IGizmoAxes { // settings private _initialOptions: AxesSettings private _options: AxesSettings @@ -46,7 +57,7 @@ export class GizmoAxes { return this._canvas } - constructor (camera: Camera, viewport: Viewport, options?: Partial) { + constructor (camera: Camera, viewport: IWebglViewport, options?: Partial) { this._initialOptions = createAxesSettings(options) this._options = createAxesSettings(options) this._camera = camera @@ -199,7 +210,7 @@ export class GizmoAxes { this._selectedAxis = null } - public update = () => { + public update () { if (!this._camera.hasMoved && !this._pointerInside && !this._isDragging && !this._resized) { return } @@ -329,4 +340,3 @@ export class GizmoAxes { } } -export { GizmoAxes as OrbitControlsGizmo } diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/index.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/index.ts index 9e68c597d..222d52850 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/index.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/axes/index.ts @@ -1,3 +1,2 @@ -export * from './axes' -export * from './axesSettings' -export * from './gizmoAxes' \ No newline at end of file +export type { AxesSettings } from './axesSettings' +export type { IGizmoAxes } from './gizmoAxes' \ No newline at end of file diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoLoading.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoLoading.ts deleted file mode 100644 index 52f1baa17..000000000 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoLoading.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - @module viw-webgl-viewer/gizmos/sectionBox -*/ - -import { Viewer } from '../viewer' - -/** - * The loading indicator gizmo. - */ -export class GizmoLoading { - // dependencies - private _viewer: Viewer - private _spinner: HTMLElement - private _visible: boolean - - constructor (viewer: Viewer) { - this._viewer = viewer - this._spinner = this.createBar() - this._visible = false - } - - private createBar () { - const div = document.createElement('span') - div.className = 'loader' - return div - } - - /** - * Indicates whether the loading gizmo will be rendered. - */ - get visible () { - return this._visible - } - - set visible (value: boolean) { - if (!this._visible && value) { - this._viewer.viewport.canvas.parentElement.appendChild(this._spinner) - this._visible = true - } - if (this._visible && !value) { - this._spinner.parentElement.removeChild(this._spinner) - this._visible = false - } - } - - /** - * Disposes of all resources. - */ - dispose () { - this.visible = false - } -} 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..6b6fdde42 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmoOrbit.ts @@ -5,41 +5,74 @@ import * as THREE from 'three' import { Renderer } from '../rendering/renderer' import { Camera } from '../camera/camera' import { ViewerSettings } from '../settings/viewerSettings' -import {type InputHandler, PointerMode} from '../../../shared' +import {type IInputHandler, 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 + +/** + * Public interface for the orbit target gizmo. + */ +export interface IGizmoOrbit { + /** Whether the orbit gizmo is enabled. */ + enabled: boolean + /** Updates the size of the orbit gizmo (fraction of screen 0-1). */ + setSize(size: number): void + /** Updates the colors of the orbit gizmo. */ + setColors(color: THREE.Color, colorHorizontal: THREE.Color): void + /** Updates the opacities of the orbit gizmo. */ + setOpacity(opacity: number, opacityAlways: number): void +} /** - * Manages the camera target gizmo + * @internal + * 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 { +export class GizmoOrbit implements IGizmoOrbit { // Dependencies private _renderer: Renderer private _camera: Camera - private _inputs: InputHandler + private _inputs: IInputHandler // 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, camera: Camera, - input: InputHandler, + input: IInputHandler, settings: ViewerSettings ) { this._renderer = renderer @@ -67,7 +100,7 @@ export class GizmoOrbit { private onUpdate () { this.updateScale() this.setPosition(this._camera.target) - this.show(this._inputs.pointerActive === PointerMode.ORBIT) + this.show(this._inputs.pointerMode === PointerMode.ORBIT) } /** @@ -94,13 +127,13 @@ export class GizmoOrbit { clearTimeout(this._timeout) this._gizmos.visible = show - this._renderer.needsUpdate = true - + this._renderer.requestRender() + // Hide after one second since last request if (show) { this._timeout = setTimeout(() => { this._gizmos.visible = false - this._renderer.needsUpdate = true + this._renderer.requestRender() }, this._showDurationMs) } } @@ -116,101 +149,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 + const frustum = this._camera.frustumSizeAt(this._gizmos.position) + // Size is fraction of screen (0-1), use smaller dimension + const screenSize = Math.min(frustum.x, frustum.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() - this._material = new THREE.LineBasicMaterial({ + // Shared torus geometry (unit radius) + this._torusGeometry = new THREE.TorusGeometry( + TORUS_RADIUS, + TUBE_RADIUS, + TUBULAR_SEGMENTS, + RADIAL_SEGMENTS + ) + + // 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 }) - this._materialAlways = new THREE.LineBasicMaterial({ + + // Materials for horizontal rings + this._horizontalMaterialDepth = new THREE.MeshBasicMaterial({ + color: this._colorHorizontal, + depthTest: true, + transparent: true, + opacity: this._opacity, + side: THREE.DoubleSide + }) + 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 + ] - this._gizmos.layers.set(2) + // Instance matrices for horizontal rings (3 rings) + const pos = new THREE.Vector3() + const quat = new THREE.Quaternion() + const scale = new THREE.Vector3() + + 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/gizmos/gizmos.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmos.ts index 48a67d63c..99fe490e5 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmos.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/gizmos.ts @@ -1,17 +1,36 @@ -import { Viewer } from '../viewer' -import { GizmoAxes } from './axes/gizmoAxes' -import { GizmoLoading } from './gizmoLoading' -import { GizmoOrbit } from './gizmoOrbit' +import { WebglViewer } from '../viewer' +import { GizmoAxes, IGizmoAxes } from './axes/gizmoAxes' +import { GizmoOrbit, IGizmoOrbit } from './gizmoOrbit' import { IMeasure, Measure } from './measure/measure' -import { SectionBox } from './sectionBox/sectionBox' -import { GizmoMarkers } from './markers/gizmoMarkers' +import { IWebglSectionBox, SectionBox } from './sectionBox/sectionBox' +import { GizmoMarkers, type IGizmoMarkers } from './markers/gizmoMarkers' import { Camera } from '../camera/camera' +import { Renderer } from '../rendering/renderer' +import { Viewport } from '../viewport' /** + * Public interface for the gizmo collection. + * Exposes only the members needed by API consumers. + */ +export interface IGizmos { + /** The interface to start and manage measure tool interaction. */ + readonly measure: IMeasure + /** The section box gizmo. */ + readonly sectionBox: IWebglSectionBox + /** The camera orbit target gizmo. */ + readonly orbit: IGizmoOrbit + /** The axis gizmos of the viewer. */ + readonly axes: IGizmoAxes + /** The interface for adding and managing sprite markers in the scene. */ + readonly markers: IGizmoMarkers +} + +/** + * @internal * Represents a collection of gizmos used for various visualization and interaction purposes within the viewer. */ -export class Gizmos { - private readonly viewer: Viewer +export class Gizmos implements IGizmos { + private readonly _viewport: Viewport /** * The interface to start and manage measure tool interaction. @@ -27,11 +46,6 @@ export class Gizmos { */ readonly sectionBox: SectionBox - /** - * The loading indicator gizmo. - */ - readonly loading: GizmoLoading - /** * The camera orbit target gizmo. */ @@ -47,20 +61,19 @@ export class Gizmos { */ readonly markers: GizmoMarkers - constructor (viewer: Viewer, camera : Camera) { - this.viewer = viewer - this._measure = new Measure(viewer) - this.sectionBox = new SectionBox(viewer) - this.loading = new GizmoLoading(viewer) + constructor (renderer: Renderer, viewport: Viewport, viewer: WebglViewer, camera : Camera) { + this._viewport = viewport + this._measure = new Measure(viewer, renderer) + this.sectionBox = new SectionBox(renderer, viewer) this.orbit = new GizmoOrbit( - viewer.renderer, + renderer, camera, viewer.inputs, viewer.settings ) - this.axes = new GizmoAxes(camera, viewer.viewport, viewer.settings.axes) - this.markers = new GizmoMarkers(viewer) - viewer.viewport.canvas.parentElement?.prepend(this.axes.canvas) + this.axes = new GizmoAxes(camera, viewport, viewer.settings.axes) + this.markers = new GizmoMarkers(renderer, viewer.selection) + viewport.canvas.parentElement?.prepend(this.axes.canvas) } updateAfterCamera () { @@ -71,10 +84,9 @@ export class Gizmos { * Disposes of all gizmos. */ dispose () { - this.viewer.viewport.canvas.parentElement?.removeChild(this.axes.canvas) + this._viewport.canvas.parentElement?.removeChild(this.axes.canvas) this._measure.clear() this.sectionBox.dispose() - this.loading.dispose() this.orbit.dispose() this.axes.dispose() } diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/index.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/index.ts index 4becf22d5..25d09f444 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/index.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/index.ts @@ -1,9 +1,6 @@ -export * from './gizmos'; -export * from './gizmoLoading'; - -// Type exports -export type * from './gizmoOrbit'; -export type * from './axes'; -export type * from './markers'; -export type * from './measure'; -export type * from './sectionBox'; +export type { IGizmos } from './gizmos' +export type { IGizmoOrbit } from './gizmoOrbit' +export type { AxesSettings, IGizmoAxes } from './axes' +export type { IMarker, IGizmoMarkers } from './markers' +export type { IMeasure, MeasureStage } from './measure' +export type { IWebglSectionBox } from './sectionBox' diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/gizmoMarker.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/gizmoMarker.ts index 43d64b895..fe697eb16 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/gizmoMarker.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/gizmoMarker.ts @@ -1,18 +1,54 @@ -import { Vim } from '../../../loader/vim' -import { Viewer } from '../../viewer' +import { IWebglVim } from '../../../loader/vim' +import { Renderer } from '../../rendering/renderer' import * as THREE from 'three' import { SimpleInstanceSubmesh } from '../../../loader/mesh' import { WebglAttribute } from '../../../loader/webglAttribute' import { WebglColorAttribute } from '../../../loader/colorAttribute' -import { IVimElement } from '../../../../shared/vim' +import { ISelectable } from '../../selection' /** + * Public interface for a marker gizmo in the scene. + * + * Obtained via `viewer.gizmos.markers.add(position)`. + */ +export interface IMarker extends ISelectable { + readonly type: 'Marker' + /** The Vim object from which this marker came, if any. */ + vim: IWebglVim | undefined + /** The BIM element index, if associated with a Vim element. */ + element: number | undefined + /** The geometry instances, if derived from multiple instances. */ + instances: number[] | undefined + /** The index of the marker in the marker collection. */ + readonly index: number + /** Always false for markers. */ + readonly isRoom: boolean + /** Always false; marker is a gizmo, not an actual mesh. */ + readonly hasMesh: boolean + /** Whether the marker is outlined (highlighted). */ + outline: boolean + /** Whether the marker is visible in the scene. */ + visible: boolean + /** Whether the marker is focused (enlarged). */ + focused: boolean + /** The color override for the marker. */ + color: THREE.Color | undefined + /** The uniform scale factor applied to the marker. */ + size: number + /** The world position of the marker in Z-up space (X = right, Y = forward, Z = up). */ + position: THREE.Vector3 + /** Retrieves the bounding box of the marker in Z-up world space. */ + getBoundingBox(): Promise +} + +/** + * @internal * Marker gizmo that displays an interactive sphere at a 3D position. * Marker gizmos are still under development. */ -export class Marker implements IVimElement { +export class Marker implements IMarker { public readonly type = 'Marker' - private _viewer: Viewer + private _renderer: Renderer private _submesh: SimpleInstanceSubmesh private static _tmpMatrix = new THREE.Matrix4() @@ -22,7 +58,7 @@ export class Marker implements IVimElement { * The Vim object from which this object came. * Can be undefined if the object is not part of a Vim. */ - vim: Vim | undefined + vim: IWebglVim | undefined /** * The BIM element index associated with this object. @@ -58,8 +94,8 @@ export class Marker implements IVimElement { * @param viewer - The viewer managing rendering and interaction. * @param submesh - The instanced submesh this marker is bound to. */ - constructor(viewer: Viewer, submesh: SimpleInstanceSubmesh) { - this._viewer = viewer + constructor(renderer: Renderer, submesh: SimpleInstanceSubmesh) { + this._renderer = renderer this._submesh = submesh const array = [submesh] @@ -67,12 +103,13 @@ export class Marker implements IVimElement { this._visibleAttribute = new WebglAttribute(true, 'ignore', 'ignore', array, (v) => (v ? 0 : 1)) this._focusedAttribute = new WebglAttribute(false, 'focused', 'focused', array, (v) => (v ? 1 : 0)) this._coloredAttribute = new WebglAttribute(false, 'colored', 'colored', array, (v) => (v ? 1 : 0)) - this._colorAttribute = new WebglColorAttribute(array, undefined, undefined) + this._colorAttribute = new WebglColorAttribute(array, undefined) this.color = new THREE.Color(0xff1a1a) } /** + * @internal * Updates the underlying submesh and rebinds all attributes to the new mesh. * @param mesh - The new submesh to bind to this marker. */ @@ -84,7 +121,7 @@ export class Marker implements IVimElement { this._outlineAttribute.updateMeshes(array) this._colorAttribute.updateMeshes(array) this._coloredAttribute.updateMeshes(array) - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() } /** @@ -94,7 +131,7 @@ export class Marker implements IVimElement { Marker._tmpMatrix.compose(value, new THREE.Quaternion(), new THREE.Vector3(1, 1, 1)) this._submesh.mesh.setMatrixAt(this.index, Marker._tmpMatrix) this._submesh.mesh.instanceMatrix.needsUpdate = true - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() this._submesh.mesh.computeBoundingSphere() // Required for raycasting } @@ -125,8 +162,8 @@ export class Marker implements IVimElement { */ set outline(value: boolean) { if (this._outlineAttribute.apply(value)) { - if (value) this._viewer.renderer.addOutline() - else this._viewer.renderer.removeOutline() + if (value) this._renderer.addOutline() + else this._renderer.removeOutline() } } @@ -142,7 +179,7 @@ export class Marker implements IVimElement { */ set focused(value: boolean) { this._focusedAttribute.apply(value) - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() } /** @@ -157,7 +194,7 @@ export class Marker implements IVimElement { */ set visible(value: boolean) { this._visibleAttribute.apply(value) - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() } /** @@ -178,7 +215,7 @@ export class Marker implements IVimElement { } else { this._coloredAttribute.apply(false) } - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() } /** @@ -203,7 +240,7 @@ export class Marker implements IVimElement { matrix.elements[10] = value this._submesh.mesh.setMatrixAt(this.index, matrix) this._submesh.mesh.instanceMatrix.needsUpdate = true - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() this._submesh.mesh.computeBoundingSphere() // Required for Raycast } } 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..fd86f0b19 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 @@ -1,15 +1,29 @@ -import { Viewer } from '../../viewer' import * as THREE from 'three' -import { Marker } from './gizmoMarker' +import { Marker, type IMarker } from './gizmoMarker' import { StandardMaterial } from '../../../loader/materials/standardMaterial' import { SimpleInstanceSubmesh } from '../../../loader/mesh' +import { packPickingId, MARKER_VIM_INDEX } from '../../rendering/gpuPicker' +import { Renderer } from '../../rendering/renderer' +import { IWebglSelection } from '../../selection' /** + * Public interface for adding and managing sprite markers in the scene. + */ +export interface IGizmoMarkers { + getMarkerFromIndex(index: number): IMarker | undefined + add(position: THREE.Vector3): IMarker + remove(marker: IMarker): void + clear(): void +} + +/** + * @internal * API for adding and managing sprite markers in the scene. * Uses THREE.InstancedMesh for performance. */ -export class GizmoMarkers { - private _viewer: Viewer +export class GizmoMarkers implements IGizmoMarkers { + private _renderer: Renderer + private _selection: IWebglSelection private _markers: Marker[] = [] private _mesh : THREE.InstancedMesh private _reusableMatrix = new THREE.Matrix4() @@ -18,8 +32,9 @@ export class GizmoMarkers { * Constructs the marker manager and sets up an initial instanced mesh. * @param viewer - The rendering context this marker system belongs to. */ - constructor (viewer: Viewer) { - this._viewer = viewer + constructor (renderer: Renderer, selection: IWebglSelection) { + this._renderer = renderer + this._selection = selection this._mesh = this.createMesh(undefined, 100) } @@ -28,7 +43,7 @@ export class GizmoMarkers { * @param index - The marker index. * @returns The Marker instance or undefined. */ - getMarkerFromIndex (index: number): Marker | undefined { + getMarkerFromIndex (index: number): IMarker | undefined { return this._markers[index] } @@ -38,7 +53,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({ @@ -48,7 +63,7 @@ export class GizmoMarkers { shininess: 1, transparent: false, depthTest: false - })).material + })).three const mesh = new THREE.InstancedMesh(geometry, mat, capacity) mesh.renderOrder = 100 @@ -57,7 +72,18 @@ export class GizmoMarkers { mesh.frustumCulled = false mesh.layers.enableAll() - this._viewer.renderer.add(mesh) + // 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._renderer.add(mesh) return mesh } @@ -68,14 +94,23 @@ 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._renderer.remove(this._mesh) this._mesh = larger } @@ -85,14 +120,21 @@ export class GizmoMarkers { * @param position - The world position to add the marker at. * @returns The newly created Marker. */ - add (position: THREE.Vector3): Marker { + add (position: THREE.Vector3): IMarker { if (this._mesh.count === this._mesh.instanceMatrix.count) { this.resizeMesh() } + const markerIndex = this._mesh.count this._mesh.count += 1 - const sub = new SimpleInstanceSubmesh(this._mesh, this._mesh.count - 1) - const marker = new Marker(this._viewer, sub) + + // 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._renderer, sub) marker.position = position this._markers.push(marker) return marker @@ -103,22 +145,27 @@ export class GizmoMarkers { * Uses swap-and-pop to maintain dense storage. * @param marker - The marker to remove. */ - remove (marker: Marker): void { - this._viewer.selection.remove(marker) + remove (marker: IMarker): void { + this._selection.remove(marker) const fromIndex = this._markers.length - 1 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) } @@ -127,7 +174,7 @@ export class GizmoMarkers { this._mesh.count -= 1 // Notify the renderer - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() } /** @@ -135,9 +182,9 @@ export class GizmoMarkers { */ clear (): void { // Assumes selection.remove supports arrays - this._viewer.selection.remove(this._markers) + this._selection.remove(this._markers) this._mesh.count = 0 this._markers.length = 0 - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() } } diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/index.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/index.ts index 9e89a0ee0..b908d1838 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/index.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/markers/index.ts @@ -1,2 +1,2 @@ -export * from './gizmoMarker' -export * from './gizmoMarkers' \ No newline at end of file +export type { IMarker } from './gizmoMarker' +export type { IGizmoMarkers } from './gizmoMarkers' \ No newline at end of file diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/index.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/index.ts index 488a05288..1cc48a498 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/index.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/index.ts @@ -1,3 +1 @@ -export * from './measure' -export * from './measureGizmo' -export * from './measureHtml' \ No newline at end of file +export type { IMeasure, MeasureStage } from './measure' \ No newline at end of file diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measure.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measure.ts index 7e20c85b9..935f89716 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measure.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measure.ts @@ -3,8 +3,9 @@ */ import * as THREE from 'three' -import { IRaycastResult, RaycastResult } from '../../raycaster' -import { Viewer } from '../../viewer' +import { IWebglRaycastResult } from '../../raycaster' +import { WebglViewer } from '../../viewer' +import { Renderer } from '../../rendering/renderer' import { MeasureGizmo } from './measureGizmo' import { ControllablePromise } from '../../../../../utils/promise' @@ -54,11 +55,13 @@ export interface IMeasure { export type MeasureStage = 'ready' | 'active' | 'done' | 'failed' /** + * @internal * Manages measure flow and gizmos */ export class Measure implements IMeasure { // dependencies - private _viewer: Viewer + private _viewer: WebglViewer + private _renderer: Renderer // resources private _meshes: MeasureGizmo | undefined @@ -68,7 +71,6 @@ export class Measure implements IMeasure { private _endPos: THREE.Vector3 | undefined private _measurement: THREE.Vector3 | undefined - private _previousOnClick: (pos: THREE.Vector2, ctrl: boolean ) => void private _promise : ControllablePromise | undefined private _stage : MeasureStage = 'ready' @@ -100,8 +102,9 @@ export class Measure implements IMeasure { return this._stage } - constructor (viewer: Viewer) { + constructor (viewer: WebglViewer, renderer: Renderer) { this._viewer = viewer + this._renderer = renderer } /** @@ -115,14 +118,10 @@ export class Measure implements IMeasure { this._promise = new ControllablePromise() this._stage = 'ready' - this._previousOnClick = this._viewer.inputs.mouse.onClick - this._viewer.inputs.mouse.onClick = async (pos: THREE.Vector2) => this.onClick(pos) - return this._promise.promise.finally(() => { - if (this._previousOnClick) { - this._viewer.inputs.mouse.onClick = this._previousOnClick - this._previousOnClick = undefined - } + const restore = this._viewer.inputs.mouse.override({ + onClick: async (_original, pos: THREE.Vector2, _ctrl) => this.onClick(pos) }) + return this._promise.promise.finally(restore) } private async onClick (pos: THREE.Vector2) { @@ -142,57 +141,17 @@ export class Measure implements IMeasure { } } - /** - * Should be private. - */ - onFirstClick (hit: IRaycastResult) { + private onFirstClick (hit: IWebglRaycastResult) { this.clear() - this._meshes = new MeasureGizmo(this._viewer) + this._meshes = new MeasureGizmo(this._renderer, this._viewer.viewport, this._viewer.camera) this._startPos = hit.worldPosition this._meshes.start(this._startPos) } - // onMouseMove () { - // this._meshes?.hide() - // } - - // onMouseIdle (hit: RaycastResult) { - // // Show markers and line on hit - // if (!hit.isHit) { - // this._meshes?.hide() - // return - // } - // if (hit.position && this._startPos) { - // this._measurement = hit.object - // ? hit.position.clone().sub(this._startPos) - // : undefined - // } - - // if (hit.object && hit.position && this._startPos) { - // this._meshes?.update(this._startPos, hit.position) - // } else { - // this._meshes?.hide() - // } - // } - - /** - * Should be private. - */ - onSecondClick (hit : IRaycastResult) { - // Compute measurement vector component + private onSecondClick (hit : IWebglRaycastResult) { this._endPos = hit.worldPosition - this._measurement = this._endPos.clone().sub(this._startPos) - console.log(`Distance: ${this._measurement.length()}`) - console.log( - ` - X: ${this._measurement.x}, - Y: ${this._measurement.y}, - Z: ${this._measurement.z} - ` - ) this._meshes?.finish(this._startPos, this._endPos) - return true } 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..18daffd5f 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 @@ -5,13 +5,14 @@ import * as THREE from 'three' import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer' import { MeshLine, MeshLineMaterial } from '../../../utils/meshLine' -import { Viewer } from '../../viewer' import { createMeasureElement, MeasureStyle, MeasureElement } from './measureHtml' -import { ICamera } from '../../camera/cameraInterface' +import { IWebglCamera } from '../../camera/cameraInterface' +import { Renderer } from '../../rendering/renderer' +import { IWebglViewport } from '../../viewport' import { Layers } from '../../raycaster' /** @@ -96,10 +97,10 @@ class MeasureMarker { mesh: THREE.Mesh private _material: THREE.Material private _materialAlways: THREE.Material - private _camera: ICamera + private _camera: IWebglCamera private disconnect: () => void - constructor (color: THREE.Color, camera: ICamera) { + constructor (color: THREE.Color, camera: IWebglCamera) { this._material = new THREE.MeshBasicMaterial({ color }) @@ -124,7 +125,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() } @@ -143,9 +144,11 @@ class MeasureMarker { /** * Reprents all graphical elements associated with a measure. + * @internal */ export class MeasureGizmo { - private _viewer: Viewer + private _renderer: Renderer + private _camera: IWebglCamera private _startMarker: MeasureMarker private _endMarker: MeasureMarker private _line: MeasureLine @@ -157,17 +160,18 @@ export class MeasureGizmo { private _html: MeasureElement private _animId: number | undefined - constructor (viewer: Viewer) { - this._viewer = viewer - const canvasSize = this._viewer.viewport.getSize() + constructor (renderer: Renderer, viewport: IWebglViewport, camera: IWebglCamera) { + this._renderer = renderer + this._camera = camera + const canvasSize = viewport.getSize() this._startMarker = new MeasureMarker( new THREE.Color(0xffb700), - viewer.camera + camera ) this._endMarker = new MeasureMarker( new THREE.Color(0x0590cc), - viewer.camera + camera ) this._line = new MeasureLine(canvasSize, new THREE.Color(0x000000), 'Dist') @@ -195,7 +199,7 @@ export class MeasureGizmo { this._label ) - this._viewer.renderer.add(this._group) + this._renderer.add(this._group) } private _animate () { @@ -230,7 +234,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._camera.frustumSizeAt(first).y / 2) return ratio } @@ -241,7 +245,7 @@ export class MeasureGizmo { // Set start marker this._startMarker.setPosition(start) this._startMarker.mesh.visible = true - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() } /** @@ -253,7 +257,7 @@ export class MeasureGizmo { this._line.label.visible = false } this._label.visible = false - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() } /** @@ -264,7 +268,7 @@ export class MeasureGizmo { this._line.setPoints(start, pos) this._line.mesh.visible = true } - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() } /** @@ -308,7 +312,7 @@ export class MeasureGizmo { // Start update of collapse. this._animate() - this._viewer.renderer.needsUpdate = true + this._renderer.requestRender() return true } @@ -319,7 +323,7 @@ export class MeasureGizmo { if (this._animId !== undefined) cancelAnimationFrame(this._animId) this._html.div.remove() - this._viewer.renderer.remove(this._group) + this._renderer.remove(this._group) this._startMarker.dispose() this._endMarker.dispose() diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measureHtml.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measureHtml.ts index d88407cdf..0169e48ed 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measureHtml.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/measure/measureHtml.ts @@ -4,11 +4,13 @@ /** * Different styles of measure display. + * @internal */ export type MeasureStyle = 'all' | 'Dist' | 'X' | 'Y' | 'Z' /** * Structure of the html element used for measure. + * @internal */ export type MeasureElement = { div: HTMLElement @@ -25,6 +27,7 @@ export type MeasureElement = { * Creates a html structure for measure value overlays * It either creates a single rows or all rows depending on style * Structure is a Table of Label:Value + * @internal */ export function createMeasureElement (style: MeasureStyle): MeasureElement { const div = document.createElement('div') diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/SectionBoxMesh.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/SectionBoxMesh.ts index f735e6b20..c67479f69 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/SectionBoxMesh.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/SectionBoxMesh.ts @@ -5,6 +5,7 @@ import { Layers } from '../../raycaster'; * Defines the box mesh for the section box. */ +/** @internal */ export class SectionBoxMesh extends THREE.Mesh { constructor() { const geo = new THREE.BoxGeometry(); diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/index.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/index.ts index e4e193f4d..ad9bf7661 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/index.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/index.ts @@ -1,7 +1 @@ -export * from './sectionBox'; -export * from './sectionBoxGizmo'; -export * from './sectionBoxHandle'; -export * from './sectionBoxHandles'; -export * from './sectionBoxInputs'; -export * from './SectionBoxMesh'; -export * from './sectionBoxOutline'; \ No newline at end of file +export type { IWebglSectionBox } from './sectionBox' \ No newline at end of file diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBox.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBox.ts index 78cae892c..a836ba04c 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBox.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBox.ts @@ -2,33 +2,69 @@ * @module viw-webgl-viewer/gizmos/sectionBox */ -import { Viewer } from '../../viewer'; +import { WebglViewer } from '../../viewer'; +import { Renderer } from '../../rendering/renderer'; import * as THREE from 'three'; import { BoxInputs } from './sectionBoxInputs'; +import type { ISignal } from '../../../../shared/events'; import { SignalDispatcher } from 'ste-signals'; +import type { ISimpleEvent } from '../../../../shared/events'; import { SimpleEventDispatcher } from 'ste-simple-events'; import { SectionBoxGizmo } from './sectionBoxGizmo'; import { safeBox } from '../../../../../utils/threeUtils'; /** + * Public interface for the section box gizmo. + * + * @example + * ```ts + * const sb = viewer.gizmos.sectionBox + * sb.active = true // Enable clipping + * sb.visible = true // Show gizmo + * sb.setBox(await vim.getBoundingBox()) // Fit to model + * sb.onBoxConfirm.sub((box) => console.log('Confirmed:', box)) + * ``` + */ +export interface IWebglSectionBox { + /** Dispatches when active, visible, or interactive change. */ + readonly onStateChanged: ISignal + /** Dispatches when the user finishes manipulating the box. */ + readonly onBoxConfirm: ISimpleEvent + /** Dispatches boolean indicating pointer hover state on box handles. */ + readonly onHover: ISimpleEvent + /** Returns a copy of the current section box. */ + getBox(): THREE.Box3 + /** Whether clipping planes are applied to the model. */ + active: boolean + /** Whether the gizmo responds to pointer events. */ + interactive: boolean + /** Whether the section box gizmo is visible. */ + visible: boolean + /** Resizes the section gizmo to match the given box. */ + setBox(box: THREE.Box3): void +} + +/** + * @internal * Manages a section box gizmo, serving as a proxy between the renderer and the user. - * + * * This class: * - Maintains a Three.js `Box3` that defines the clipping region. * - Handles user interaction via {@link BoxInputs}. * - Updates a {@link SectionBoxGizmo} to visualize the clipping box. * - Dispatches signals when the box is resized or interaction state changes. */ -export class SectionBox { +export class SectionBox implements IWebglSectionBox { // ------------------------------------------------------------------------- // Private fields // ------------------------------------------------------------------------- - private _viewer: Viewer; + private _renderer: Renderer; + private _viewer: WebglViewer; private _gizmos: SectionBoxGizmo; private _inputs: BoxInputs; - private _clip: boolean | undefined = undefined; + private _active: boolean | undefined = undefined; private _visible: boolean | undefined = undefined; private _interactive: boolean | undefined = undefined; @@ -36,20 +72,12 @@ export class SectionBox { private _onBoxConfirm = new SimpleEventDispatcher(); private _onHover = new SimpleEventDispatcher(); - /** - * @internal - * A convenience getter to the viewer's renderer. - */ private get renderer() { - return this._viewer.renderer; + return this._renderer; } - /** - * @internal - * A convenience getter to the `Section` module in the renderer. - */ private get section() { - return this._viewer.renderer.section; + return this._renderer.section; } // ------------------------------------------------------------------------- @@ -58,7 +86,7 @@ export class SectionBox { /** * Dispatches when any of the following properties change: - * - {@link clip} (clipping planes active) + * - {@link active} (clipping planes active) * - {@link visible} (gizmo visibility) * - {@link interactive} (pointer inputs active) */ @@ -90,22 +118,23 @@ export class SectionBox { /** * Creates a new SectionBox gizmo controller. * - * @param viewer - The parent {@link Viewer} in which the section box is rendered. + * @param viewer - The parent {@link WebglViewer} in which the section box is rendered. */ - constructor(viewer: Viewer) { + constructor(renderer: Renderer, viewer: WebglViewer) { + this._renderer = renderer; this._viewer = viewer; - this._gizmos = new SectionBoxGizmo(viewer.renderer, viewer.camera); + this._gizmos = new SectionBoxGizmo(renderer, viewer.camera); this._inputs = new BoxInputs( viewer, this._gizmos.handles, - this._viewer.renderer.section.box + this._renderer.section.box ); // When the pointer enters/leaves a face, dispatch hover state. this._inputs.onFaceEnter = (normal) => { this._onHover.dispatch(normal.x !== 0 || normal.y !== 0 || normal.z !== 0); - this.renderer.needsUpdate = true; + this.renderer.requestRender(); }; // When user drags the box, resize and update. @@ -118,7 +147,7 @@ export class SectionBox { this._inputs.onBoxConfirm = (box) => this._onBoxConfirm.dispatch(box); // Default states - this.clip = false; + this.active = false; this.visible = false; this.interactive = false; this.update(); @@ -138,16 +167,16 @@ export class SectionBox { /** * Determines whether the section gizmo applies clipping planes to the model. - * + * * When `true`, `renderer.section.active` is enabled. */ - get clip(): boolean { - return this._clip ?? false; + get active(): boolean { + return this._active ?? false; } - set clip(value: boolean) { - if (value === this._clip) return; - this._clip = value; + set active(value: boolean) { + if (value === this._active) return; + this._active = value; this.renderer.section.active = value; this._onStateChanged.dispatch(); } @@ -172,7 +201,7 @@ export class SectionBox { } this._interactive = value; - this.renderer.needsUpdate = true; + this.renderer.requestRender(); this._onStateChanged.dispatch(); } @@ -190,7 +219,7 @@ export class SectionBox { if (value) { this.update(); } - this.renderer.needsUpdate = true; + this.renderer.requestRender(); this._onStateChanged.dispatch(); } @@ -212,7 +241,7 @@ export class SectionBox { this._gizmos.fitBox(box); this.renderer.section.fitBox(box); this._onBoxConfirm.dispatch(box); - this.renderer.needsUpdate = true; + this.renderer.requestRender(); } /** @@ -222,7 +251,7 @@ export class SectionBox { */ public update(): void { this.setBox(this.section.box); - this.renderer.needsUpdate = true; + this.renderer.requestRender(); } /** diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxGizmo.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxGizmo.ts index 060ed0a95..b67589490 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxGizmo.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxGizmo.ts @@ -3,8 +3,9 @@ import { SectionBoxMesh } from './SectionBoxMesh' import { SectionBoxOutline } from './sectionBoxOutline' import { SectionBoxHandles } from './sectionBoxHandles' import { Renderer } from '../../rendering/renderer' -import { ICamera } from '../../camera' +import { IWebglCamera } from '../../camera' +/** @internal */ export class SectionBoxGizmo { private _renderer: Renderer @@ -22,7 +23,7 @@ export class SectionBoxGizmo this.handles.visible = value } - constructor(renderer: Renderer, camera: ICamera) + constructor(renderer: Renderer, camera: IWebglCamera) { this._renderer = renderer this.cube = new SectionBoxMesh() 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..f235917c0 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 @@ -1,8 +1,10 @@ import * as THREE from 'three'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils'; -import { ICamera } from '../../camera'; +import { IWebglCamera } from '../../camera'; +/** @internal */ export type AxisName = 'x' | 'y' | 'z'; +/** @internal */ export class SectionBoxHandle extends THREE.Mesh { readonly axis : AxisName readonly sign: number; @@ -13,7 +15,7 @@ export class SectionBoxHandle extends THREE.Mesh { private _materials: THREE.MeshBasicMaterial[]; - private _camera : ICamera | undefined + private _camera : IWebglCamera | undefined private _camSub : () => void constructor(axes: AxisName, sign: number, size: number, color?: THREE.Color) { @@ -52,7 +54,7 @@ export class SectionBoxHandle extends THREE.Mesh { this.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), this._forward); } - trackCamera(camera: ICamera) { + trackCamera(camera: IWebglCamera) { this._camera = camera this.update() this._camSub = camera.onMoved.subscribe(() => this.update()); @@ -60,7 +62,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/gizmos/sectionBox/sectionBoxHandles.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxHandles.ts index ba2b3991c..3f39cce06 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxHandles.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxHandles.ts @@ -4,8 +4,9 @@ import * as THREE from 'three' import { SectionBoxHandle } from './sectionBoxHandle' -import { ICamera } from '../../camera' +import { IWebglCamera } from '../../camera' +/** @internal */ export class SectionBoxHandles { readonly up: SectionBoxHandle readonly down: SectionBoxHandle @@ -16,7 +17,7 @@ export class SectionBoxHandles { readonly meshes : THREE.Group - constructor(camera: ICamera){ + constructor(camera: IWebglCamera){ const size = 2 this.up = new SectionBoxHandle('y', 1, size, new THREE.Color(0x00ff00)) this.down = new SectionBoxHandle('y', -1, size, new THREE.Color(0x00ff00)) 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..79016aeaa 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 @@ -2,7 +2,7 @@ * @module viw-webgl-viewer/gizmos/sectionBox */ -import { Viewer } from '../../viewer'; +import { WebglViewer } from '../../viewer'; import * as THREE from 'three'; import { SectionBoxHandles } from './sectionBoxHandles'; import { AxisName, SectionBoxHandle } from './sectionBoxHandle'; @@ -14,14 +14,15 @@ const MIN_BOX_SIZE = 3; * Manages pointer interactions (mouse, touch, etc.) on a {@link SectionBoxHandles} to * reshape a Three.js `Box3`. This includes detecting which handle is hovered or dragged, * capturing the pointer for smooth dragging, and enforcing a minimum box size. + * @internal */ export class BoxInputs { // ------------------------------------------------------------------------- // Dependencies and shared resources // ------------------------------------------------------------------------- - /** The parent Viewer controlling the scene. */ - private _viewer: Viewer; + /** The parent WebglViewer controlling the scene. */ + private _viewer: WebglViewer; /** The handles mesh group containing the draggable cones/faces. */ private _handles: SectionBoxHandles; @@ -51,8 +52,8 @@ export class BoxInputs { /** The box state before the current drag. */ private _lastBox: THREE.Box3 = new THREE.Box3(); - /** A callback to restore the original input listeners after unregistering. */ - private _restoreOriginalInputs: () => void; + /** Restores mouse overrides when unregistering. */ + private _restore: (() => void) | undefined; // ------------------------------------------------------------------------- // Callbacks @@ -83,11 +84,11 @@ export class BoxInputs { /** * Creates a new BoxInputs instance for pointer-driven box resizing. * - * @param viewer - The parent {@link Viewer} that renders the scene. + * @param viewer - The parent {@link WebglViewer} that renders the scene. * @param handles - A {@link SectionBoxHandles} instance containing the draggable mesh handles. * @param box - The shared bounding box (`Box3`) that will be updated by dragging. */ - constructor(viewer: Viewer, handles: SectionBoxHandles, box: THREE.Box3) { + constructor(viewer: WebglViewer, handles: SectionBoxHandles, box: THREE.Box3) { this._viewer = viewer; this._handles = handles; this._sharedBox = box; @@ -102,40 +103,14 @@ export class BoxInputs { * If already registered, it does nothing. */ public register(): void { - if(this._restoreOriginalInputs) return; // Don't register twice - - const mouse = this._viewer.inputs.mouse; - - const up = mouse.onButtonUp - const down = mouse.onButtonDown - const move = mouse.onMouseMove - const drag = mouse.onDrag - - this._restoreOriginalInputs = () => { - mouse.onButtonUp = up - mouse.onButtonDown = down - mouse.onMouseMove = move - mouse.onDrag = drag - } - - mouse.onButtonUp = (pos, btn) => { - up(pos, btn) - this.onMouseUp(pos) - } - mouse.onButtonDown = (pos, btn) => { - down(pos, btn) - this.onMouseDown(pos) - } - - mouse.onMouseMove = (pos) => { - move(pos) - this.onMouseMove(pos) - } - - mouse.onDrag = (pos, btn) => { - if(this._handle) return - drag(pos, btn) - } + if(this._restore) return; // Don't register twice + + this._restore = this._viewer.inputs.mouse.override({ + onPointerUp: (original, pos, btn) => { original(pos, btn); this.onMouseUp(pos) }, + onPointerDown: (original, pos, btn) => { original(pos, btn); this.onMouseDown(pos) }, + onPointerMove: (original, pos) => { original(pos); this.onMouseMove(pos) }, + onDrag: (original, delta, btn) => { if(this._handle) return; original(delta, btn) }, + }) } /** @@ -147,8 +122,8 @@ export class BoxInputs { this._handle?.highlight(false); this._handle = undefined; - this._restoreOriginalInputs?.(); - this._restoreOriginalInputs = undefined + this._restore?.() + this._restore = undefined } // ------------------------------------------------------------------------- diff --git a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxOutline.ts b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxOutline.ts index 6511f812b..f79ed56eb 100644 --- a/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxOutline.ts +++ b/src/vim-web/core-viewers/webgl/viewer/gizmos/sectionBox/sectionBoxOutline.ts @@ -5,6 +5,7 @@ import { Layers } from '../../raycaster'; * Defines the thin outline on the edges of the section box. */ +/** @internal */ export class SectionBoxOutline extends THREE.LineSegments { constructor(color : THREE.Color) { // prettier-ignore diff --git a/src/vim-web/core-viewers/webgl/viewer/index.ts b/src/vim-web/core-viewers/webgl/viewer/index.ts index 7c9b2750b..8d206ba91 100644 --- a/src/vim-web/core-viewers/webgl/viewer/index.ts +++ b/src/vim-web/core-viewers/webgl/viewer/index.ts @@ -1,19 +1,29 @@ +// Viewer interface (concrete class is internal) +export type { IWebglViewer } from './viewer' +export { createCoreWebglViewer } from './viewer' -// Full export -export * from './viewer'; -export * from './settings'; +// Settings +export type { ViewerSettings, PartialViewerSettings, MaterialSettings } from './settings' -// Partial export -export {Layers} from './raycaster'; +// Camera +export type { IWebglCamera, ICameraMovement } from './camera' -// Type only -export type * from './environment'; -export type * from './gizmos'; -export type * from './raycaster'; -export type * from './selection'; -export type * from './viewport'; -export type * from './rendering'; -export type * from './camera'; +// Rendering +export type { IWebglRenderer, IRenderingSection } from './rendering' -// Not exported -// export * from './inputsAdapter'; +// Selection +export { isElement3D } from './selection' +export type { ISelectable, IWebglSelection } from './selection' + +// Viewport +export type { IWebglViewport } from './viewport' + +// Raycaster +export type { IWebglRaycaster, IWebglRaycastResult } from './raycaster' + +// Gizmos +export type { IGizmos, IGizmoOrbit } from './gizmos' +export type { IGizmoAxes, AxesSettings } from './gizmos' +export type { IMarker, IGizmoMarkers } from './gizmos' +export type { IMeasure, MeasureStage } from './gizmos' +export type { IWebglSectionBox } from './gizmos' diff --git a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts index 2852e8bca..38e6714ae 100644 --- a/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts +++ b/src/vim-web/core-viewers/webgl/viewer/inputAdapter.ts @@ -1,9 +1,11 @@ -import {type IInputAdapter} from "../../shared/inputAdapter" -import {InputHandler, PointerMode} from "../../shared/inputHandler" -import { Viewer } from "./viewer" +import {type IInputAdapter} from "../../shared/input/inputAdapter" +import {InputHandler} from "../../shared/input/inputHandler" +import { WebglViewer } from "./viewer" +import { Element3D } from '../loader/element3d' import * as THREE from 'three' -export function createInputHandler(viewer: Viewer) { +/** @internal */ +export function createInputHandler(viewer: WebglViewer) { return new InputHandler( viewer.viewport.canvas, createAdapter(viewer), @@ -11,7 +13,10 @@ export function createInputHandler(viewer: Viewer) { ) } -function createAdapter(viewer: Viewer ) : IInputAdapter { +function createAdapter(viewer: WebglViewer ) : IInputAdapter { + let _pinchWorldPoint: THREE.Vector3 | undefined + let _pinchStartDist = 0 + return { init: () => {}, orbitCamera: (value: THREE.Vector2) => { @@ -21,23 +26,18 @@ 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().move2(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().move1(dist, 'Y') + viewer.camera.snap().move('Y', dist, 'local') }, 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() @@ -58,30 +58,65 @@ 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) + viewer.selection.add(result?.object) } else{ - viewer.selection.select(result.object) + 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 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: (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.25).zoomTowards(value, result.worldPosition, screenPos) + return + } + // No hit: zoom in the direction of the cursor without updating target + const dir = viewer.camera.screenToDirection(screenPos) + const displacement = dir.multiplyScalar(viewer.camera.orbitDistance * (1 - 1 / value)) + viewer.camera.lerp(0.25).move('XYZ', displacement, 'world') + return + } viewer.camera.lerp(0.75).zoom(value) }, moveCamera: (value : THREE.Vector3) => { 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: () => {}, - mouseMove: () => {}, - mouseUp: () => {}, + pointerDown: () => {}, + pointerMove: () => {}, + pointerUp: () => {}, } } diff --git a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts index eb921e80a..419f84473 100644 --- a/src/vim-web/core-viewers/webgl/viewer/raycaster.ts +++ b/src/vim-web/core-viewers/webgl/viewer/raycaster.ts @@ -4,35 +4,39 @@ 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' -import { Marker } from './gizmos/markers/gizmoMarker' +import { type IMarker } from './gizmos/markers/gizmoMarker' import { GizmoMarkers } from './gizmos/markers/gizmoMarkers' import type { IRaycaster as IRaycasterBase, IRaycastResult as IRaycastResultBase, -} from '../../shared' +} from '../../shared/raycaster' import { Validation } from '../../../utils' +import type { ISelectable } from './selection' /** + * @internal * Type alias for an array of THREE.Intersection objects. */ export type ThreeIntersectionList = THREE.Intersection>[] -export type RaycastableObject = Element3D | Marker -export type IRaycastResult = IRaycastResultBase -export type IRaycaster = IRaycasterBase +export type IWebglRaycastResult = IRaycastResultBase +export type IWebglRaycaster = IRaycasterBase +/** @internal */ export enum Layers { Default = 0, NoRaycast = 1, } /** + * @internal * A simple container for raycast results. */ -export class RaycastResult implements IRaycastResult { - object: Element3D | Marker | undefined +export class RaycastResult implements IWebglRaycastResult { + object: Element3D | IMarker | undefined intersections: ThreeIntersectionList firstHit: THREE.Intersection | undefined @@ -44,7 +48,7 @@ export class RaycastResult implements IRaycastResult { return this.firstHit?.point } - constructor(intersections: ThreeIntersectionList, firstHit?: THREE.Intersection, object?: Element3D | Marker) { + constructor(intersections: ThreeIntersectionList, firstHit?: THREE.Intersection, object?: Element3D | IMarker) { this.intersections = intersections this.firstHit = firstHit this.object = object @@ -52,9 +56,12 @@ export class RaycastResult implements IRaycastResult { } /** - * Performs raycasting operations. + * @internal + * 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 { +export class Raycaster implements IWebglRaycaster { private _camera: Camera private _scene: RenderScene private _renderer: Renderer @@ -104,7 +111,7 @@ export class Raycaster implements IRaycaster { * Processes the list of intersections to determine the first valid hit. * It first checks for a marker hit, then for a model object hit. */ - private processIntersections(intersections: ThreeIntersectionList): { firstHit?: THREE.Intersection, object?: Element3D | Marker } { + private processIntersections(intersections: ThreeIntersectionList): { firstHit?: THREE.Intersection, object?: Element3D | IMarker } { // Check for marker hit first for (let i = 0; i < intersections.length; i++) { const userData = intersections[i].object.userData.vim @@ -130,11 +137,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 } @@ -148,6 +155,7 @@ export class Raycaster implements IRaycaster { } /** + * @internal * Converts normalized screen coordinates (0-1 range) into Three.js NDC ([-1, 1] range). */ export function threeNDCFromVector2(position: THREE.Vector2): THREE.Vector2 { 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..b535bd022 --- /dev/null +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/gpuPicker.ts @@ -0,0 +1,409 @@ +/** + * @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 { type IElement3D } from '../../loader/element3d' +import { Vim } from '../../loader/vim' +import { VimCollection } from '../../../shared/vimCollection' +import type { IRaycaster, IRaycastResult } from '../../../shared/raycaster' +import { Layers } from '../raycaster' +import { type IMarker } from '../gizmos/markers/gizmoMarker' +import type { GizmoMarkers } from '../gizmos/markers/gizmoMarkers' +import type { ISelectable } from '../selection' + +/** + * @internal + * Reserved vimIndex for marker gizmos in GPU picking. + * Markers use this index to distinguish them from vim elements. + */ +export const MARKER_VIM_INDEX = 255 + +/** + * @internal + * 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) +} + +/** + * @internal + * Unpacks vimIndex and elementIndex from a packed uint32. + */ +export function unpackPickingId(packedId: number): { vimIndex: number; elementIndex: number } { + return { + vimIndex: packedId >>> 24, + elementIndex: packedId & 0xFFFFFF + } +} + +/** + * @internal + * 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 world position of the hit */ + readonly worldPosition: THREE.Vector3 + /** The world normal at the hit point */ + readonly worldNormal: THREE.Vector3 + + private _elementIndex: number + private _vim: Vim | undefined + private _marker: IMarker | undefined + + constructor( + elementIndex: number, + worldPosition: THREE.Vector3, + worldNormal: THREE.Vector3, + vim: Vim | undefined, + marker?: IMarker + ) { + this._elementIndex = elementIndex + this.worldPosition = worldPosition + this.worldNormal = worldNormal + this._vim = vim + this._marker = marker + } + + /** + * The object property for IRaycastResult interface. + * Returns the Element3D or IMarker for the picked object. + */ + get object(): ISelectable | undefined { + return this._marker ?? this.getElement() + } + + /** + * Gets the Element3D object for the picked element. + * @returns The Element3D object, or undefined if not found or if this is a marker hit + */ + getElement(): IElement3D | undefined { + return this._vim?.getElementFromIndex(this._elementIndex) + } + + /** + * Gets the IMarker object if this is a marker hit. + * @returns The IMarker object, or undefined if this is an element hit + */ + getMarker(): IMarker | undefined { + return this._marker + } +} + +/** + * @internal + * 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 = 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 + private _camera: Camera + private _scene: RenderScene + private _vims: VimCollection + private _markers: GizmoMarkers | undefined + private _section: RenderingSection + + private _renderTarget: THREE.WebGLRenderTarget + private _pickingMaterial: PickingMaterial + private _readBuffer: Float32Array + + // Debug visualization + debug = false + private _debugSphere: THREE.Mesh | undefined + private _debugLine: THREE.Line | undefined + + constructor( + renderer: THREE.WebGLRenderer, + camera: Camera, + scene: RenderScene, + vims: VimCollection, + section: RenderingSection, + width: number, + height: number + ) { + 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 + 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) + } + + /** + * 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. + */ + 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.three + + // Disable NoRaycast layer to hide skybox and gizmos + camera.layers.disable(Layers.NoRaycast) + + // 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(Layers.NoRaycast) + 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 = 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] + + // Check if hit (depth <= 0 means background/no hit) + if (depth <= 0) { + return undefined + } + + // Reinterpret float bits as uint32 and unpack vimIndex/elementIndex + const dataView = new DataView(this._readBuffer.buffer) + const packedId = dataView.getUint32(0, true) // little-endian + 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)) + const worldNormal = new THREE.Vector3(normalX, normalY, normalZ).normalize() + + // 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, 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) + + const result = new GpuPickResult(elementIndex, 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(Layers.NoRaycast) + 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(Layers.NoRaycast) + this._scene.threeScene.add(this._debugLine) + + // Request re-render + this._renderer.domElement.dispatchEvent(new Event('needsUpdate')) + } + + /** + * 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 + } + + /** + * Removes debug visuals (sphere and normal line) from the scene. + */ + 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 + } + } + + /** + * 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)) + } + + /** + * 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. + */ + dispose(): void { + this.clearDebugVisuals() + this._renderTarget.dispose() + this._pickingMaterial.dispose() + } +} diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/index.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/index.ts index 7a37a81b3..5a5fc2410 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/index.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/index.ts @@ -1,14 +1,2 @@ - -// Full export -// None - -// Type export -export type * from './renderingSection'; -export type * from './renderer'; -export type * from './renderScene'; -export type * from './renderingComposer'; - -// Not exported -// export * from './transferPass'; -// export * from './mergePass'; -// export * from './outlinePass'; \ No newline at end of file +export type { IWebglRenderer } from './renderer' +export type { IRenderingSection } from './renderingSection' \ No newline at end of file diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/mergePass.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/mergePass.ts index 11dfe3212..370b4dafb 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/mergePass.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/mergePass.ts @@ -8,6 +8,7 @@ import { Materials } from '../../loader/materials/materials' import { MergeMaterial } from '../../loader/materials/mergeMaterial' /** + * @internal * Merges a source buffer into the the current write buffer. */ export class MergePass extends Pass { @@ -18,8 +19,8 @@ export class MergePass extends Pass { super() this._fsQuad = new FullScreenQuad() - this._material = materials?.merge ?? new MergeMaterial() - this._fsQuad.material = this._material.material + this._material = materials?.system.merge ?? new MergeMaterial() + this._fsQuad.material = this._material.three this._material.sourceA = source this.needsSwap = true } @@ -43,12 +44,7 @@ export class MergePass extends Pass { readBuffer: THREE.WebGLRenderTarget ) { this._material.sourceB = readBuffer.texture - // 2. Draw the outlines using the depth texture and normal texture - // and combine it with the scene color if (this.renderToScreen) { - // If this is the last effect, then renderToScreen is true. - // So we should render to the screen by setting target null - // Otherwise, just render into the writeBuffer that the next effect will use as its read buffer. renderer.setRenderTarget(null) this._fsQuad.render(renderer) } else { diff --git a/src/vim-web/core-viewers/webgl/viewer/rendering/outlinePass.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/outlinePass.ts index fdd809271..91dcbc645 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/outlinePass.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/outlinePass.ts @@ -10,6 +10,7 @@ import { OutlineMaterial } from '../../loader/materials/outlineMaterial' // https://github.com/mrdoob/three.js/blob/master/examples/jsm/postprocessing/OutlinePass.js // Based on https://github.com/OmarShehata/webgl-outlines/blob/cf81030d6f2bc20e6113fbf6cfd29170064dce48/threejs/src/CustomOutlinePass.js /** + * @internal * Edge detection pass on the current readbuffer depth texture. */ export class OutlinePass extends Pass { @@ -24,7 +25,7 @@ export class OutlinePass extends Pass { this.material = material ?? new OutlineMaterial() this.material.camera = camera - this._fsQuad = new FullScreenQuad(this.material.material) + this._fsQuad = new FullScreenQuad(this.material.three) this.needsSwap = true } @@ -57,12 +58,7 @@ export class OutlinePass extends Pass { this.material.depthBuffer = readBuffer.depthTexture this.material.sceneBuffer = readBuffer.texture - // 2. Draw the outlines using the depth texture and normal texture - // and combine it with the scene color if (this.renderToScreen) { - // If this is the last effect, then renderToScreen is true. - // So we should render to the screen by setting target null - // Otherwise, just render into the writeBuffer that the next effect will use as its read buffer. renderer.setRenderTarget(null) this._fsQuad.render(renderer) } else { 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..acf30eac2 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderScene.ts @@ -5,10 +5,13 @@ import * as THREE from 'three' import { Scene } from '../../loader/scene' import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer' -import { ModelMaterial } from '../../loader/materials/materials' +import { Materials } from '../../loader/materials/materials' +import { MaterialSet } from '../../loader/materials/materialSet' import { InstancedMesh } from '../../loader/progressive/instancedMesh' +import { MAX_VIMS } from '../../../shared/vimCollection' /** + * @internal * Wrapper around the THREE scene that tracks bounding box and other information. */ export class RenderScene { @@ -18,25 +21,26 @@ export class RenderScene { boxUpdated = false // public value - smallGhostThreshold: number | undefined = 10 + smallGhostThreshold: number = 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 private _outlineCount = 0 - private _modelMaterial: ModelMaterial + private _modelMaterial: MaterialSet get meshes() { - return this._vimScenes.flatMap((s) => s.meshes) + return this._vimScenesById + .filter((s): s is Scene => s !== undefined) + .flatMap((s) => s.meshes) } constructor () { this.threeScene = new THREE.Scene() - } - - get estimatedMemory () { - return this._memory + // Initialize with simple material (fast mode) - will be overridden by isolation system + const m = Materials.getInstance() + this._modelMaterial = new MaterialSet(m.modelOpaqueMaterial, m.modelTransparentMaterial) } has2dObjects () { @@ -45,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() + } } /** @@ -63,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 } @@ -124,25 +131,25 @@ export class RenderScene { */ clear () { this.threeScene.clear() + this._vimScenesById.fill(undefined) this._boundingBox = undefined - this._memory = 0 } get modelMaterial() { return this._modelMaterial } - set modelMaterial(material: ModelMaterial) { + set modelMaterial(material: MaterialSet) { 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() } private updateInstanceMeshVisibility(){ - const hide = this._modelMaterial?.[1]?.userData.isGhost === true + const hide = this._modelMaterial?.hidden?.userData.isGhost === true for(const mesh of this.meshes){ if(mesh instanceof InstancedMesh){ @@ -151,7 +158,7 @@ export class RenderScene { continue } // Check if any submesh is visible - const visible = mesh.getSubmeshes().some((m) => + const visible = mesh.getSubmeshes().some((m) => m.object.visible ) mesh.mesh.visible = !(hide && !visible && mesh.size < this.smallGhostThreshold) @@ -160,15 +167,15 @@ 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?.vimIndex ?? 0 + this._vimScenesById[vimIndex] = scene + scene.meshes.forEach((m) => { this.threeScene.add(m.mesh) }) this.updateBox(scene.getBoundingBox()) - - // Memory - this._memory += scene.getMemory() } updateBox (box: THREE.Box3 | undefined) { @@ -178,21 +185,22 @@ 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?.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 - this._memory -= scene.getMemory() } } 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..64465fa47 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderer.ts @@ -3,26 +3,72 @@ */ import * as THREE from 'three' -import { IRenderer, Scene } from '../../loader/scene' +import { ISceneRenderer, Scene } from '../../loader/scene' import { Viewport } from '../viewport' import { RenderScene } from './renderScene' -import { ModelMaterial, Materials } from '../../loader/materials/materials' +import { MaterialSet, Materials } from '../../loader/materials/materials' import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer' import { Camera } from '../camera/camera' -import { RenderingSection } from './renderingSection' +import { IRenderingSection, RenderingSection } from './renderingSection' import { RenderingComposer } from './renderingComposer' import { ViewerSettings } from '../settings/viewerSettings' +import type { ISignal } from '../../../shared/events' import { SignalDispatcher } from 'ste-signals' /** + * Public interface for the WebGL renderer. + * Exposes only the members needed by API consumers. + */ +export interface IWebglRenderer { + /** The THREE WebGL renderer. */ + readonly three: THREE.WebGLRenderer + /** Interface to interact with section box directly without using the gizmo. */ + readonly section: IRenderingSection + /** Whether a re-render has been requested for the current frame. */ + readonly needsUpdate: boolean + /** + * Flags the scene as dirty so it will be re-rendered on the next animation frame. + * Selection, visibility, camera, and material changes call this automatically. + * Only needed when making direct Three.js changes the renderer can't detect. + */ + requestRender(): void + /** + * Immediately renders the current frame. + * For screenshots: call `requestRender()`, then `render()`, then read `canvas.toDataURL()`. + */ + render(): void + /** Gets or sets the background color or texture of the scene. */ + background: THREE.Color | THREE.Texture + /** Gets or sets the material used to render models. */ + modelMaterial: MaterialSet + /** The target MSAA sample count. Higher = better quality. */ + samples: number + /** Scale factor for outline/selection render target resolution (0-1). */ + outlineScale: number + /** Signal dispatched once per render frame if the scene was updated. */ + readonly onSceneUpdated: ISignal + /** Signal dispatched when bounding box is updated. */ + readonly onBoxUpdated: ISignal + /** Whether text rendering is enabled. */ + textEnabled: boolean + /** Instance count below which ghosted meshes are hidden entirely. */ + smallGhostThreshold: number + /** Returns the bounding box encompassing all rendered objects. */ + getBoundingBox(target?: THREE.Box3): THREE.Box3 | undefined + /** When true (default), only renders when dirty (`requestRender()` was called). When false, renders every frame. */ + autoRender: boolean +} + +/** + * @internal * Manages how vim objects are added and removed from the THREE.Scene to be rendered */ -export class Renderer implements IRenderer { +export class Renderer implements ISceneRenderer { /** * The THREE WebGL renderer. */ - readonly renderer: THREE.WebGLRenderer + readonly three: THREE.WebGLRenderer /** * The THREE sample ui renderer @@ -34,11 +80,6 @@ export class Renderer implements IRenderer { */ readonly section: RenderingSection - /** - * Determines whether antialias will be applied to rendering or not. - */ - antialias: boolean = true - private _scene: RenderScene private _viewport: Viewport private _camera: Camera @@ -52,27 +93,27 @@ export class Renderer implements IRenderer { private _onBoxUpdated = new SignalDispatcher() private _sceneUpdated = false - // 3GB - private maxMemory = 3 * Math.pow(10, 9) private _outlineCount = 0 - /** - * Indicates whether the scene should be re-rendered on change only. + * When true, the renderer only renders when a render has been requested. + * When false, the renderer renders every frame. */ - onDemand: boolean - + autoRender: boolean /** - * Indicates whether the scene needs to be re-rendered. - * Can only be set to true. Cleared on each render. + * Whether a re-render has been requested for the current frame. + * Cleared automatically after each render frame. */ get needsUpdate () { return this._needsUpdate } - set needsUpdate (value: boolean) { - this._needsUpdate = this._needsUpdate || value + /** + * Requests a re-render on the next frame. + */ + requestRender () { + this._needsUpdate = true } constructor ( @@ -87,10 +128,23 @@ export class Renderer implements IRenderer { this._materials = materials this._camera = camera - this.renderer = new THREE.WebGLRenderer({ + // Force WebGL 2 context + const context = viewport.canvas.getContext('webgl2', { + alpha: true, + antialias: true, + stencil: false, + powerPreference: 'high-performance' + }) + + if (!context) { + throw new Error('WebGL 2 is not supported by this browser') + } + + this.three = new THREE.WebGLRenderer({ canvas: viewport.canvas, + context: context, antialias: true, - precision: 'highp', + precision: 'highp', alpha: true, stencil: false, powerPreference: 'high-performance', @@ -98,27 +152,28 @@ export class Renderer implements IRenderer { }) - this.onDemand = settings.rendering.onDemand + this.autoRender = settings.rendering.autoRender this.textRenderer = this._viewport.textRenderer this.textEnabled = true this._composer = new RenderingComposer( - this.renderer, + this.three, scene, viewport, materials, camera ) + this.outlineScale = settings.materials.outline.scale this.section = new RenderingSection(this, this._materials) this.fitViewport() this._viewport.onResize.subscribe(() => this.fitViewport()) this._camera.onSettingsChanged.sub(() => { this._composer.camera = this._camera.three - this.needsUpdate = true + this._needsUpdate = true }) - this._materials.onUpdate.sub(() => (this.needsUpdate = true)) + this._materials.onUpdate.sub(() => (this._needsUpdate = true)) this.background = settings.background.color } @@ -128,9 +183,9 @@ export class Renderer implements IRenderer { dispose () { this.clear() - this.renderer.clear() - this.renderer.forceContextLoss() - this.renderer.dispose() + this.three.clear() + this.three.forceContextLoss() + this.three.dispose() this._composer.dispose() } @@ -143,27 +198,24 @@ export class Renderer implements IRenderer { set background (color: THREE.Color | THREE.Texture) { this._scene.threeScene.background = color - this.needsUpdate = true + this._needsUpdate = true } /** - * Sets the material used to render models. If set to undefined, the default model or mesh material is used. + * Sets the material used to render models. */ get modelMaterial () { return this._scene.modelMaterial } - set modelMaterial (material: ModelMaterial) { - this._scene.modelMaterial = material ?? this.defaultModelMaterial + set modelMaterial (material: MaterialSet) { + this._scene.modelMaterial = material } /** - * The material that will be used when setting model material to undefined. - */ - defaultModelMaterial: ModelMaterial - - /** - * Signal dispatched at the end of each frame if the scene was updated, such as visibility changes. + * Signal dispatched once per render frame if the scene was updated (e.g. visibility changes). + * Fires during `render()`, not when `notifySceneUpdate()` is called, + * so multiple updates within a frame are coalesced into a single dispatch. */ get onSceneUpdated () { return this._onSceneUpdate.asEvent() @@ -185,11 +237,16 @@ export class Renderer implements IRenderer { set textEnabled (value: boolean) { if (value === this._renderText) return - this.needsUpdate = true + this._needsUpdate = true this._renderText = value this.textRenderer.domElement.style.display = value ? 'block' : 'none' } + /** + * Instance count below which ghosted meshes are hidden entirely. + * Set to -1 to disable (show all ghosted meshes regardless of size). + * @default 10 + */ get smallGhostThreshold(){ return this._scene.smallGhostThreshold } @@ -220,17 +277,17 @@ export class Renderer implements IRenderer { */ notifySceneUpdate () { this._sceneUpdated = true - this.needsUpdate = true + this._needsUpdate = true } addOutline () { this._outlineCount++ - this.needsUpdate = true + this._needsUpdate = true } removeOutline () { this._outlineCount-- - this.needsUpdate = true + this._needsUpdate = true } /** @@ -248,7 +305,7 @@ export class Renderer implements IRenderer { } this._composer.outlines = this._outlineCount > 0 - if(this.needsUpdate || !this.onDemand) { + if(this._needsUpdate || !this.autoRender) { this._composer.render() } this._needsUpdate = false @@ -266,18 +323,12 @@ export class Renderer implements IRenderer { */ add (target: Scene | THREE.Object3D) { if (target instanceof Scene) { - const mem = target.getMemory() - const remaining = this.maxMemory - this.estimatedMemory - if (mem > remaining) { - return false - } target.renderer = this this._sceneUpdated = true } this._scene.add(target) this.notifySceneUpdate() - return true } /** @@ -297,13 +348,6 @@ export class Renderer implements IRenderer { this._needsUpdate = true } - /** - * Returns an estimate of the memory used by the renderer. - */ - get estimatedMemory () { - return this._scene.estimatedMemory - } - /** * Determines the target sample count on the rendering target. * Higher number increases quality. @@ -316,12 +360,24 @@ export class Renderer implements IRenderer { this._composer.samples = value } + /** + * Scale factor for outline/selection render target resolution (0-1). + * Lower = faster, higher = sharper outlines. Default: 0.75. + */ + get outlineScale () { + return this._composer.outlineScale + } + + set outlineScale (value: number) { + this._composer.outlineScale = value + } + private fitViewport = () => { const size = this._viewport.getParentSize() - this.renderer.setPixelRatio(window.devicePixelRatio) - this.renderer.setSize(size.x, size.y) + this.three.setPixelRatio(window.devicePixelRatio) + this.three.setSize(size.x, size.y) this._composer.setSize(size.x, size.y) this.textRenderer.setSize(size.x, size.y) - this.needsUpdate = true + this._needsUpdate = true } } 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..5370b3238 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderingComposer.ts @@ -5,8 +5,6 @@ import * as THREE from 'three' import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js' import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js' -import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js' -import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js' import { Viewport } from '../viewport' import { RenderScene } from './renderScene' @@ -19,20 +17,20 @@ import { Camera } from '../camera/camera' /* * Rendering Pipeline Flow: *---------------------* - | Regular/SSAA Render | --------------------------------------- - *---------------------* | - | - *-----------------* *----------* *------* *----------------* *--------* - |Render Selection | --- | Outlines | --- | FXAA | --- | Merge/Transfer | --- | Screen | - *-----------------* *----------* *------* *----------------* *--------* + | Regular/MSAA Render | ------------------------------------ + *---------------------* | + | + *-----------------* *----------* *----------------* *--------* + |Render Selection | --- | Outlines | --- | Merge/Transfer | --- | Screen | + *-----------------* *----------* *----------------* *--------* */ /** - * Composer for managing the rendering pipeline including anti-aliasing and outline effects. + * @internal + * Composer for managing the rendering pipeline including outline effects. * Handles the orchestration of multiple render passes including: - * - Main scene rendering + * - Main scene rendering (MSAA) * - Selection outline rendering - * - FXAA anti-aliasing * - Final composition and screen output */ export class RenderingComposer { @@ -46,7 +44,6 @@ export class RenderingComposer { private _renderPass: RenderPass private _selectionRenderPass: RenderPass private _transferPass: TransferPass - private _outlineFxaaPass: ShaderPass private _outlines: boolean = false private _clock: THREE.Clock @@ -56,6 +53,10 @@ export class RenderingComposer { private _outlineTarget: THREE.WebGLRenderTarget private _sceneTarget: THREE.WebGLRenderTarget + // Scale factor for outline/selection render target (0.5 = 50% resolution = 4x faster) + // Lower values = better performance, higher values = better quality + private _outlineScale = 0.75 + /** * Creates a new RenderingComposer instance * @param renderer - The WebGL renderer instance @@ -90,10 +91,12 @@ export class RenderingComposer { */ private initSceneRenderingPipeline () { // Create render texture with maximum available MSAA samples + // Use half-float (16-bit) precision - plenty for display, 50% less bandwidth this._sceneTarget = new THREE.WebGLRenderTarget( this._size.x, this._size.y, { + type: THREE.HalfFloatType, samples: this._renderer.capabilities.maxSamples } ) @@ -109,16 +112,25 @@ export class RenderingComposer { /** * Initializes the outline rendering pipeline - * Sets up render targets and passes for selection, outline, FXAA, and final composition + * Sets up render targets and passes for selection, outline, and final composition + * Renders selection at reduced resolution for better performance (3-4x faster) * @private */ private initOutlinePipeline () { - // Create texture for outline rendering with depth information + // Calculate scaled dimensions for outline/selection rendering + const outlineWidth = Math.floor(this._size.x * this._outlineScale) + const outlineHeight = Math.floor(this._size.y * this._outlineScale) + + // Create texture for outline rendering with depth information at reduced resolution + // No MSAA needed for outline target + // RedFormat uses only 1 channel instead of 4 (RGBA) - 75% less memory bandwidth! this._outlineTarget = new THREE.WebGLRenderTarget( - this._size.x, - this._size.y, + outlineWidth, + outlineHeight, { - depthTexture: new THREE.DepthTexture(this._size.x, this._size.y), + format: THREE.RedFormat, + type: THREE.UnsignedByteType, + depthTexture: new THREE.DepthTexture(outlineWidth, outlineHeight), } ) @@ -129,22 +141,17 @@ export class RenderingComposer { this._selectionRenderPass = new RenderPass( this._scene.threeScene, this._camera, - this._materials.mask + this._materials.system.mask ) this._composer.addPass(this._selectionRenderPass) // Setup outline pass using the selection render result this._outlinePass = new OutlinePass( this._camera, - this._materials.outline + this._materials.system.outline ) this._composer.addPass(this._outlinePass) - // Add FXAA pass for anti-aliasing the outlines - this._outlineFxaaPass = new ShaderPass(FXAAShader) - this._outlineFxaaPass.enabled = this._materials.outlineAntialias - this._composer.addPass(this._outlineFxaaPass) - // Setup final composition passes this._mergePass = new MergePass(this._sceneTarget.texture, this._materials) this._mergePass.enabled = false @@ -156,6 +163,20 @@ export class RenderingComposer { this._composer.addPass(this._transferPass) } + /** + * Scale factor for outline/selection render target resolution (0-1). + * Lower = faster, higher = sharper outlines. Default: 0.75. + * Takes effect immediately by resizing the outline render target. + */ + get outlineScale () { + return this._outlineScale + } + + set outlineScale (value: number) { + this._outlineScale = value + this.setSize(this._size.x, this._size.y) + } + /** * @returns Whether outline rendering is enabled */ @@ -164,7 +185,9 @@ export class RenderingComposer { } /** - * Enables or disables outline rendering + * Switches between two rendering paths: + * - true: selection render → outline → merge (3 passes) + * - false: transfer only (1 pass) */ set outlines (value: boolean) { this._outlines = value @@ -200,21 +223,26 @@ export class RenderingComposer { this._size = new THREE.Vector2(width, height) this._sceneTarget.setSize(width, height) this._renderPass.setSize(width, height) - this._composer.setSize(width, height) + + // Update outline/selection target with scaled dimensions for performance + const outlineWidth = Math.floor(width * this._outlineScale) + const outlineHeight = Math.floor(height * this._outlineScale) + this._composer.setSize(outlineWidth, outlineHeight) } /** - * @returns The current MSAA sample count + * @returns The current MSAA sample count for scene rendering */ get samples () { return this._sceneTarget.samples } /** - * Sets the MSAA sample count for the scene render target + * Sets the MSAA sample count for the scene render target. + * Three.js handles the framebuffer recreation automatically. */ set samples (value: number) { - this._sceneTarget.samples = value + this._sceneTarget.samples = Math.min(value, this._renderer.capabilities.maxSamples) } /** @@ -222,17 +250,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) } /** @@ -245,7 +274,6 @@ export class RenderingComposer { this._outlineTarget.dispose() this._selectionRenderPass.dispose() this._outlinePass.dispose() - this._outlineFxaaPass.dispose() this._mergePass.dispose() this._transferPass.dispose() 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..fe6fd141a 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/renderingSection.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/renderingSection.ts @@ -7,9 +7,21 @@ import { Materials } from '../../loader/materials/materials' import { Renderer } from './renderer' /** + * Public interface for section box management. + * Exposes only the members needed by API consumers. + */ +export interface IRenderingSection { + readonly box: THREE.Box3 + fitBox(box: THREE.Box3): void + active: boolean + readonly clippingPlanes: THREE.Plane[] +} + +/** + * @internal * Manages a section box from renderer clipping planes */ -export class RenderingSection { +export class RenderingSection implements IRenderingSection { private _renderer: Renderer private _materials: Materials @@ -48,6 +60,8 @@ export class RenderingSection { * @param box The bounding box to match the section box to. */ fitBox (box: THREE.Box3) { + // THREE.Plane equation: normal · point + constant = 0 + // Min planes have positive normals, so constant = -position. this.maxX.constant = box.max.x this.minX.constant = -box.min.x this.maxY.constant = box.max.y @@ -55,7 +69,7 @@ export class RenderingSection { this.maxZ.constant = box.max.z this.minZ.constant = -box.min.z this.box.copy(box) - this._renderer.needsUpdate = true + this._renderer.requestRender() } /** @@ -63,12 +77,19 @@ export class RenderingSection { */ set active (value: boolean) { this._materials.clippingPlanes = this.planes - this._renderer.renderer.localClippingEnabled = value + this._renderer.three.localClippingEnabled = value this._active = value - this._renderer.needsUpdate = true + this._renderer.requestRender() } 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/rendering/transferPass.ts b/src/vim-web/core-viewers/webgl/viewer/rendering/transferPass.ts index 21cb70c07..1e28b2c01 100644 --- a/src/vim-web/core-viewers/webgl/viewer/rendering/transferPass.ts +++ b/src/vim-web/core-viewers/webgl/viewer/rendering/transferPass.ts @@ -7,6 +7,7 @@ import { FullScreenQuad, Pass } from 'three/examples/jsm/postprocessing/Pass' import { createTransferMaterial } from '../../loader/materials/transferMaterial' /** + * @internal * Copies a source buffer to the current write buffer. */ export class TransferPass extends Pass { @@ -43,15 +44,12 @@ export class TransferPass extends Pass { writeBuffer: THREE.WebGLRenderTarget, readBuffer: THREE.WebGLRenderTarget ) { - // 2. Draw the outlines using the depth texture and normal texture - // and combine it with the scene color if (this.renderToScreen) { - // If this is the last effect, then renderToScreen is true. - // So we should render to the screen by setting target null - // Otherwise, just render into the writeBuffer that the next effect will use as its read buffer. renderer.setRenderTarget(null) this._fsQuad.render(renderer) } else { + // Write to readBuffer (not writeBuffer) because needsSwap=false. + // This pass just copies the scene texture through without consuming a swap. renderer.setRenderTarget(readBuffer) this._fsQuad.render(renderer) } diff --git a/src/vim-web/core-viewers/webgl/viewer/selection.ts b/src/vim-web/core-viewers/webgl/viewer/selection.ts index 662ce3811..6fe730c3c 100644 --- a/src/vim-web/core-viewers/webgl/viewer/selection.ts +++ b/src/vim-web/core-viewers/webgl/viewer/selection.ts @@ -2,21 +2,64 @@ * @module viw-webgl-viewer */ -import { Marker } from './gizmos/markers/gizmoMarker' -import { Element3D } from '../loader/element3d' -import {Selection, type ISelectionAdapter} from '../../shared/selection' +import {Selection, type ISelection, type ISelectionAdapter} from '../../shared/selection' +import { IVimElement } from '../../shared/vim' -export type Selectable = Element3D | Marker -export type ISelection = Selection +/** + * Selectable object in the WebGL viewer. Both {@link IElement3D} and {@link IMarker} implement this. + * + * Use the `type` discriminant to narrow: + * - `'Element3D'` → {@link IElement3D} (BIM element with geometry, color, BIM data) + * - `'Marker'` → {@link IMarker} (sprite gizmo in the scene) + * + * @example + * ```ts + * const items = viewer.core.selection.getAll() // ISelectable[] + * for (const item of items) { + * if (item.type === 'Element3D') { + * const el = item as IElement3D + * console.log(el.hasMesh, el.elementId) + * } + * } + * ``` + */ +export interface ISelectable extends IVimElement { + /** Discriminant: `'Element3D'` for BIM elements, `'Marker'` for gizmo markers. */ + readonly type: string + /** The BIM element index, or undefined for markers without an associated element. */ + readonly element: number | undefined + /** Whether to render selection outline for this object. */ + outline: boolean + /** Whether to render this object. */ + visible: boolean + /** True if this object is a room element. Always false for markers. */ + readonly isRoom: boolean + /** The geometry instances associated with this object, if any. */ + readonly instances: number[] | undefined +} +export type IWebglSelection = ISelection + +/** + * Type guard to narrow an {@link ISelectable} to an `IElement3D`. + * + * @example + * ```ts + * const items = viewer.core.selection.getAll() + * const elements = items.filter(isElement3D) // IElement3D[] + * ``` + */ +export function isElement3D(item: ISelectable): item is ISelectable & { type: 'Element3D' } { + return item.type === 'Element3D' +} + +/** @internal */ export function createSelection() { - return new Selection(new SelectionAdapter()) -} + return new Selection(new SelectionAdapter()) +} -class SelectionAdapter implements ISelectionAdapter{ - outline(object: Selectable, state: boolean): void { +class SelectionAdapter implements ISelectionAdapter{ + outline(object: ISelectable, state: boolean): void { object.outline = state } } - - diff --git a/src/vim-web/core-viewers/webgl/viewer/settings/index.ts b/src/vim-web/core-viewers/webgl/viewer/settings/index.ts index ef3c5c4ae..1acc1fa10 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/index.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/index.ts @@ -1,3 +1 @@ -export * from './viewerDefaultSettings' -export * from './viewerSettings' -export * from './viewerSettingsParsing' \ No newline at end of file +export type { ViewerSettings, PartialViewerSettings, MaterialSettings } from './viewerSettings' 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..257339b8c 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerDefaultSettings.ts @@ -13,8 +13,8 @@ export function getDefaultViewerSettings(): ViewerSettings { }, camera: { orthographic: false, - allowedMovement: new THREE.Vector3(1, 1, 1), - allowedRotation: new THREE.Vector2(1, 1), + lockMovement: new THREE.Vector3(1, 1, 1), + lockRotation: new THREE.Vector2(1, 1), near: 0.001, far: 15000, fov: 50, @@ -26,15 +26,16 @@ export function getDefaultViewerSettings(): ViewerSettings { rotateSpeed: 1, orbitSpeed: 1, moveSpeed: 1, - scrollSpeed: 1.5 + scrollSpeed: 1.75 }, 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) }, @@ -64,16 +65,13 @@ export function getDefaultViewerSettings(): ViewerSettings { } ], materials: { + useFastMaterials: false, standard: { color: new THREE.Color(0xcccccc) }, - highlight: { - color: new THREE.Color(0x6ad2ff), - opacity: 0.5 - }, ghost: { color: new THREE.Color(0x0E0E0E), - opacity: 0.25 + opacity: 7 / 255 }, section: { strokeWidth: 0.01, @@ -81,16 +79,14 @@ export function getDefaultViewerSettings(): ViewerSettings { strokeColor: new THREE.Color(0xf6f6f6) }, outline: { - antialias: true, - intensity: 3, - falloff: 3, - blur: 2, - color: new THREE.Color(0x00ffff) + intensity: 2, + color: new THREE.Color(0x00ffff), + scale: 0.75 } }, axes: getDefaultAxesSettings(), rendering: { - onDemand: true + autoRender: true } } } 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..c9717a421 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettings.ts @@ -11,7 +11,90 @@ import { RecursivePartial } from '../../../../utils/partial' export type TextureEncoding = 'url' | 'base64' | undefined -/** Viewer related options independant from vims */ +export type MaterialSettings = { + /** + * 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 + */ + standard: { + color: THREE.Color + } + /** + * Ghost material options + */ + ghost: { + /** + * Ghost material color + * Default: rgb(78, 82, 92) + */ + color: THREE.Color + /** + * Ghost material opacity + * Default: 0.08 + */ + opacity: number + } + /** + * Section box intersection highlight options + */ + section: { + /** + * Intersection highlight stroke width. + * Default: 0.01 + */ + strokeWidth: number; + /** + * Intersection highlight stroke falloff. + * Default: 0.75 + */ + strokeFalloff: number; + /** + * Intersection highlight stroke color. + * Default: rgb(246, 246, 246) + */ + strokeColor: THREE.Color; + } + /** + * Selection outline options + */ + outline: { + /** + * Selection outline intensity (brightness multiplier). + * Default: 2 + */ + intensity: number; + /** + * Selection outline color. + * Default: rgb(0, 255, 255) + */ + color: THREE.Color; + /** + * Scale factor for outline render target resolution (0-1). + * Lower = faster, higher = sharper outlines. + * Default: 0.75 + */ + scale: number; + } +} + +/** + * Core renderer configuration, passed to `Core.Webgl.createViewer(settings)` at initialization. + * Controls camera defaults, lighting, materials, canvas, and rendering pipeline. + * Not to be confused with {@link VimSettings} (per-model transform) or WebglSettings (React UI toggles). + * + * @example + * const viewer = Core.Webgl.createViewer({ + * camera: { orthographic: true, fov: 50 }, + * materials: { useFastMaterials: true } + * }) + */ export type ViewerSettings = { /** * Webgl canvas related options @@ -40,16 +123,18 @@ export type ViewerSettings = { orthographic: boolean /** - * Vector3 of 0 or 1 to enable/disable movement along each axis + * Movement lock per axis in Z-up space (X = right, Y = forward, Z = up). + * Each component should be 0 (locked) or 1 (free). * Default: THREE.Vector3(1, 1, 1) */ - allowedMovement: THREE.Vector3 + lockMovement: THREE.Vector3 /** - * Vector2 of 0 or 1 to enable/disable rotation around x or y. + * Rotation lock per axis. x = yaw (around Z), y = pitch (up/down). + * Each component should be 0 (locked) or 1 (free). * Default: THREE.Vector2(1, 1) */ - allowedRotation: THREE.Vector2 + lockRotation: THREE.Vector2 /** * Near clipping plane distance @@ -76,8 +161,8 @@ export type ViewerSettings = { zoom: number /** - * Initial forward vector of the camera - * THREE.Vector3(1, -1, 1) + * Initial forward vector of the camera in Z-up space (X = right, Y = forward, Z = up). + * Default: THREE.Vector3(1, -1, 1) */ forward: THREE.Vector3 @@ -128,26 +213,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 } @@ -187,97 +278,9 @@ export type ViewerSettings = { }, /** -* Object highlight on click options +* Material options */ -materials: { - /** - * Default color of standard material - */ - standard: { - color: THREE.Color - } - /** - * Highlight on hover options - */ - highlight: { - /** - * Highlight color - * Default: rgb(106, 210, 255) - */ - color: THREE.Color - /** - * Highlight opacity - * Default: 0.5 - */ - opacity: number - } - /** - * Ghost material options - */ - ghost: { - /** - * Ghost material color - * Default: rgb(78, 82, 92) - */ - color: THREE.Color - /** - * Ghost material opacity - * Default: 0.08 - */ - opacity: number - } - /** - * Section box intersection highlight options - */ - section: { - /** - * Intersection highlight stroke width. - * Default: 0.01 - */ - strokeWidth: number; - /** - * Intersection highlight stroke falloff. - * Default: 0.75 - */ - strokeFalloff: number; - /** - * Intersection highlight stroke color. - * Default: rgb(246, 246, 246) - */ - strokeColor: THREE.Color; - } - /** - * Selection outline options - */ - outline: { - - /** - * Enable antialiasing for the outline. - * Default: false - */ - antialias: boolean - /** - * Selection outline intensity. - * Default: 3 - */ - intensity: number; - /** - * Selection outline falloff. - * Default: 3 - */ - falloff: number; - /** - * Selection outline blur. - * Default: 2 - */ - blur: number; - /** - * Selection outline color. - * Default: rgb(0, 255, 255) - */ - color: THREE.Color; - } -} +materials: MaterialSettings /** * Axes gizmo options @@ -322,10 +325,10 @@ materials: { rendering: { /** - * Enable on-demand rendering which wait for changes before rendering to the canvas. + * When true, only renders when changes are detected. When false, renders every frame. * Default: true */ - onDemand: boolean + autoRender: boolean } } diff --git a/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettingsParsing.ts b/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettingsParsing.ts index 2c15fba32..19ce7e3f8 100644 --- a/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettingsParsing.ts +++ b/src/vim-web/core-viewers/webgl/viewer/settings/viewerSettingsParsing.ts @@ -32,8 +32,8 @@ function parseSettingsFromUrl (url: string) { }, camera: { orthographic: get('camera.orthographic', strToBool), - allowedMovement: get('camera.allowedMovement', strToVector3), - allowedRotation: get('camera.allowedRotation', strToVector2), + lockMovement: get('camera.lockMovement', strToVector3), + lockRotation: get('camera.lockRotation', strToVector2), near: get('camera.near', Number.parseFloat), far: get('camera.far', Number.parseFloat), fov: get('camera.fov', Number.parseInt), @@ -85,10 +85,6 @@ function parseSettingsFromUrl (url: string) { standard: { color: get('materials.standard.color', strToColor) }, - highlight: { - color: get('materials.highlight.color', strToColor), - opacity: get('materials.highlight.opacity', Number.parseFloat) - }, ghost: { color: get('materials.ghost.color', strToColor), opacity: get('materials.ghost.opacity', Number.parseFloat) @@ -99,16 +95,14 @@ function parseSettingsFromUrl (url: string) { strokeColor: get('materials.section.strokeColor', strToColor) }, outline: { - antialias: get('materials.outline.antialias', strToBool), intensity: get('materials.outline.intensity', Number.parseFloat), - falloff: get('materials.outline.falloff', Number.parseFloat), - blur: get('materials.outline.blur', Number.parseFloat), - color: get('materials.outline.color', strToColor) + color: get('materials.outline.color', strToColor), + scale: get('materials.outline.scale', Number.parseFloat) } }, axes: undefined, rendering: { - onDemand: get('rendering.onDemand', strToBool) + autoRender: get('rendering.autoRender', strToBool) } } as ViewerSettings diff --git a/src/vim-web/core-viewers/webgl/viewer/viewer.ts b/src/vim-web/core-viewers/webgl/viewer/viewer.ts index cf20a4c42..c1f60eeca 100644 --- a/src/vim-web/core-viewers/webgl/viewer/viewer.ts +++ b/src/vim-web/core-viewers/webgl/viewer/viewer.ts @@ -6,26 +6,78 @@ import * as THREE from 'three' // internal import { Camera } from './camera/camera' -import { Environment } from './environment/environment' -import { Gizmos } from './gizmos/gizmos' -import { IRaycaster, Raycaster } from './raycaster' +import { IWebglCamera } from './camera/cameraInterface' +import { Gizmos, IGizmos } from './gizmos/gizmos' +import { IWebglRaycaster } from './raycaster' +import { GpuPicker } from './rendering/gpuPicker' import { RenderScene } from './rendering/renderScene' -import { createSelection, ISelection } from './selection' +import { createSelection, IWebglSelection } from './selection' import { createViewerSettings, PartialViewerSettings, ViewerSettings } from './settings/viewerSettings' -import { Viewport } from './viewport' +import { IWebglViewport, Viewport } from './viewport' // loader -import { ISignal, SignalDispatcher } from 'ste-signals' -import type {InputHandler} from '../../shared' -import { Materials } from '../loader/materials/materials' -import { Vim } from '../loader/vim' +import type { ISignal } from '../../shared/events' +import { SignalDispatcher } from 'ste-signals' +import {type IInputHandler} from '../../shared' +import {type InputHandler} from '../../shared/input/inputHandler' +import { IMaterials, Materials } from '../loader/materials/materials' +import { Vim, IWebglVim } from '../loader/vim' +import { Scene } from '../loader/scene' +import { VimCollection } from '../../shared/vimCollection' import { createInputHandler } from './inputAdapter' -import { Renderer } from './rendering/renderer' +import { IWebglRenderer, Renderer } from './rendering/renderer' +import { LoadRequest as CoreLoadRequest, RequestSource, IWebglLoadRequest } from '../loader/progressive/loadRequest' +import { VimPartialSettings } from '../loader/vimSettings' + +/** + * Public interface for the WebGL viewer. + * Consumers should use this instead of the concrete class. + * + * **Lifecycle:** Call `load()` to add VIM models (auto-populates `vims`), + * `unload(vim)` to remove one, `clear()` to remove all, and `dispose()` to + * tear down the viewer entirely. Do not call `dispose()` on individual vims — + * always go through `unload()`. + * + * @example + * ```ts + * const viewer = Core.Webgl.createViewer() + * const vim = await viewer.load({ url: 'model.vim' }).getVim() + * console.log(viewer.vims.length) // 1 + * + * viewer.unload(vim) // Remove one vim + * viewer.clear() // Remove all vims + * viewer.dispose() // Tear down viewer + * ``` + */ +export interface IWebglViewer { + readonly type: 'webgl' + readonly settings: ViewerSettings + readonly renderer: IWebglRenderer + readonly viewport: IWebglViewport + readonly selection: IWebglSelection + readonly inputs: IInputHandler + readonly raycaster: IWebglRaycaster + readonly materials: IMaterials + readonly camera: IWebglCamera + readonly gizmos: IGizmos + /** Fires when a vim finishes loading and is added to the scene. */ + readonly onVimLoaded: ISignal + /** All loaded VIM models. Auto-populated on load, auto-removed on unload. */ + readonly vims: IWebglVim[] + /** Loads a VIM file. The resulting vim is added to `vims` on success. */ + load (source: RequestSource, settings?: VimPartialSettings): IWebglLoadRequest + /** Removes a vim from the viewer and disposes its resources. */ + unload (vim: IWebglVim): void + /** Removes and disposes all loaded vims. */ + clear (): void + /** Tears down the viewer entirely — releases WebGL context, DOM elements, and all resources. */ + dispose (): void +} /** * Viewer and loader for vim files. */ -export class Viewer { +export class WebglViewer implements IWebglViewer { /** * The type of the viewer, indicating it is a WebGL viewer. * Useful for distinguishing between different viewer types in a multi-viewer application. @@ -39,50 +91,49 @@ export class Viewer { /** * The renderer used by the viewer for rendering scenes. */ - readonly renderer: Renderer + get renderer(): IWebglRenderer { return this._renderer } + private readonly _renderer: Renderer /** * The interface for managing the HTML canvas viewport. */ - - readonly viewport: Viewport + get viewport(): IWebglViewport { return this._viewport } + private readonly _viewport: Viewport /** * The interface for managing viewer selection. */ - readonly selection: ISelection + readonly selection: IWebglSelection /** * The interface for manipulating default viewer inputs. */ - readonly inputs: InputHandler + get inputs(): IInputHandler { return this._inputs } + private readonly _inputs: InputHandler /** * The interface for performing raycasting into the scene to find objects. */ - readonly raycaster: IRaycaster + readonly raycaster: IWebglRaycaster /** * The materials used by the viewer to render the vims. */ - readonly materials: Materials - - /** - * The environment of the viewer, including the ground plane and lights. - */ - readonly environment: Environment + get materials (): IMaterials { return this._materials } + private readonly _materials: Materials /** * The interface for manipulating the viewer's camera. */ - get camera () { - return this._camera as Camera + get camera (): IWebglCamera { + return this._camera } /** * The collection of gizmos available for visualization and interaction within the viewer. */ - gizmos: Gizmos + get gizmos (): IGizmos { return this._gizmos } + private _gizmos: Gizmos /** * A signal that is dispatched when a new Vim object is loaded or unloaded. @@ -95,42 +146,54 @@ export class Viewer { private _clock = new THREE.Clock() // State - private _vims = new Set() + private readonly vimCollection = new VimCollection() private _onVimLoaded = new SignalDispatcher() private _updateId: number constructor (settings?: PartialViewerSettings) { this.settings = createViewerSettings(settings) - this.materials = Materials.getInstance() + this._materials = Materials.getInstance() const scene = new RenderScene() - this.viewport = new Viewport(this.settings) - this._camera = new Camera(scene, this.viewport, this.settings) - this.renderer = new Renderer( + this._viewport = new Viewport(this.settings) + this._camera = new Camera(scene, this._viewport, this.settings) + this._renderer = new Renderer( scene, - this.viewport, - this.materials, + this._viewport, + this._materials, this._camera, this.settings ) - this.inputs = createInputHandler(this) - this.gizmos = new Gizmos(this, this._camera) - this.materials.applySettings(this.settings) - - // Ground plane and lights - this.environment = new Environment(this.camera, this.renderer, this.materials, this.settings) + this._inputs = createInputHandler(this) + this._gizmos = new Gizmos(this._renderer, this._viewport, this, this._camera) + this.materials.applySettings(this.settings.materials) // Input and Selection this.selection = createSelection() - this.raycaster = new Raycaster( + + // GPU-based raycaster for element picking and world position queries + const size = this._renderer.three.getSize(new THREE.Vector2()) + const gpuPicker = new GpuPicker( + this._renderer.three, this._camera, scene, - this.renderer + this.vimCollection, + this._renderer.section, + size.x || 1, + size.y || 1 ) + gpuPicker.setMarkers(this._gizmos.markers) + this.raycaster = gpuPicker + + // Update raycaster size on viewport resize + this._viewport.onResize.sub(() => { + const size = this._viewport.getParentSize() + ;(this.raycaster as GpuPicker).setSize(size.x, size.y) + }) - this.inputs.init() + this._inputs.init() // Start Loop this.animate() @@ -142,61 +205,72 @@ export class Viewer { this._updateId = requestAnimationFrame(() => this.animate()) // Camera - this.renderer.needsUpdate = this._camera.update(deltaTime) + if (this._camera.update(deltaTime)) this._renderer.requestRender() // Gizmos - this.gizmos.updateAfterCamera() + this._gizmos.updateAfterCamera() // Rendering - this.renderer.render() + this._renderer.render() } /** - * Retrieves an array containing all currently loaded Vim objects. - * @returns {Vim[]} An array of all Vim objects currently loaded in the viewer. + * Starts loading a VIM file. The resulting Vim is auto-added to the viewer on success. + * @param source The url or buffer to load from. + * @param settings Optional settings for the vim. + * @returns A load request to track progress and get the result. + * @throws Error if the viewer has reached maximum capacity (256 vims) */ - get vims () { - return [...this._vims] + load (source: RequestSource, settings?: VimPartialSettings): IWebglLoadRequest { + const vimIndex = this.vimCollection.allocateId() + if (vimIndex === undefined) { + throw new Error('Cannot load vim: maximum of 256 vims already loaded') + } + const request = new CoreLoadRequest(source, settings ?? {}, vimIndex) + request.getResult().then((result) => { + if (result.isSuccess) { + this.add(result.vim) + } + }) + return request } /** - * The number of Vim objects currently loaded in the viewer. + * All currently loaded Vim models. */ - get vimCount () { - return this._vims.size + get vims (): IWebglVim[] { + return this.vimCollection.getAll() } /** - * Adds a Vim object to the renderer, triggering the appropriate actions and dispatching events upon successful addition. - * @param {Vim} vim - The Vim object to add to the renderer. - * @throws {Error} If the Vim object is already added or if loading the Vim would exceed maximum geometry memory. + * Adds a Vim object to the renderer. + * @throws {Error} If the Vim object is already added. */ - add (vim: Vim) { - if (this._vims.has(vim)) { + private add (vim: Vim) { + if (this.vimCollection.has(vim)) { throw new Error('Vim cannot be added again, unless removed first.') } - const success = this.renderer.add(vim.scene) - if (!success) { - throw new Error('Could not load vim. Max geometry memory reached.') - } - - this._vims.add(vim) + this._renderer.add(vim.scene as Scene) + this.vimCollection.add(vim) this._onVimLoaded.dispatch() } /** - * Unloads the given Vim object from the viewer, updating the scene and triggering necessary actions. - * @param {Vim} vim - The Vim object to remove from the viewer. - * @throws {Error} If attempting to remove a Vim object that is not present in the viewer. + * Unloads and disposes the given Vim from the viewer. + * This is the proper way to unload a vim — do not call `vim.dispose()` directly. + * @param vim - The Vim to unload. + * @throws If the vim is not present in the viewer. */ - remove (vim: Vim) { - if (!this._vims.has(vim)) { + unload (vim: IWebglVim) { + const v = vim as Vim + if (!this.vimCollection.has(v)) { throw new Error('Cannot remove missing vim from viewer.') } - this._vims.delete(vim) - this.renderer.remove(vim.scene) - this.selection.removeFromVim(vim) + this.vimCollection.remove(v) + this._renderer.remove(v.scene as Scene) + this.selection.removeFromVim(v) + v.dispose() this._onVimLoaded.dispatch() } @@ -204,7 +278,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.unload(vim) + } } /** @@ -212,13 +290,30 @@ export class Viewer { */ dispose () { cancelAnimationFrame(this._updateId) - this.environment.dispose() this.selection.clear() - this.viewport.dispose() - this.renderer.dispose() - this.inputs.unregisterAll() - this._vims.forEach((v) => v?.dispose()) - this.materials.dispose() - this.gizmos.dispose() + this.clear() + this._viewport.dispose() + this._renderer.dispose() + ;(this.raycaster as GpuPicker).dispose() + this._inputs.dispose() + this._materials.dispose() + this._gizmos.dispose() } } + +/** + * Creates a headless WebGL viewer without React UI. + * Use this for programmatic-only usage or custom UI frameworks. + * For a full React UI viewer, use `React.Webgl.createViewer()` instead. + * + * @param settings - Optional renderer config (camera, materials, lighting). See {@link ViewerSettings}. + * @returns A new WebGL viewer. + * + * @example + * const viewer = Core.Webgl.createViewer({ camera: { orthographic: true } }) + * document.body.appendChild(viewer.viewport.canvas) + * const vim = await viewer.load({ url: 'model.vim' }).getVim() + */ +export function createCoreWebglViewer (settings?: PartialViewerSettings): IWebglViewer { + return new WebglViewer(settings) +} diff --git a/src/vim-web/core-viewers/webgl/viewer/viewport.ts b/src/vim-web/core-viewers/webgl/viewer/viewport.ts index 84191812f..6afb07dec 100644 --- a/src/vim-web/core-viewers/webgl/viewer/viewport.ts +++ b/src/vim-web/core-viewers/webgl/viewer/viewport.ts @@ -2,12 +2,39 @@ @module viw-webgl-viewer */ +import type { ISignal } from '../../shared/events' import { SignalDispatcher } from 'ste-signals' import * as THREE from 'three' import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer' import { ViewerSettings } from './settings/viewerSettings' -export class Viewport { +/** + * Public interface for the viewport. + * Exposes only the members needed by API consumers. + */ +export interface IWebglViewport { + /** HTML Canvas on which the model is rendered. */ + readonly canvas: HTMLCanvasElement + /** The parent element of the canvas. */ + readonly parent: HTMLElement | null + /** Moves the canvas to a new parent element. */ + reparent(parent: HTMLElement): void + /** Returns the pixel size of the parent element. */ + getParentSize(): THREE.Vector2 + /** Returns the pixel size of the canvas. */ + getSize(): THREE.Vector2 + /** Returns the aspect ratio (width / height) of the parent element. */ + getAspectRatio(): number + /** Triggers a resize to match parent dimensions. */ + resizeToParent(): void + /** Signal dispatched when the canvas is reparented. */ + readonly onReparent: ISignal + /** Signal dispatched when the canvas is resized. */ + readonly onResize: ISignal +} + +/** @internal */ +export class Viewport implements IWebglViewport { /** * HTML Canvas on which the model is rendered */ diff --git a/src/vim-web/index.ts b/src/vim-web/index.ts index 44edd6eb5..804b3ebcd 100644 --- a/src/vim-web/index.ts +++ b/src/vim-web/index.ts @@ -1,5 +1,5 @@ export * as Core from './core-viewers' export * as React from './react-viewers' export * as THREE from 'three' - - +export type { ISignal, ISimpleEvent } from './core-viewers/shared/events' +export type * as BIM from 'vim-format' diff --git a/src/vim-web/react-viewers/bim/bimInfoBody.tsx b/src/vim-web/react-viewers/bim/bimInfoBody.tsx index 08df65dbb..e532bed0c 100644 --- a/src/vim-web/react-viewers/bim/bimInfoBody.tsx +++ b/src/vim-web/react-viewers/bim/bimInfoBody.tsx @@ -3,14 +3,14 @@ import ReactTooltip from 'react-tooltip' import * as Icons from '../icons' import * as BIM from './bimInfoData' import { createOpenState } from './openState' -import { BimInfoPanelRef } from './bimInfoData' +import { BimInfoPanelApi } from './bimInfoData' /** * Represents the details of a BIM object. */ export function BimBody ( props:{ - bimInfoRef: BimInfoPanelRef, + bimInfoRef: BimInfoPanelApi, sections : BIM.Section[], } ) { @@ -49,7 +49,7 @@ export function BimBody ( } function createSection ( - bimInfoRef: BimInfoPanelRef, + bimInfoRef: BimInfoPanelApi, section: BIM.Section, getOpen: (key: string) => boolean, setOpen: (key: string, value: boolean) => void @@ -86,7 +86,7 @@ function createSection ( } function createGroup ( - bimInfoRef: BimInfoPanelRef, + bimInfoRef: BimInfoPanelApi, group: BIM.Group, getOpen: (key: string) => boolean, setOpen: (key: string, value: boolean) => void @@ -137,7 +137,7 @@ function createCollapseButton ( } function createGroupContent ( - bimInfoRef: BimInfoPanelRef, + bimInfoRef: BimInfoPanelApi, group: BIM.Group, open: boolean) { if (open === false) return null @@ -156,7 +156,7 @@ function createGroupContent (
)) } -function createEntry (bimInfoRef: BimInfoPanelRef, entry: BIM.Entry) { +function createEntry (bimInfoRef: BimInfoPanelApi, entry: BIM.Entry) { const func = () => { const standard = () => (<>{entry.value}) if (bimInfoRef.onRenderBodyEntryValue !== undefined) { diff --git a/src/vim-web/react-viewers/bim/bimInfoData.ts b/src/vim-web/react-viewers/bim/bimInfoData.ts index c71a5a863..363a22c3a 100644 --- a/src/vim-web/react-viewers/bim/bimInfoData.ts +++ b/src/vim-web/react-viewers/bim/bimInfoData.ts @@ -90,7 +90,7 @@ export type Data = { * @param source - The VIM.Object or VIM.Vim instance from which the data was originally extracted. * @returns A promise that resolves to the modified Data object. */ -export type DataCustomization = (data: Data, source: Core.Webgl.Vim | Core.Webgl.Element3D) => Promise +export type DataCustomization = (data: Data, source: Core.Webgl.IWebglVim | Core.Webgl.IElement3D) => Promise /** * A rendering customization function that takes props containing data and a standard @@ -109,7 +109,7 @@ export type DataRender = ((props: { data: T; standard: () => JSX.Element }) = * different parts of the BIM info panel. These callbacks can be updated at runtime and will be used * the next time the panel re-renders. */ -export type BimInfoPanelRef = { +export type BimInfoPanelApi = { /** * A function that customizes the data before it is rendered in the BIM info panel. */ @@ -161,9 +161,9 @@ export type BimInfoPanelRef = { * of a BIM info panel. This hook maintains internal state for each customization callback, * allowing dynamic updates at runtime. * - * @returns A {@link BimInfoPanelRef} object containing getters and setters for each customization callback. + * @returns A {@link BimInfoPanelApi} object containing getters and setters for each customization callback. */ -export function useBimInfo(): BimInfoPanelRef { +export function useBimInfo(): BimInfoPanelApi { const [onData, setOnData] = useState(() => async (data, _) => data) const [renderHeader, setRenderHeader] = useState>(undefined) const [renderHeaderEntry, setRenderHeaderEntry] = useState>(undefined) diff --git a/src/vim-web/react-viewers/bim/bimInfoHeader.tsx b/src/vim-web/react-viewers/bim/bimInfoHeader.tsx index 5a0cd2f58..c2322b623 100644 --- a/src/vim-web/react-viewers/bim/bimInfoHeader.tsx +++ b/src/vim-web/react-viewers/bim/bimInfoHeader.tsx @@ -1,9 +1,9 @@ import React from 'react' import * as BIM from './bimInfoData' -import { BimInfoPanelRef } from './bimInfoData' +import { BimInfoPanelApi } from './bimInfoData' export function BimHeader (props: { - bimInfoRef: BimInfoPanelRef + bimInfoRef: BimInfoPanelApi entries: BIM.Entry[] }) { if (props.entries === undefined) { @@ -28,7 +28,7 @@ export function BimHeader (props: { ) } -function createEntry (bimInfoRef: BimInfoPanelRef, entry: BIM.Entry) { +function createEntry (bimInfoRef: BimInfoPanelApi, entry: BIM.Entry) { const create = () => { const standard = () => (<>{entry.value?.toString()}) if (bimInfoRef.onRenderHeaderEntryValue !== undefined) { diff --git a/src/vim-web/react-viewers/bim/bimInfoObject.ts b/src/vim-web/react-viewers/bim/bimInfoObject.ts index bdcf4a077..bc1bd6305 100644 --- a/src/vim-web/react-viewers/bim/bimInfoObject.ts +++ b/src/vim-web/react-viewers/bim/bimInfoObject.ts @@ -12,7 +12,7 @@ export type ElementParameter = { isInstance: boolean; }; -export async function getObjectData (object: Core.Webgl.Element3D, elements: AugmentedElement[]) : Promise { +export async function getObjectData (object: Core.Webgl.IElement3D, elements: AugmentedElement[]) : Promise { const element = object ? elements.find((e) => e.index === object.element) : undefined @@ -62,7 +62,7 @@ export function getHeader (info: AugmentedElement | undefined): BIM.Entry[] | un } export async function getBody ( - object: Core.Webgl.Element3D + object: Core.Webgl.IElement3D ): Promise { let parameters = await object?.getBimParameters() if (!parameters) return null diff --git a/src/vim-web/react-viewers/bim/bimInfoPanel.tsx b/src/vim-web/react-viewers/bim/bimInfoPanel.tsx index 1db3369c8..ce86c9b13 100644 --- a/src/vim-web/react-viewers/bim/bimInfoPanel.tsx +++ b/src/vim-web/react-viewers/bim/bimInfoPanel.tsx @@ -7,14 +7,14 @@ import { BimHeader } from './bimInfoHeader' import { getObjectData } from './bimInfoObject' import { getVimData } from './bimInfoVim' import { AugmentedElement } from '../helpers/element' -import { Data, BimInfoPanelRef } from './bimInfoData' +import { Data, BimInfoPanelApi } from './bimInfoData' export function BimInfoPanel (props : { - object: Core.Webgl.Element3D, - vim: Core.Webgl.Vim, + object: Core.Webgl.IElement3D, + vim: Core.Webgl.IWebglVim, elements: AugmentedElement[], full : boolean - bimInfoRef: BimInfoPanelRef + bimInfoRef: BimInfoPanelApi } ) { const target = props.object?.type === 'Element3D' ? props.object : undefined diff --git a/src/vim-web/react-viewers/bim/bimInfoVim.ts b/src/vim-web/react-viewers/bim/bimInfoVim.ts index 46a81deff..3eefa7137 100644 --- a/src/vim-web/react-viewers/bim/bimInfoVim.ts +++ b/src/vim-web/react-viewers/bim/bimInfoVim.ts @@ -2,7 +2,7 @@ import * as Core from '../../core-viewers' import * as BIM from './bimInfoData' import { compare } from './bimUtils' -export async function getVimData (vim: Core.Webgl.Vim): Promise { +export async function getVimData (vim: Core.Webgl.IWebglVim): Promise { const [header, body] = await Promise.all([ getHeader(vim), getBody(vim) @@ -10,7 +10,7 @@ export async function getVimData (vim: Core.Webgl.Vim): Promise { return { header, body } } -export async function getHeader (vim: Core.Webgl.Vim): Promise { +export async function getHeader (vim: Core.Webgl.IWebglVim): Promise { const documents = await vim?.bim?.bimDocument?.getAll() const main = documents ? documents.find((d) => !d.isLinked) ?? documents[0] @@ -55,7 +55,7 @@ function formatDate (source: string | undefined) { return source?.replace(/(..:..):../, '$1') ?? '' } -export async function getBody (vim: Core.Webgl.Vim): Promise { +export async function getBody (vim: Core.Webgl.IWebglVim): Promise { let documents = await vim?.bim?.bimDocument?.getAll() if (!documents) return undefined diff --git a/src/vim-web/react-viewers/bim/bimPanel.tsx b/src/vim-web/react-viewers/bim/bimPanel.tsx index f675b835b..a241b1f24 100644 --- a/src/vim-web/react-viewers/bim/bimPanel.tsx +++ b/src/vim-web/react-viewers/bim/bimPanel.tsx @@ -6,13 +6,13 @@ import React, { useMemo, useState } from 'react' import * as Core from '../../core-viewers' import { whenAllTrue, whenFalse, whenSomeTrue, whenTrue } from '../helpers/utils' -import { CameraRef } from '../state/cameraState' -import { IsolationRef } from '../state/sharedIsolation' +import { FramingApi } from '../state/cameraState' +import { IsolationApi } from '../state/sharedIsolation' import { ViewerState } from '../webgl/viewerState' -import { BimInfoPanelRef } from './bimInfoData' +import { BimInfoPanelApi } from './bimInfoData' import { BimInfoPanel } from './bimInfoPanel' import { BimSearch } from './bimSearch' -import { BimTree, TreeActionRef } from './bimTree' +import { BimTree, TreeActionApi } from './bimTree' import { toTreeData } from './bimTreeData' import { WebglSettings } from '../webgl/settings' import { isFalse } from '../settings/userBoolean' @@ -22,13 +22,13 @@ import { isFalse } from '../settings/userBoolean' // The error appears only in JSFiddle when the module is directly imported in a script tag. export function OptionalBimPanel (props: { viewer: Core.Webgl.Viewer - camera: CameraRef + framing: FramingApi viewerState: ViewerState - isolation: IsolationRef + isolation: IsolationApi visible: boolean settings: WebglSettings - treeRef: React.MutableRefObject - bimInfoRef: BimInfoPanelRef + treeRef: React.MutableRefObject + bimInfoRef: BimInfoPanelApi }) { return whenSomeTrue([ props.settings.ui.panelBimTree, @@ -47,13 +47,13 @@ export function OptionalBimPanel (props: { */ export function BimPanel (props: { viewer: Core.Webgl.Viewer - camera: CameraRef + framing: FramingApi viewerState: ViewerState - isolation: IsolationRef + isolation: IsolationApi visible: boolean settings: WebglSettings - treeRef: React.MutableRefObject - bimInfoRef: BimInfoPanelRef + treeRef: React.MutableRefObject + bimInfoRef: BimInfoPanelApi }) { const tree = useMemo(() => { @@ -82,7 +82,7 @@ export function BimPanel (props: { { - props.viewer.inputs.keyboard.unregister() + props.viewer.inputs.keyboard.active = false } const onBlur = () => { - props.viewer.inputs.keyboard.register() + props.viewer.inputs.keyboard.active = true } return ( @@ -84,7 +84,7 @@ export function BimSearch (props: { className="search-clear vc-absolute vc-right-0 vc-flex vc-h-4 vc-w-4 vc-shrink-0 vc-items-center vc-justify-center vc-rounded-full vc-bg-gray-medium vc-text-white" onClick={onClear} > - {Icons.close({ width: 10, height: 10, fill: 'currentColor' })}{' '} + {Icons.closeIcon({ width: 10, height: 10, fill: 'currentColor' })}{' '} ) : null} diff --git a/src/vim-web/react-viewers/bim/bimTree.tsx b/src/vim-web/react-viewers/bim/bimTree.tsx index 9eea50f56..cdf4292aa 100644 --- a/src/vim-web/react-viewers/bim/bimTree.tsx +++ b/src/vim-web/react-viewers/bim/bimTree.tsx @@ -12,19 +12,19 @@ import 'react-complex-tree/lib/style.css' import ReactTooltip from 'react-tooltip' import * as Core from '../../core-viewers' import { showContextMenu } from '../panels/contextMenu' -import { CameraRef } from '../state/cameraState' +import { FramingApi } from '../state/cameraState' import { ArrayEquals } from '../helpers/data' import { BimTreeData, VimTreeNode } from './bimTreeData' -import { IsolationRef } from '../state/sharedIsolation' +import { IsolationApi } from '../state/sharedIsolation' -import Element3D = Core.Webgl.Element3D -import Viewer = Core.Webgl.Viewer +type IElement3D = Core.Webgl.IElement3D +type Viewer = Core.Webgl.Viewer -export type TreeActionRef = { +export type TreeActionApi = { showAll: () => void hideAll: () => void collapseAll: () => void - selectSiblings: (element: Element3D) => void + selectSiblings: (element: IElement3D) => void } /** @@ -35,15 +35,15 @@ export type TreeActionRef = { * @param isolation current isolation state. */ export function BimTree (props: { - actionRef: React.MutableRefObject + actionRef: React.MutableRefObject viewer: Viewer - camera: CameraRef - objects: Element3D[] - isolation: IsolationRef + framing: FramingApi + objects: IElement3D[] + isolation: IsolationApi treeData: BimTreeData }) { // Data state - const [objects, setObjects] = useState([]) + const [objects, setObjects] = useState([]) // Tree state const [expandedItems, setExpandedItems] = useState([]) @@ -57,15 +57,15 @@ export function BimTree (props: { props.actionRef.current = useMemo( () => ({ showAll: () => { - props.isolation.adapter.current.showAll() + props.isolation.showAll() }, hideAll: () => { - props.isolation.adapter.current.hideAll() + props.isolation.hideAll() }, collapseAll: () => { setExpandedItems([]) }, - selectSiblings: (object: Element3D) => { + selectSiblings: (object: IElement3D) => { const element = object.element const node = props.treeData.getNodeFromElement(element) const siblings = props.treeData.getSiblings(node) @@ -142,8 +142,8 @@ export function BimTree (props: { className="vim-bim-tree vc-mt-2 vc-flex-1 vc-flex vc-w-full vc-min-h-0" ref={div} tabIndex={0} - onFocus={() => (props.viewer.inputs.keyboard.unregister())} - onBlur={() => (props.viewer.inputs.keyboard.register())} + onFocus={() => (props.viewer.inputs.keyboard.active = false)} + onBlur={() => (props.viewer.inputs.keyboard.active = true)} > ({ onKeyUp: (e) => { if (e.key === 'f') { - props.camera.frameSelection.call() + props.framing.frameSelection.call() } if (e.key === 'Escape') { props.viewer.selection.clear() @@ -238,7 +238,7 @@ export function BimTree (props: { // Implement double click onPrimaryAction={(item, _) => { if (doubleClick.isDoubleClick(item.index as number)) { - props.camera.frameSelection.call() + props.framing.frameSelection.call() } }} // Default behavior @@ -262,7 +262,7 @@ export function BimTree (props: { function toggleVisibility ( viewer: Viewer, - isolation: IsolationRef, + isolation: IsolationApi, tree: BimTreeData, index: number ) { @@ -274,9 +274,9 @@ function toggleVisibility ( const visibility = tree.nodes[index].visible if (visibility !== 'vim-visible') { - isolation.adapter.current.show(objs.flatMap(o => o?.instances ?? [])) + isolation.show(objs.flatMap(o => o?.instances ?? [])) } else { - isolation.adapter.current.hide(objs.flatMap(o => o?.instances ?? [])) + isolation.hide(objs.flatMap(o => o?.instances ?? [])) } } @@ -286,7 +286,7 @@ function updateViewerSelection ( nodes: number[], operation: 'add' | 'remove' | 'set' ) { - const objects: Element3D[] = [] + const objects: IElement3D[] = [] nodes.forEach((n) => { const item = tree.nodes[n] const element = item.data.index diff --git a/src/vim-web/react-viewers/bim/bimTreeData.ts b/src/vim-web/react-viewers/bim/bimTreeData.ts index 67a9933a0..ba6a7a12c 100644 --- a/src/vim-web/react-viewers/bim/bimTreeData.ts +++ b/src/vim-web/react-viewers/bim/bimTreeData.ts @@ -30,7 +30,7 @@ export type VimTreeNode = TreeItem & { * @returns */ export function toTreeData ( - vim: Core.Webgl.Vim, + vim: Core.Webgl.IWebglVim, elements: AugmentedElement[], grouping: Grouping ) { @@ -59,11 +59,11 @@ export function toTreeData ( } export class BimTreeData { - vim : Core.Webgl.Vim + vim : Core.Webgl.IWebglVim nodes: Record elementToNode: Map - constructor (vim: Core.Webgl.Vim, map: MapTree) { + constructor (vim: Core.Webgl.IWebglVim, map: MapTree) { this.vim = vim this.nodes = {} this.elementToNode = new Map() diff --git a/src/vim-web/react-viewers/bim/index.ts b/src/vim-web/react-viewers/bim/index.ts index 40b5de5d4..95efb1789 100644 --- a/src/vim-web/react-viewers/bim/index.ts +++ b/src/vim-web/react-viewers/bim/index.ts @@ -1,17 +1,10 @@ -// Type exports -export type * from './bimInfoBody'; - -export type * from './bimInfoHeader'; -export type * from './bimInfoPanel'; -export type * from './bimPanel'; -export type * from './bimSearch'; -export type * from './bimTree'; -export type * from './bimTreeData'; - -export type * as BimInfo from './bimInfoData'; - - -// Not exported -// export type * from './bimInfoObject'; -// export type * from './bimInfoVim'; -// export * from './bimUtils'; \ No newline at end of file +// BIM info panel types for customization +export type { + BimInfoPanelApi, + DataCustomization, + DataRender, + Data, + Section, + Group, + Entry, +} from './bimInfoData' diff --git a/src/vim-web/react-viewers/contextMenu/contextMenuIds.ts b/src/vim-web/react-viewers/contextMenu/contextMenuIds.ts new file mode 100644 index 000000000..f308963ba --- /dev/null +++ b/src/vim-web/react-viewers/contextMenu/contextMenuIds.ts @@ -0,0 +1,17 @@ +export const contextMenuIds = { + showControls: 'showControls', + dividerCamera: 'dividerCamera', + resetCamera: 'resetCamera', + zoomToFit: 'zoomToFit', + dividerSelection: 'dividerSelection', + isolateSelection: 'isolateObject', + selectSimilar: 'selectSimilar', + hideObject: 'hideObject', + showObject: 'showObject', + clearSelection: 'clearSelection', + showAll: 'showAll', + dividerSection: 'dividerSection', + ignoreSection: 'ignoreSection', + resetSection: 'resetSection', + fitSectionToSelection: 'fitSectionToSelection', +} as const diff --git a/src/vim-web/react-viewers/contextMenu/index.ts b/src/vim-web/react-viewers/contextMenu/index.ts new file mode 100644 index 000000000..333981f81 --- /dev/null +++ b/src/vim-web/react-viewers/contextMenu/index.ts @@ -0,0 +1,9 @@ +export { contextMenuIds as Ids } from './contextMenuIds' + +export type { + ContextMenuApi, + ContextMenuCustomization, + ContextMenuElement, + IContextMenuButton, + IContextMenuDivider, +} from '../panels/contextMenu' diff --git a/src/vim-web/react-viewers/controlbar/controlBar.tsx b/src/vim-web/react-viewers/controlbar/controlBar.tsx index f32c36d66..6c6fadc5c 100644 --- a/src/vim-web/react-viewers/controlbar/controlBar.tsx +++ b/src/vim-web/react-viewers/controlbar/controlBar.tsx @@ -9,7 +9,7 @@ import { createSection, IControlBarSection } from './controlBarSection' /** * Reference to manage control bar functionality in the viewer. */ -export type ControlBarRef = { +export type ControlBarApi = { /** * Defines a callback function to dynamically customize the control bar. * @param customization The configuration object specifying the customization options for the control bar. @@ -18,7 +18,7 @@ export type ControlBarRef = { } /** - * A map function that changes the context menu. + * A map function that customizes the control bar sections. */ export type ControlBarCustomization = ( e: IControlBarSection[] diff --git a/src/vim-web/react-viewers/controlbar/controlBarButton.tsx b/src/vim-web/react-viewers/controlbar/controlBarButton.tsx index 10cf41616..a85b25994 100644 --- a/src/vim-web/react-viewers/controlbar/controlBarButton.tsx +++ b/src/vim-web/react-viewers/controlbar/controlBarButton.tsx @@ -1,22 +1,23 @@ import * as Style from './style' +import { IconOptions } from '../icons' -export interface IControlBarButtonItem { +export interface IControlBarButton { id: string, enabled?: (() => boolean) | undefined tip: string action: () => void - icon: ({ height, width, fill, className }) => JSX.Element + icon: (options?: IconOptions) => JSX.Element isOn?: () => boolean style?: (on: boolean) => string } -export function createButton (button: IControlBarButtonItem) { +export function createButton (button: IControlBarButton) { if (button.enabled !== undefined && !button.enabled()) return null const style = (button.style?? Style.buttonDefaultStyle)(button.isOn?.()) return ( ) } diff --git a/src/vim-web/react-viewers/controlbar/controlBarIds.ts b/src/vim-web/react-viewers/controlbar/controlBarIds.ts index 99d1aed99..80058cbb3 100644 --- a/src/vim-web/react-viewers/controlbar/controlBarIds.ts +++ b/src/vim-web/react-viewers/controlbar/controlBarIds.ts @@ -1,43 +1,45 @@ -// Camera buttons -export const cameraSpan = 'controlBar.cameraSpan' -export const cameraFrameSelection = 'controlBar.cameraFrameSelection' -export const cameraFrameScene = 'controlBar.cameraFrameScene' -export const cameraAuto = 'controlBar.cameraAuto' +export const controlBarIds = { + // Camera buttons + cameraSpan: 'controlBar.cameraSpan', + cameraFrameSelection: 'controlBar.cameraFrameSelection', + cameraFrameScene: 'controlBar.cameraFrameScene', + cameraAuto: 'controlBar.cameraAuto', -// Camera Control buttons -export const cursorSpan = 'controlBar.cursorSpan' -export const cursorOrbit = 'controlBar.cursorOrbit' -export const cursorLook = 'controlBar.cursorLook' -export const cursorPan = 'controlBar.cursorPan' -export const cursorZoom = 'controlBar.cursorZoom' -export const cursorZoomWindow = 'controlBar.cursorZoomWindow' + // Camera Control buttons + cursorSpan: 'controlBar.cursorSpan', + cursorOrbit: 'controlBar.cursorOrbit', + cursorLook: 'controlBar.cursorLook', + cursorPan: 'controlBar.cursorPan', + cursorZoom: 'controlBar.cursorZoom', + cursorZoomWindow: 'controlBar.cursorZoomWindow', -// Visibility buttons -export const visibilitySpan = 'controlBar.visibilitySpan' -export const visibilityClearSelection = 'controlBar.visibilityClearSelection' -export const visibilityShowAll = 'controlBar.visibilityShowAll' -export const visibilityIsolateSelection = 'controlBar.visibilityIsolateSelection' -export const visibilityHideSelection = 'controlBar.visibilityHideSelection' -export const visibilityShowSelection = 'controlBar.visibilityShowSelection' -export const visibilityAutoIsolate = 'controlBar.visibilityAutoIsolate' -export const visibilitySettings = 'controlBar.visibilitySettings' + // Visibility buttons + visibilitySpan: 'controlBar.visibilitySpan', + visibilityClearSelection: 'controlBar.visibilityClearSelection', + visibilityShowAll: 'controlBar.visibilityShowAll', + visibilityIsolateSelection: 'controlBar.visibilityIsolateSelection', + visibilityHideSelection: 'controlBar.visibilityHideSelection', + visibilityShowSelection: 'controlBar.visibilityShowSelection', + visibilityAutoIsolate: 'controlBar.visibilityAutoIsolate', + visibilitySettings: 'controlBar.visibilitySettings', -// Section buttons -export const sectioningSpan = 'controlBar.sectioningSpan' -export const sectioningEnable = 'controlBar.sectioningEnable' -export const sectioningVisible = 'controlBar.sectioningVisible' -export const sectioningFitSelection = 'controlBar.sectioningFitSelection' -export const sectioningFitScene = 'controlBar.sectioningFitScene' -export const sectioningAuto = 'controlBar.sectioningAuto' -export const sectioningSettings = 'controlBar.sectioningSettings' + // Section buttons + sectioningSpan: 'controlBar.sectioningSpan', + sectioningEnable: 'controlBar.sectioningEnable', + sectioningVisible: 'controlBar.sectioningVisible', + sectioningFitSelection: 'controlBar.sectioningFitSelection', + sectioningFitScene: 'controlBar.sectioningFitScene', + sectioningAuto: 'controlBar.sectioningAuto', + sectioningSettings: 'controlBar.sectioningSettings', -// Measure buttons -export const measureSpan = 'controlBar.measureSpan' -export const measureEnable = 'controlBar.measureEnable' + // Measure buttons + measureSpan: 'controlBar.measureSpan', + measureEnable: 'controlBar.measureEnable', -// Settings buttons -export const miscSpan = 'controlBar.miscSpan' -export const miscInspector = 'controlBar.miscInspector' -export const miscSettings = 'controlBar.miscSettings' -export const miscHelp = 'controlBar.miscHelp' -export const miscMaximize = 'controlBar.miscMaximize' + // Settings buttons + miscSpan: 'controlBar.miscSpan', + miscInspector: 'controlBar.miscInspector', + miscSettings: 'controlBar.miscSettings', + miscHelp: 'controlBar.miscHelp', + miscMaximize: 'controlBar.miscMaximize', +} as const diff --git a/src/vim-web/react-viewers/controlbar/controlBarSection.tsx b/src/vim-web/react-viewers/controlbar/controlBarSection.tsx index 58e525d6d..480c5e799 100644 --- a/src/vim-web/react-viewers/controlbar/controlBarSection.tsx +++ b/src/vim-web/react-viewers/controlbar/controlBarSection.tsx @@ -1,10 +1,10 @@ -import { createButton, IControlBarButtonItem } from './controlBarButton' +import { createButton, IControlBarButton } from './controlBarButton' import * as Style from './style' export interface IControlBarSection { id: string, enable? : (() => boolean) | undefined - buttons: (IControlBarButtonItem)[] + buttons: (IControlBarButton)[] style?: string } diff --git a/src/vim-web/react-viewers/controlbar/index.ts b/src/vim-web/react-viewers/controlbar/index.ts index fa661d324..618ed5bbc 100644 --- a/src/vim-web/react-viewers/controlbar/index.ts +++ b/src/vim-web/react-viewers/controlbar/index.ts @@ -1,8 +1,8 @@ -// Full exports -export * as Ids from './controlBarIds' +// Constant namespaces (all values are public for customization) +export { controlBarIds as Ids } from './controlBarIds' export * as Style from './style' - -// Type Exports -export type * from './controlBarButton' -export type * from './controlBar' -export type * from './controlBarSection' + +// Public types +export type { ControlBarApi, ControlBarCustomization } from './controlBar' +export type { IControlBarSection } from './controlBarSection' +export type { IControlBarButton } from './controlBarButton' diff --git a/src/vim-web/react-viewers/errors/index.ts b/src/vim-web/react-viewers/errors/index.ts index fc363f80e..5441d64c6 100644 --- a/src/vim-web/react-viewers/errors/index.ts +++ b/src/vim-web/react-viewers/errors/index.ts @@ -1,2 +1,2 @@ -export * from './errors' -export * as Style from './errorStyle' \ No newline at end of file +// Error styling utilities +export * as Style from './errorStyle' diff --git a/src/vim-web/react-viewers/generic/genericField.tsx b/src/vim-web/react-viewers/generic/genericField.tsx index 44a9619cc..dff83bd7b 100644 --- a/src/vim-web/react-viewers/generic/genericField.tsx +++ b/src/vim-web/react-viewers/generic/genericField.tsx @@ -3,22 +3,22 @@ import React from "react"; import { InputNumber } from "./inputNumber"; import { StateRef, useRefresher } from "../helpers/reactUtils"; -// Base interface for a panel field. -interface BaseGenericEntry { +// A text field. +export interface GenericTextEntry { + type: "text"; id: string; label: string; enabled?: () => boolean; visible?: () => boolean; -} - -// A text field. -export interface GenericTextEntry extends BaseGenericEntry { - type: "text"; state: StateRef; } -export interface GenericNumberEntry extends BaseGenericEntry { +export interface GenericNumberEntry { type: "number"; + id: string; + label: string; + enabled?: () => boolean; + visible?: () => boolean; state: StateRef; min?: number; max?: number; @@ -26,8 +26,12 @@ export interface GenericNumberEntry extends BaseGenericEntry { } // A boolean field. -export interface GenericBoolEntry extends BaseGenericEntry { +export interface GenericBoolEntry { type: "bool"; + id: string; + label: string; + enabled?: () => boolean; + visible?: () => boolean; state: StateRef; } diff --git a/src/vim-web/react-viewers/generic/genericPanel.tsx b/src/vim-web/react-viewers/generic/genericPanel.tsx index 29d6e936b..3bb89b3b7 100644 --- a/src/vim-web/react-viewers/generic/genericPanel.tsx +++ b/src/vim-web/react-viewers/generic/genericPanel.tsx @@ -1,9 +1,9 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; -import { Icons } from ".."; +import * as Icons from '../icons'; import { StateRef } from "../helpers/reactUtils"; import { useFloatingPanelPosition } from "../helpers/layout"; import { GenericEntryType, GenericEntry } from "./genericField"; -import { Customizer, useCustomizer } from "../helpers/customizer"; +import { useCustomizer } from "../helpers/customizer"; // Generic props for the panel. export interface GenericPanelProps { @@ -14,9 +14,11 @@ export interface GenericPanelProps { anchorElement: HTMLElement | null; } -export type GenericPanelHandle = Customizer; +export type GenericPanelApi = { + customize(fn: (entries: GenericEntryType[]) => GenericEntryType[]): void; +}; -export const GenericPanel = forwardRef((props, ref) => { +export const GenericPanel = forwardRef((props, ref) => { const panelRef = useRef(null); const panelPosition = useFloatingPanelPosition( @@ -50,7 +52,7 @@ export const GenericPanel = forwardRef((p className="vc-flex vc-border-none vc-bg-transparent vc-text-sm vc-cursor-pointer" onClick={props.onClose ?? (() => props.showPanel.set(false))} > - {Icons.close({ height: 12, width: 12, fill: "currentColor" })} + {Icons.closeIcon({ height: 12, width: 12, fill: "currentColor" })}
diff --git a/src/vim-web/react-viewers/generic/index.ts b/src/vim-web/react-viewers/generic/index.ts index 38e5a232c..2cca5c283 100644 --- a/src/vim-web/react-viewers/generic/index.ts +++ b/src/vim-web/react-viewers/generic/index.ts @@ -1,7 +1,8 @@ - -// type exports -export type * from './genericField'; -export {type GenericPanelHandle} from './genericPanel'; - -// Not exported -// export * from './inputNumber' \ No newline at end of file +// Public types for generic panel customization +export type { GenericPanelApi } from './genericPanel' +export type { + GenericEntryType, + GenericTextEntry, + GenericNumberEntry, + GenericBoolEntry, +} from './genericField' diff --git a/src/vim-web/react-viewers/helpers/cursor.ts b/src/vim-web/react-viewers/helpers/cursor.ts index 960e1e49f..faf62312a 100644 --- a/src/vim-web/react-viewers/helpers/cursor.ts +++ b/src/vim-web/react-viewers/helpers/cursor.ts @@ -4,7 +4,7 @@ import * as Core from '../../core-viewers' import PointerMode = Core.PointerMode -import Viewer = Core.Webgl.Viewer +type Viewer = Core.Webgl.Viewer /** * Css classes for custom cursors. @@ -56,14 +56,11 @@ export class CursorManager { */ register () { // Update and Register cursor for pointers - this.setCursor(pointerToCursor(this._viewer.inputs.pointerActive)) + this.setCursor(pointerToCursor(this._viewer.inputs.pointerMode)) const sub1 = this._viewer.inputs.onPointerModeChanged.subscribe(() => this._updateCursor() ) - const sub2 = this._viewer.inputs.onPointerOverrideChanged.subscribe(() => - this._updateCursor() - ) const sub3 = this._viewer.gizmos.sectionBox.onStateChanged.subscribe(() => { if (!this._viewer.gizmos.sectionBox.visible) { this._boxHover = false @@ -74,7 +71,7 @@ export class CursorManager { this._boxHover = hover this._updateCursor() }) - this._subscriptions = [sub1, sub2, sub3, sub4] + this._subscriptions = [sub1, sub3, sub4] } /** @@ -103,7 +100,7 @@ export class CursorManager { ? pointerToCursor(this._viewer.inputs.pointerOverride) : this._boxHover ? 'cursor-section-box' - : pointerToCursor(this._viewer.inputs.pointerActive) + : pointerToCursor(this._viewer.inputs.pointerMode) this.setCursor(cursor) } } diff --git a/src/vim-web/react-viewers/helpers/customizer.ts b/src/vim-web/react-viewers/helpers/customizer.ts index f1adfdc89..7af44baf1 100644 --- a/src/vim-web/react-viewers/helpers/customizer.ts +++ b/src/vim-web/react-viewers/helpers/customizer.ts @@ -1,12 +1,12 @@ import { useEffect, useImperativeHandle, useRef, useState } from "react"; -export interface Customizer { +interface ICustomizer { customize(fn: (entries: TData) => TData); } export function useCustomizer( baseEntries: TData, - ref: React.Ref> + ref: React.Ref> ) { const customization = useRef<(entries: TData) => TData>(); const [entries, setEntries] = useState(baseEntries); diff --git a/src/vim-web/react-viewers/helpers/element.ts b/src/vim-web/react-viewers/helpers/element.ts index bc5642c1b..f4cc3aef4 100644 --- a/src/vim-web/react-viewers/helpers/element.ts +++ b/src/vim-web/react-viewers/helpers/element.ts @@ -12,7 +12,7 @@ export type AugmentedElement = BIM.IElement & { levelName: string worksetName: string } -export async function getElements (vim: Core.Webgl.Vim) { +export async function getElements (vim: Core.Webgl.IWebglVim) { if (!vim.bim) return [] const [elements, bimDocument, category, levels, worksets] = await Promise.all( [ diff --git a/src/vim-web/react-viewers/helpers/index.ts b/src/vim-web/react-viewers/helpers/index.ts index fe84c9287..0816ef8b0 100644 --- a/src/vim-web/react-viewers/helpers/index.ts +++ b/src/vim-web/react-viewers/helpers/index.ts @@ -1,15 +1,8 @@ -// full export -export * as ReactUtils from './reactUtils'; +// Public ref types — hooks and utilities are internal +export type { + StateRef, + FuncRef, +} from './reactUtils' +export { createState } from './reactUtils' -// Type exports -export type * from './cursor'; -export type * from './data'; -export type * from './element'; -export type * from './loadRequest'; -export type * from './requestResult'; - -// Not exported - -// export * from './utils'; -// export * from './cameraObserver'; -// export * from './fullScreenObserver'; +export type { AugmentedElement } from './element' diff --git a/src/vim-web/react-viewers/helpers/loadRequest.ts b/src/vim-web/react-viewers/helpers/loadRequest.ts index cc5d967e1..9d8ff150e 100644 --- a/src/vim-web/react-viewers/helpers/loadRequest.ts +++ b/src/vim-web/react-viewers/helpers/loadRequest.ts @@ -1,86 +1,81 @@ import * as Core from '../../core-viewers' +import { AsyncQueue } from '../../utils/asyncQueue' import { LoadingError } from '../webgl/loading' -import { ControllablePromise } from '../../utils' type RequestCallbacks = { - onProgress: (p: Core.Webgl.IProgressLogs) => void + onProgress: (p: Core.IProgress) => void onError: (e: LoadingError) => void onDone: () => void } /** * Class to handle loading a request. + * Implements ILoadRequest for compatibility with Ultra viewer's load request interface. + * @internal */ -export class LoadRequest { - readonly source - private _callbacks : RequestCallbacks - private _request: Core.Webgl.VimRequest +export class LoadRequest implements Core.Webgl.IWebglLoadRequest { + private _sourceUrl: string | undefined + private _request: Core.Webgl.IWebglLoadRequest + private _callbacks: RequestCallbacks + private _onLoaded?: (vim: Core.Webgl.IWebglVim) => Promise | void + private _progressQueue = new AsyncQueue() + private _resultPromise: Promise> - 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, settings?: Core.Webgl.VimPartialSettings) { - this.source = source + constructor ( + callbacks: RequestCallbacks, + request: Core.Webgl.IWebglLoadRequest, + sourceUrl: string | undefined, + onLoaded?: (vim: Core.Webgl.IWebglVim) => Promise | void + ) { + this._sourceUrl = sourceUrl this._callbacks = callbacks - this.startRequest(source, settings) + this._onLoaded = onLoaded + this._request = request + this._resultPromise = this.trackAndGetResult() } - private async startRequest (source: Core.Webgl.RequestSource, settings?: Core.Webgl.VimPartialSettings) { - this._request = await Core.Webgl.request(source, settings) - 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.onSuccess() - } - } + private async trackAndGetResult (): Promise> { + try { + for await (const progress of this._request.getProgress()) { + this._callbacks.onProgress(progress) + this._progressQueue.push(progress) + } - 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() + const result = await this._request.getResult() + if (result.isSuccess === false) { + this._callbacks.onError({ url: this._sourceUrl, error: result.error }) + } else { + await this._onLoaded?.(result.vim) + this._callbacks.onDone() + } + this._progressQueue.close() + return result + } catch (err) { + this._callbacks.onError({ url: this._sourceUrl, error: String(err) }) + this._progressQueue.close() + throw err + } } - private onError (error: string) { - this._callbacks.onError({ - url: this.source.url, - error - }) - this.end() + get isCompleted () { + return this._request.isCompleted } - private end () { - this._isDone = true - this._progressPromise.resolve() - this._completionPromise.resolve() + async * getProgress (): AsyncGenerator { + yield * this._progressQueue } - async * getProgress () : AsyncGenerator { - while (!this._isDone) { - await this._progressPromise.promise - yield this._progress - } + getResult () { + return this._resultPromise } - async getResult () { - await this._completionPromise - return this._request.getResult() + async getVim () { + const result = await this.getResult() + if (result.isSuccess === false) throw new Error(result.error) + return result.vim } abort () { this._request.abort() - this.onError('Request aborted') } } diff --git a/src/vim-web/react-viewers/helpers/reactUtils.ts b/src/vim-web/react-viewers/helpers/reactUtils.ts index 24c605ff0..a356e94cc 100644 --- a/src/vim-web/react-viewers/helpers/reactUtils.ts +++ b/src/vim-web/react-viewers/helpers/reactUtils.ts @@ -1,25 +1,26 @@ /** * @module state-action-refs * - * This module exports various React hooks and TypeScript interfaces to create references for state, - * actions, and functions. These references allow you to store and manipulate values and functions, - * as well as dynamically inject additional behavior (using prepend and append methods) into their call chains. + * Reactive state and function references for the React viewer layer. * - * The provided hooks include: - * - useStateRef: A state reference with event dispatching and validation. - * - useActionRef: A reference for an action (a function with no arguments). - * - useArgActionRef: A reference for an action that accepts an argument. - * - useFuncRef: A reference for a function returning a value. - * - useAsyncFuncRef: A reference for an asynchronous function. - * - useArgFuncRef: A reference for a function that accepts an argument and returns a value. + * - `StateRef` — observable state with get/set/onChange + * - `FuncRef` — callable function reference with `update` middleware + * - `useFuncRef(fn)` — creates a FuncRef (sync or async, with or without args) + * + * Common shapes: `FuncRef`, `FuncRef>`, `FuncRef` */ import { useEffect, useMemo, useRef, useState } from "react"; -import { ISimpleEvent, SimpleEventDispatcher } from "ste-simple-events"; +import type { ISimpleEvent } from '../../core-viewers/shared/events' +import { SimpleEventDispatcher } from 'ste-simple-events' /** - * Interface for a state reference. - * Provides methods to get, set, and confirm the current state. + * Observable state container. Read, write, and subscribe to changes. + * + * @example + * state.get() // Read current value + * state.set(true) // Update value + * state.onChange.subscribe(v => ...) // Subscribe (returns unsubscribe fn) */ export interface StateRef { /** @@ -40,9 +41,15 @@ export interface StateRef { } /** - * A basic implementation of StateRef without React. + * Creates a standalone StateRef without React hooks. + * Use this when you need observable state outside of React components. */ -export class MutableState implements StateRef { +export function createState(initial: T): StateRef { + return new MutableState(initial) +} + +/** @internal */ +class MutableState implements StateRef { private _value: T; private _onChange = new SimpleEventDispatcher(); @@ -198,341 +205,72 @@ export function useStateRef(initialValue: T | (() => T), isLazy = false) { } /** - * Interface for an action reference (a function with no arguments). - * Provides methods to call, get, set, and inject code before or after the stored function. - */ -export interface ActionRef { - /** - * Invokes the stored action. - */ - call(): void; - /** - * Retrieves the current action function. - * @returns The stored action function. - */ - get(): () => void; - /** - * Sets the stored action function. - * @param fn - The new action function. - */ - set(fn: () => void): void; - /** - * Prepends a function to be executed before the stored action. - * @param fn - The function to run before the original action. - */ - prepend(fn: () => void): void; - /** - * Appends a function to be executed after the stored action. - * @param fn - The function to run after the original action. - */ - append(fn: () => void): void; -} - -/** - * Custom hook to create an action reference. + * A callable function reference with middleware support. + * All ref types (sync, async, with/without args) use this single interface. * - * @param action - The initial action function. - * @returns An object implementing ActionRef. - */ -export function useActionRef(action: () => void): ActionRef { - const ref = useRef(action); - return { - call() { - ref?.current(); - }, - get() { - return ref.current; - }, - set(fn: () => void) { - ref.current = fn; - }, - prepend(fn: () => void) { - const oldFn = ref.current; - ref.current = () => { - fn(); - oldFn(); - }; - }, - append(fn: () => void) { - const oldFn = ref.current; - ref.current = () => { - oldFn(); - fn(); - }; - }, - }; -} - -/** - * Interface for an action reference that accepts an argument. - * Provides methods to call with an argument, get, set, and inject code before or after the stored function. - */ -export interface ArgActionRef { - /** - * Invokes the stored action with the provided argument. - * @param arg - The argument to pass to the action. - */ - call(arg: T): void; - /** - * Retrieves the current action function. - * @returns The stored action function. - */ - get(): (arg: T) => void; - /** - * Sets the stored action function. - * @param fn - The new action function. - */ - set(fn: (arg: T) => void): void; - /** - * Prepends a function to be executed before the stored action. - * @param fn - The function to run before the original action. - */ - prepend(fn: (arg: T) => void): void; - /** - * Appends a function to be executed after the stored action. - * @param fn - The function to run after the original action. - */ - append(fn: (arg: T) => void): void; -} - -/** - * Custom hook to create an argument-based action reference. + * When `TArg` is `void`, `call()` can be invoked without arguments. + * For async functions, use `FuncRef>`. * - * @param action - The initial action function that accepts an argument. - * @returns An object implementing ArgActionRef. + * @example + * ```ts + * ref.call() // Execute (no-arg) + * ref.call(box) // Execute (with arg) + * ref.set(() => newImpl()) // Replace implementation + * ref.update(prev => (...args) => { // Wrap with middleware + * console.log('before') + * const result = prev(...args) + * console.log('after') + * return result + * }) + * ``` */ -export function useArgActionRef(action: (arg: T) => void): ArgActionRef { - const ref = useRef(action); - return { - call(arg: T) { - ref?.current(arg); - }, - get() { - return ref.current; - }, - set(fn: (arg: T) => void) { - ref.current = fn; - }, - prepend(fn: (arg: T) => void) { - const oldFn = ref.current; - ref.current = (arg: T) => { - fn(arg); - oldFn(arg); - }; - }, - append(fn: (arg: T) => void) { - const oldFn = ref.current; - ref.current = (arg: T) => { - oldFn(arg); - fn(arg); - }; - }, - }; -} - -/** - * Interface for a function reference that returns a value. - * Provides methods to call, get, set, and inject code before or after the stored function. - */ -export interface FuncRef { - /** - * Invokes the stored function and returns its value. - * @returns The result of the function call. - */ - call(): T; - /** - * Retrieves the current function. - * @returns The stored function. - */ - get(): () => T; - /** - * Sets the stored function. - * @param fn - The new function. - */ - set(fn: () => T): void; +export interface FuncRef { + /** Invokes the stored function. When `TArg` is `void`, no argument is needed. */ + call(arg: TArg): TReturn; + /** Returns the current function. */ + get(): (arg: TArg) => TReturn; + /** Replaces the stored function. */ + set(fn: (arg: TArg) => TReturn): void; /** - * Prepends a function to be executed before the stored function. - * @param fn - The function to run before the original function. - */ - prepend(fn: () => void): void; - /** - * Appends a function to be executed after the stored function. - * @param fn - The function to run after the original function. - */ - append(fn: () => void): void; -} - -/** - * Custom hook to create a function reference. - * - * @param fn - The initial function. - * @returns An object implementing FuncRef. - */ -export function useFuncRef(fn: () => T): FuncRef { - const ref = useRef(fn); - return { - call() { - return ref?.current(); - }, - get() { - return ref.current; - }, - set(fn: () => T) { - ref.current = fn; - }, - prepend(fn: () => void) { - const oldFn = ref.current; - ref.current = () => { - fn(); - return oldFn(); - }; - }, - append(fn: () => void) { - const oldFn = ref.current; - ref.current = () => { - const result = oldFn(); - fn(); - return result; - }; - }, - }; -} - -/** - * Interface for an asynchronous function reference. - * Provides methods to call, get, set, and inject code before or after the stored async function. - */ -export interface AsyncFuncRef { - /** - * Invokes the stored asynchronous function and returns a promise of its result. - * @returns A promise resolving to the result of the async function. - */ - call(): Promise; - /** - * Retrieves the current asynchronous function. - * @returns The stored async function. - */ - get(): () => Promise; - /** - * Sets the stored asynchronous function. - * @param fn - The new async function. - */ - set(fn: () => Promise): void; - /** - * Prepends a function to be executed before the stored async function. - * @param fn - The function to run before the original async function. - */ - prepend(fn: () => Promise | void): void; - /** - * Appends a function to be executed after the stored async function. - * @param fn - The function to run after the original async function. - */ - append(fn: () => Promise | void): void; -} - -/** - * Custom hook to create an asynchronous function reference. - * - * @param fn - The initial asynchronous function. - * @returns An object implementing AsyncFuncRef. - */ -export function useAsyncFuncRef(fn: () => Promise): AsyncFuncRef { - const ref = useRef(fn); - return { - async call() { - return ref?.current(); - }, - get() { - return ref.current; - }, - set(fn: () => Promise) { - ref.current = fn; - }, - prepend(fn: () => Promise | void) { - const oldFn = ref.current; - ref.current = async () => { - await fn(); - return await oldFn(); - }; - }, - append(fn: () => Promise | void) { - const oldFn = ref.current; - ref.current = async () => { - const result = await oldFn(); - await fn(); - return result; - }; - }, - }; -} - -/** - * Interface for a function reference that accepts an argument and returns a result. - * Provides methods to call, get, set, and inject code before or after the stored function. - */ -export interface ArgFuncRef { - /** - * Invokes the stored function with the provided argument. - * @param arg - The argument to pass to the function. - * @returns The result of the function call. - */ - call(arg: TArg): TResult; - /** - * Retrieves the current function. - * @returns The stored function. - */ - get(): (arg: TArg) => TResult; - /** - * Sets the stored function. - * @param fn - The new function. - */ - set(fn: (arg: TArg) => TResult): void; - /** - * Prepends a function to be executed before the stored function. - * @param fn - The function to run before the original function. - */ - prepend(fn: (arg: TArg) => void): void; - /** - * Appends a function to be executed after the stored function. - * @param fn - The function to run after the original function. + * Wraps the stored function with a transform. + * Use this to inject behavior before/after the original function. + * + * @example + * ```ts + * // Append behavior + * ref.update(prev => async () => { await prev(); doAfter() }) + * // Prepend behavior + * ref.update(prev => async () => { doBefore(); return await prev() }) + * ``` */ - append(fn: (arg: TArg) => void): void; + update(transform: (prev: (arg: TArg) => TReturn) => (arg: TArg) => TReturn): void; } /** - * Custom hook to create an argument-based function reference. + * Creates a function reference. Works for both sync and async, with or without arguments. * - * @param fn - The initial function that accepts an argument and returns a result. - * @returns An object implementing ArgFuncRef. + * @example + * const action = useFuncRef(() => console.log('hi')) // FuncRef + * const query = useFuncRef(async () => fetch('/api')) // FuncRef> + * const setter = useFuncRef((box: Box3) => apply(box)) // FuncRef */ -export function useArgFuncRef( - fn: (arg: TArg) => TResult -): ArgFuncRef { +export function useFuncRef(fn: () => TReturn): FuncRef +export function useFuncRef(fn: (arg: TArg) => TReturn): FuncRef +export function useFuncRef(fn: (arg: TArg) => TReturn): FuncRef { const ref = useRef(fn); return { call(arg: TArg) { - return ref?.current(arg); + return ref.current(arg); }, get() { return ref.current; }, - set(fn: (arg: TArg) => TResult) { + set(fn: (arg: TArg) => TReturn) { ref.current = fn; }, - prepend(fn: (arg: TArg) => void) { - const oldFn = ref.current; - ref.current = (arg: TArg) => { - fn(arg); - return oldFn(arg); - }; - }, - append(fn: (arg: TArg) => void) { - const oldFn = ref.current; - ref.current = (arg: TArg) => { - const result = oldFn(arg); - fn(arg); - return result; - }; + update(transform: (prev: (arg: TArg) => TReturn) => (arg: TArg) => TReturn) { + ref.current = transform(ref.current); }, }; } + diff --git a/src/vim-web/react-viewers/helpers/utils.ts b/src/vim-web/react-viewers/helpers/utils.ts index 1f13eb00d..176c31c7f 100644 --- a/src/vim-web/react-viewers/helpers/utils.ts +++ b/src/vim-web/react-viewers/helpers/utils.ts @@ -23,17 +23,3 @@ export function whenSomeTrue (value: (UserBoolean| boolean)[], element: JSX.Elem export function whenSomeFalse (value: (UserBoolean| boolean)[], element: JSX.Element) { return value.some(isFalse) ? element : null } - - -/** - * Makes all fields optional recursively - * @template T - The type to make recursively partial - * @returns A type with all nested properties made optional - */ -export type RecursivePartial = { - [P in keyof T]?: T[P] extends (infer U)[] - ? RecursivePartial[] - : T[P] extends object - ? RecursivePartial - : T[P] -} \ No newline at end of file diff --git a/src/vim-web/react-viewers/icons.tsx b/src/vim-web/react-viewers/icons.tsx index 950bb81a5..23f839929 100644 --- a/src/vim-web/react-viewers/icons.tsx +++ b/src/vim-web/react-viewers/icons.tsx @@ -13,15 +13,15 @@ import React from 'react' * Common Icon Options. */ export type IconOptions = { - height: number | string - width: number | string - fill: string + height?: number | string + width?: number | string + fill?: string className?: string } // Common -export function pointer({ height, width, fill, className }: IconOptions) { +export function pointer({ height = 20, width = 20, fill = 'currentColor', className }: IconOptions = {}) { return ( @@ -45,7 +45,7 @@ export function filter({ height, width, fill, className }: IconOptions) { } -export function slidersHoriz ({ height, width, fill, className }: IconOptions) { +export function slidersHoriz ({ height = 20, width = 20, fill = 'currentColor', className }: IconOptions = {}) { return ( @@ -155,7 +155,7 @@ export function home ({ height, width, fill, className }: IconOptions) { ) } -export function fullsScreen ({ height, width, fill, className }: IconOptions) { +export function fullScreen ({ height = 20, width = 20, fill = 'currentColor', className }: IconOptions = {}) { return ( @@ -182,7 +182,7 @@ export function minimize ({ height, width, fill, className }: IconOptions) { ) } -export function treeView ({ height, width, fill, className }: IconOptions) { +export function treeView ({ height = 20, width = 20, fill = 'currentColor', className }: IconOptions = {}) { return ( ) } -export function more ({ height, width, fill, className }: IconOptions) { +export function more ({ height = 20, width = 20, fill = 'currentColor', className }: IconOptions = {}) { return ( @@ -236,12 +236,12 @@ export function arrowLeft ({ height, width, fill, className }: IconOptions) { ) } -export function fullArrowLeft ({ height, width, fill, className }: IconOptions) { +export function fullArrowLeft ({ height = 20, width = 20, fill = 'currentColor', className }: IconOptions = {}) { 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'} >
) } diff --git a/src/vim-web/react-viewers/panels/sectionBoxPanel.tsx b/src/vim-web/react-viewers/panels/sectionBoxPanel.tsx index 8bc5c7b05..833d43829 100644 --- a/src/vim-web/react-viewers/panels/sectionBoxPanel.tsx +++ b/src/vim-web/react-viewers/panels/sectionBoxPanel.tsx @@ -1,6 +1,6 @@ import { forwardRef } from "react"; -import { SectionBoxRef } from "../state/sectionBoxState"; -import { GenericPanel, GenericPanelHandle } from "../generic/genericPanel"; +import { SectionBoxApi } from "../state/sectionBoxState"; +import { GenericPanel, GenericPanelApi } from "../generic/genericPanel"; export const Ids = { topOffset: "sectionBoxPanel.TopOffset", @@ -8,7 +8,7 @@ export const Ids = { bottomOffset: "sectionBoxPanel.BottomOffset", } -export const SectionBoxPanel = forwardRef( +export const SectionBoxPanel = forwardRef( (props, ref) => { return ( - {Icons.close({ ...iconOptions, className: 'vc-max-h-full vc-max-w-full' })} + {Icons.closeIcon({ ...iconOptions, className: 'vc-max-h-full vc-max-w-full' })}
diff --git a/src/vim-web/react-viewers/settings/index.ts b/src/vim-web/react-viewers/settings/index.ts index 54bef09f4..fcca87687 100644 --- a/src/vim-web/react-viewers/settings/index.ts +++ b/src/vim-web/react-viewers/settings/index.ts @@ -1,9 +1,12 @@ +// Settings types +export type { + SettingsCustomization, + SettingsItem, + SettingsSubtitle, + SettingsToggle, + SettingsBox, + SettingsElement, +} from './settingsItem' -//Full exports -export * from './anySettings'; -export * from './settingsStorage'; -export * from './userBoolean'; - -// Type exports -export type * from './settingsPanel'; -export type * from './settingsState'; +// Settings utilities +export { type UserBoolean, isTrue, isFalse } from './userBoolean' diff --git a/src/vim-web/react-viewers/settings/settingsItem.ts b/src/vim-web/react-viewers/settings/settingsItem.ts index 218bdfb12..6cc96a71e 100644 --- a/src/vim-web/react-viewers/settings/settingsItem.ts +++ b/src/vim-web/react-viewers/settings/settingsItem.ts @@ -2,29 +2,27 @@ import { AnySettings } from './anySettings' import { UserBoolean } from './userBoolean' -export type SettingsCustomizer = (items: SettingsItem[]) => SettingsItem[] +export type SettingsCustomization = (items: SettingsItem[]) => SettingsItem[] export type SettingsItem = SettingsSubtitle | SettingsToggle | SettingsBox | SettingsElement -export type BaseSettingsItem = { - type: string - key: string -} - -export type SettingsSubtitle = BaseSettingsItem & { +export type SettingsSubtitle = { type: 'subtitle' + key: string title: string } -export type SettingsToggle = BaseSettingsItem & { +export type SettingsToggle = { type: 'toggle' + key: string label: string getter: (settings: T) => UserBoolean setter: (settings: T, b: boolean) => void } -export type SettingsBox = BaseSettingsItem & { +export type SettingsBox = { type: 'box' + key: string label: string info: string transform: (value: number) => number @@ -32,7 +30,8 @@ export type SettingsBox = BaseSettingsItem & { setter: (settings: T, b: number) => void } -export type SettingsElement = BaseSettingsItem & { +export type SettingsElement = { type: 'element' + key: string element: JSX.Element -} \ No newline at end of file +} diff --git a/src/vim-web/react-viewers/settings/settingsState.ts b/src/vim-web/react-viewers/settings/settingsState.ts index bf02fefce..df13ec8e3 100644 --- a/src/vim-web/react-viewers/settings/settingsState.ts +++ b/src/vim-web/react-viewers/settings/settingsState.ts @@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { isTrue } from './userBoolean' import { saveSettingsToLocal } from './settingsStorage' import { StateRef, useStateRef } from '../helpers/reactUtils' -import { SettingsCustomizer } from './settingsItem' +import { SettingsCustomization } from './settingsItem' import { AnySettings } from './anySettings' import { RecursivePartial } from '../../utils' import deepmerge from 'deepmerge' @@ -15,7 +15,7 @@ export type SettingsState = { value: T update: (updater: (s: T) => void) => void register: (action: (s: T) => void) => void - customizer : StateRef> + customizer : StateRef> } /** @@ -29,7 +29,7 @@ export function useSettings ( const merged = createSettings(value, defaultSettings) const [settings, setSettings] = useState(merged) const onUpdate = useRef<(s: T) => void>() - const customizer = useStateRef>(settings => settings) + const customizer = useStateRef>(settings => settings) const update = function (updater: (s: T) => void) { const next = { ...settings } // Shallow copy diff --git a/src/vim-web/react-viewers/settings/userBoolean.ts b/src/vim-web/react-viewers/settings/userBoolean.ts index b138e2c52..3b65c1ecf 100644 --- a/src/vim-web/react-viewers/settings/userBoolean.ts +++ b/src/vim-web/react-viewers/settings/userBoolean.ts @@ -1,6 +1,7 @@ /** - * Represents a boolean value that can also be locked to always true or false - * @typedef {boolean | 'AlwaysTrue' | 'AlwaysFalse'} UserBoolean + * A boolean setting that can be locked by the host application. + * - `true` / `false` — user-toggleable default value, shown in settings UI. + * - `"AlwaysTrue"` / `"AlwaysFalse"` — locked value, hidden from settings UI. */ export type UserBoolean = boolean | 'AlwaysTrue' | 'AlwaysFalse' diff --git a/src/vim-web/react-viewers/state/cameraState.ts b/src/vim-web/react-viewers/state/cameraState.ts index ae85fef02..6f89a84d1 100644 --- a/src/vim-web/react-viewers/state/cameraState.ts +++ b/src/vim-web/react-viewers/state/cameraState.ts @@ -4,20 +4,38 @@ import { useEffect } from 'react' import * as THREE from 'three' -import { SectionBoxRef } from './sectionBoxState' -import { ActionRef, AsyncFuncRef, StateRef, useActionRef, useAsyncFuncRef, useStateRef } from '../helpers/reactUtils' -import { ISignal } from 'ste-signals' +import { SectionBoxApi } from './sectionBoxState' +import { FuncRef, StateRef, useFuncRef, useStateRef } from '../helpers/reactUtils' +import type { ISignal } from '../../core-viewers/shared/events' -export interface CameraRef { +/** + * High-level framing controls for the React viewer. + * Provides semantic operations like "frame selection" and "frame scene". + * + * For low-level camera movement (orbit, pan, zoom, snap/lerp), use + * `viewer.core.camera` which exposes {@link IWebglCamera}. + * + * @example + * // Frame the current selection with animation + * viewer.framing.frameSelection.call() + * + * // For direct camera manipulation, use the core camera: + * viewer.core.camera.lerp(1).frame('all') + * viewer.core.camera.snap().set(position, target) + */ +export interface FramingApi { + /** When true, automatically frames the camera on the selection whenever it changes. */ autoCamera: StateRef - reset : ActionRef - - frameSelection: AsyncFuncRef - frameScene: AsyncFuncRef - - // Allow to override these at the viewer level - getSelectionBox: AsyncFuncRef - getSceneBox: AsyncFuncRef + /** Resets the camera to its last saved position. */ + reset: FuncRef + /** Frames the camera on the current selection (or scene if nothing selected). */ + frameSelection: FuncRef> + /** Frames the camera to show all loaded geometry. */ + frameScene: FuncRef> + /** Returns the bounding box of the current selection, or undefined if nothing selected. */ + getSelectionBox: FuncRef> + /** Returns the bounding box of all loaded geometry. */ + getSceneBox: FuncRef> } interface ICameraAdapter { @@ -28,7 +46,7 @@ interface ICameraAdapter { getSceneBox: () => Promise } -export function useCamera(adapter: ICameraAdapter, section: SectionBoxRef){ +export function useFraming(adapter: ICameraAdapter, section: SectionBoxApi){ const autoCamera = useStateRef(false) autoCamera.useOnChange((v) => { @@ -43,21 +61,21 @@ export function useCamera(adapter: ICameraAdapter, section: SectionBoxRef){ } // Reframe on section box change. - section.sectionSelection.append(refresh) - section.sectionScene.append(refresh) + section.sectionSelection.update(prev => async () => { await prev(); refresh() }) + section.sectionScene.update(prev => async () => { await prev(); refresh() }) adapter.onSelectionChanged.sub(refresh) },[]) - const reset = useActionRef(() => adapter.resetCamera(1)) - const getSelectionBox = useAsyncFuncRef(adapter.getSelectionBox) - const getSceneBox = useAsyncFuncRef(adapter.getSceneBox) + const reset = useFuncRef(() => adapter.resetCamera(1)) + const getSelectionBox = useFuncRef(adapter.getSelectionBox) + const getSceneBox = useFuncRef(adapter.getSceneBox) - const frameSelection = useAsyncFuncRef(async () => { + const frameSelection = useFuncRef(async () => { const box = (await getSelectionBox.call()) ?? (await getSceneBox.call()) frame(adapter, section, box) }) - const frameScene = useAsyncFuncRef(async () => { + const frameScene = useFuncRef(async () => { const box = await getSceneBox.call() frame(adapter, section, box) }) @@ -69,14 +87,14 @@ export function useCamera(adapter: ICameraAdapter, section: SectionBoxRef){ reset, frameSelection, frameScene - } as CameraRef + } as FramingApi } -function frame(adapter: ICameraAdapter, section: SectionBoxRef, box: THREE.Box3) { +function frame(adapter: ICameraAdapter, section: SectionBoxApi, box: THREE.Box3) { if(!box) return // Take into account section box for framing. - if(section.enable.get()){ + if(section.active.get()){ const sectionBox = section.getBox(); if (section) { box.intersect(sectionBox); diff --git a/src/vim-web/react-viewers/state/controlBarState.tsx b/src/vim-web/react-viewers/state/controlBarState.tsx index 6e2942519..f0cce1cd7 100644 --- a/src/vim-web/react-viewers/state/controlBarState.tsx +++ b/src/vim-web/react-viewers/state/controlBarState.tsx @@ -1,5 +1,5 @@ import * as Core from "../../core-viewers"; -import { CameraRef } from './cameraState'; +import { FramingApi } from './cameraState'; import { CursorManager } from '../helpers/cursor'; import { SideState } from './sideState'; @@ -7,20 +7,21 @@ import * as Icons from '../icons'; import { getPointerState } from './pointerState'; import { getFullScreenState } from './fullScreenState'; -import { SectionBoxRef } from './sectionBoxState'; +import { SectionBoxApi } from './sectionBoxState'; import { getMeasureState } from './measureState'; -import { ModalHandle } from '../panels/modal'; +import { ModalApi } from '../panels/modal'; -import { IsolationRef } from './sharedIsolation'; +import { IsolationApi } from './sharedIsolation'; import { PointerMode } from '../../core-viewers/shared'; -import * as ControlBar from '../controlbar' -import Style = ControlBar.Style; -import Ids = ControlBar.Ids; +import * as Style from '../controlbar/style' +import { controlBarIds as Ids } from '../controlbar/controlBarIds' +import type { IControlBarSection } from '../controlbar/controlBarSection' +import type { ControlBarCustomization } from '../controlbar/controlBar' import { isFalse, isTrue, UserBoolean } from "../settings/userBoolean"; import { UltraSettings } from "../ultra/settings"; import { WebglSettings } from "../webgl/settings"; -import { AnySettings } from "../settings"; +import { AnySettings } from "../settings/anySettings"; export type ControlBarSectionBoxSettings = { sectioningEnable: UserBoolean @@ -35,30 +36,30 @@ export type ControlBarSectionBoxSettings = { * Returns a control bar section for the section box. */ export function controlBarSectionBox( - section: SectionBoxRef, + section: SectionBoxApi, hasSelection : boolean, settings: ControlBarSectionBoxSettings -): ControlBar.IControlBarSection { +): IControlBarSection { return { id: Ids.sectioningSpan, - style: section.enable.get()? Style.sectionNoPadStyle : Style.sectionDefaultStyle, + style: section.active.get()? Style.sectionNoPadStyle : Style.sectionDefaultStyle, //enable: () => section.getEnable(), buttons: [ { id: Ids.sectioningEnable, enabled: () => isTrue(settings.sectioningEnable), tip: 'Enable Section Box', - isOn: () => section.enable.get(), + isOn: () => section.active.get(), style: Style.buttonExpandStyle, - action: () => section.enable.set(!section.enable.get()), + action: () => section.active.set(!section.active.get()), icon: Icons.sectionBox, }, { id: Ids.sectioningFitSelection, tip: 'Fit Section', - enabled: () => section.enable.get() && isTrue(settings.sectioningFitToSelection), + enabled: () => section.active.get() && isTrue(settings.sectioningFitToSelection), isOn: () => hasSelection, style: Style.buttonDisableStyle, action: () => section.sectionSelection.call(), @@ -67,7 +68,7 @@ export function controlBarSectionBox( { id: Ids.sectioningFitScene, tip: 'Reset Section', - enabled: () => section.enable.get() && isTrue(settings.sectioningReset), + enabled: () => section.active.get() && isTrue(settings.sectioningReset), style: Style.buttonDefaultStyle, action: () => section.sectionScene.call(), icon: Icons.sectionBoxReset, @@ -75,7 +76,7 @@ export function controlBarSectionBox( { id: Ids.sectioningVisible, tip: 'Show Section Box', - enabled: () => section.enable.get() && isTrue(settings.sectioningShow), + enabled: () => section.active.get() && isTrue(settings.sectioningShow), isOn: () => section.visible.get(), style: Style.buttonDefaultStyle, action: () => section.visible.set(!section.visible.get()), @@ -84,7 +85,7 @@ export function controlBarSectionBox( { id: Ids.sectioningAuto, tip: 'Auto Section', - enabled: () => section.enable.get() && isTrue(settings.sectioningAuto), + enabled: () => section.active.get() && isTrue(settings.sectioningAuto), isOn: () => section.auto.get(), style: Style.buttonDefaultStyle, action: () => section.auto.set(!section.auto.get()), @@ -93,7 +94,7 @@ export function controlBarSectionBox( { id: Ids.sectioningSettings, tip: 'Section Settings', - enabled: () => section.enable.get() && isTrue(settings.sectioningSettings), + enabled: () => section.active.get() && isTrue(settings.sectioningSettings), isOn: () => section.showOffsetPanel.get(), style: Style.buttonDefaultStyle, action: () => section.showOffsetPanel.set(!section.showOffsetPanel.get()), @@ -116,7 +117,7 @@ export type ControlBarCursorSettings = { function controlBarPointer( viewer: Core.Webgl.Viewer, settings: ControlBarCursorSettings, -): ControlBar.IControlBarSection { +): IControlBarSection { const pointer = getPointerState(viewer); return { @@ -205,7 +206,7 @@ function createMiscSettingsButton( } function createMiscHelpButton( - modal : ModalHandle, + modal : ModalApi, settings: AnySettings, ){ return { @@ -220,10 +221,10 @@ function createMiscHelpButton( // Ultra version export function controlBarMiscUltra( - modal : ModalHandle, + modal : ModalApi, side: SideState, settings: UltraSettings -): ControlBar.IControlBarSection { +): IControlBarSection { return { id: Ids.miscSpan, enable: () => anyUltraMiscButton(settings), @@ -237,10 +238,10 @@ export function controlBarMiscUltra( // WebGL version function controlBarMisc( - modal: ModalHandle, + modal: ModalApi, side: SideState, settings: WebglSettings -): ControlBar.IControlBarSection { +): IControlBarSection { const fullScreen = getFullScreenState(); return { @@ -265,7 +266,7 @@ function controlBarMisc( settings.capacity.canGoFullScreen, tip: fullScreen.get() ? 'Minimize' : 'Fullscreen', action: () => fullScreen.toggle(), - icon: fullScreen.get() ? Icons.minimize : Icons.fullsScreen, + icon: fullScreen.get() ? Icons.minimize : Icons.fullScreen, style: Style.buttonDefaultStyle } ] @@ -278,7 +279,7 @@ export type ControlBarCameraSettings ={ cameraFrameScene: UserBoolean } -export function controlBarCamera(camera: CameraRef, settings: ControlBarCameraSettings): ControlBar.IControlBarSection { +export function controlBarCamera(camera: FramingApi, settings: ControlBarCameraSettings): IControlBarSection { return { id: Ids.cameraSpan, enable: () => true, @@ -324,9 +325,8 @@ export type ControlBarVisibilitySettings = { visibilitySettings: UserBoolean } -export function controlBarVisibility(isolation: IsolationRef, settings: ControlBarVisibilitySettings): ControlBar.IControlBarSection { - const adapter = isolation.adapter.current - const someVisible = adapter.hasVisibleSelection() || !adapter.hasHiddenSelection() +export function controlBarVisibility(isolation: IsolationApi, settings: ControlBarVisibilitySettings): IControlBarSection { + const someVisible = isolation.hasVisibleSelection() || !isolation.hasHiddenSelection() return { id: Ids.visibilitySpan, @@ -337,16 +337,16 @@ export function controlBarVisibility(isolation: IsolationRef, settings: ControlB id: Ids.visibilityClearSelection, enabled: () => isTrue(settings.visibilityClearSelection), tip: 'Clear Selection', - action: () => adapter.clearSelection(), + action: () => isolation.clearSelection(), icon: Icons.pointer, - isOn: () => adapter.hasSelection(), + isOn: () => isolation.hasSelection(), style: Style.buttonDisableDefaultStyle, }, { id: Ids.visibilityShowAll, tip: 'Show All', enabled: () => isTrue(settings.visibilityShowAll), - action: () => adapter.showAll(), + action: () => isolation.showAll(), icon: Icons.showAll, isOn: () =>!isolation.autoIsolate.get() && isolation.visibility.get() !== 'all', style: Style.buttonDisableStyle, @@ -356,27 +356,27 @@ export function controlBarVisibility(isolation: IsolationRef, settings: ControlB id: Ids.visibilityHideSelection, enabled: () => someVisible && isTrue(settings.visibilityToggle), tip: 'Hide Selection', - action: () => adapter.hideSelection(), + action: () => isolation.hideSelection(), icon: Icons.hideSelection, - isOn: () =>!isolation.autoIsolate.get() && adapter.hasVisibleSelection(), + isOn: () =>!isolation.autoIsolate.get() && isolation.hasVisibleSelection(), style: Style.buttonDisableStyle, }, { id: Ids.visibilityShowSelection, enabled: () => !someVisible && isTrue(settings.visibilityToggle), tip: 'Show Selection', - action: () => adapter.showSelection(), + action: () => isolation.showSelection(), icon: Icons.showSelection, - isOn: () => !isolation.autoIsolate.get() && adapter.hasHiddenSelection(), + isOn: () => !isolation.autoIsolate.get() && isolation.hasHiddenSelection(), style: Style.buttonDisableStyle, }, { id: Ids.visibilityIsolateSelection, enabled: () => isTrue(settings.visibilityIsolate), tip: 'Isolate Selection', - action: () => adapter.isolateSelection(), + action: () => isolation.isolateSelection(), icon: Icons.isolateSelection, - isOn: () =>!isolation.autoIsolate.get() && adapter.hasSelection() && isolation.visibility.get() !== 'onlySelection', + isOn: () =>!isolation.autoIsolate.get() && isolation.hasSelection() && isolation.visibility.get() !== 'onlySelection', style: Style.buttonDisableStyle, }, { @@ -404,21 +404,21 @@ export function controlBarVisibility(isolation: IsolationRef, settings: ControlB */ export function useControlBar( viewer: Core.Webgl.Viewer, - camera: CameraRef, - modal: ModalHandle, + framing: FramingApi, + modal: ModalApi, side: SideState, cursor: CursorManager, settings: WebglSettings, - section: SectionBoxRef, - isolationRef: IsolationRef, - customization: ControlBar.ControlBarCustomization | undefined + section: SectionBoxApi, + isolationRef: IsolationApi, + customization: ControlBarCustomization | undefined ) { const measure = getMeasureState(viewer, cursor); // Apply user customization (note that pointerSection is added twice per original design) let controlBarSections = [ controlBarPointer(viewer, settings.ui), - controlBarCamera(camera, settings.ui), + controlBarCamera(framing, settings.ui), controlBarVisibility(isolationRef, settings.ui), controlBarMeasure(measure, settings.ui), controlBarSectionBox(section, viewer.selection.any(), settings.ui), diff --git a/src/vim-web/react-viewers/state/index.ts b/src/vim-web/react-viewers/state/index.ts index f4e7c2f89..8df600b92 100644 --- a/src/vim-web/react-viewers/state/index.ts +++ b/src/vim-web/react-viewers/state/index.ts @@ -1,9 +1,4 @@ -export * from './cameraState'; -export * from './controlBarState'; -export * from './fullScreenState'; -export * from './measureState'; -export * from './pointerState'; -export * from './sectionBoxState'; -export * from './sharedIsolation'; -export * from './sideState'; -export * from './viewerInputs'; +// Public API interfaces only — hooks and adapters are internal +export type { FramingApi } from './cameraState' +export type { SectionBoxApi } from './sectionBoxState' +export type { IsolationApi, VisibilityStatus } from './sharedIsolation' diff --git a/src/vim-web/react-viewers/state/measureState.tsx b/src/vim-web/react-viewers/state/measureState.tsx index f8eecee5f..c453173f5 100644 --- a/src/vim-web/react-viewers/state/measureState.tsx +++ b/src/vim-web/react-viewers/state/measureState.tsx @@ -48,7 +48,7 @@ export function getMeasureState (viewer: Core.Webgl.Viewer, cursor: CursorManage setMeasurement(undefined) }) .finally(() => { - cursor.setCursor(pointerToCursor(viewer.inputs.pointerActive)) + cursor.setCursor(pointerToCursor(viewer.inputs.pointerMode)) viewer.viewport.canvas.removeEventListener('mousemove', onMouseMove) if (activeRef.current) { loop() diff --git a/src/vim-web/react-viewers/state/pointerState.ts b/src/vim-web/react-viewers/state/pointerState.ts index 9ac6c925b..28a29e0a8 100644 --- a/src/vim-web/react-viewers/state/pointerState.ts +++ b/src/vim-web/react-viewers/state/pointerState.ts @@ -2,19 +2,18 @@ import { useEffect, useState } from 'react' import * as Core from '../../core-viewers' export function getPointerState (viewer: Core.Webgl.Viewer) { - const [mode, setMode] = useState(viewer.inputs.pointerActive) + const [mode, setMode] = useState(viewer.inputs.pointerMode) useEffect(() => { const sub = viewer.inputs.onPointerModeChanged.subscribe(() => { - setMode(viewer.inputs.pointerActive) + setMode(viewer.inputs.pointerMode) }) return () => sub() }, []) const onModeBtn = (target: Core.PointerMode) => { - const next = mode === target ? viewer.inputs.pointerFallback : target - viewer.inputs.pointerActive = next - setMode(next) + viewer.inputs.pointerMode = target + setMode(target) } return { diff --git a/src/vim-web/react-viewers/state/sectionBoxState.ts b/src/vim-web/react-viewers/state/sectionBoxState.ts index 99400104e..26ab261fc 100644 --- a/src/vim-web/react-viewers/state/sectionBoxState.ts +++ b/src/vim-web/react-viewers/state/sectionBoxState.ts @@ -1,8 +1,8 @@ import { useEffect, useState, useRef, useLayoutEffect, useMemo, useCallback } from 'react'; import * as THREE from 'three'; import { addBox } from '../../utils/threeUtils'; -import { ISignal } from 'ste-signals'; -import { ActionRef, ArgActionRef, AsyncFuncRef, StateRef, useArgActionRef, useAsyncFuncRef, useFuncRef, useStateRef } from '../helpers/reactUtils'; +import type { ISignal } from '../../core-viewers/shared/events' +import { FuncRef, StateRef, useFuncRef, useStateRef } from '../helpers/reactUtils'; export type Offsets = { topOffset: string; @@ -12,14 +12,23 @@ export type Offsets = { export type OffsetField = keyof Offsets; -export interface SectionBoxRef { - enable: StateRef; +/** + * Controls the section box clipping volume. + * Shared between WebGL and Ultra viewers. + * + * @example + * viewer.sectionBox.active.set(true) + * viewer.sectionBox.sectionSelection.call() // Fit to selection + * viewer.sectionBox.sectionScene.call() // Fit to scene + */ +export interface SectionBoxApi { + active: StateRef; visible: StateRef; auto: StateRef; - sectionSelection: AsyncFuncRef; - sectionScene: AsyncFuncRef; - sectionBox: ArgActionRef; + sectionSelection: FuncRef>; + sectionScene: FuncRef>; + sectionBox: FuncRef; getBox: () => THREE.Box3; showOffsetPanel: StateRef; @@ -28,12 +37,12 @@ export interface SectionBoxRef { sideOffset: StateRef; bottomOffset: StateRef; - getSelectionBox: AsyncFuncRef; - getSceneBox: AsyncFuncRef; + getSelectionBox: FuncRef>; + getSceneBox: FuncRef>; } -export interface SectionBoxAdapter { - setClip : (b: boolean) => void; +export interface ISectionBoxAdapter { + setActive : (b: boolean) => void; setVisible: (visible: boolean) => void; getBox: () => THREE.Box3; setBox: (box: THREE.Box3) => void; @@ -41,14 +50,14 @@ export interface SectionBoxAdapter { // Allow to override these at the viewer level getSelectionBox: () => Promise; - getSceneBox: () => Promise; + getSceneBox: () => Promise; } export function useSectionBox( - adapter: SectionBoxAdapter -): SectionBoxRef { + adapter: ISectionBoxAdapter +): SectionBoxApi { // Local state. - const enable = useStateRef(false); + const active = useStateRef(false); const visible = useStateRef(false); const showOffsetPanel = useStateRef(false); const auto = useStateRef(false); @@ -59,21 +68,21 @@ export function useSectionBox( // The reference box on which the offsets are applied. const boxRef = useRef(adapter.getBox()); - const getSelectionBox = useAsyncFuncRef(adapter.getSelectionBox); - const getSceneBox = useAsyncFuncRef(adapter.getSceneBox); + const getSelectionBox = useFuncRef(adapter.getSelectionBox); + const getSceneBox = useFuncRef(adapter.getSceneBox); // One Time Setup useEffect(() => { adapter.setVisible(false); - adapter.setClip(false); + adapter.setActive(false); return adapter.onSelectionChanged.sub(() => { - if(auto.get() && enable.get()) sectionSelection.call() + if(auto.get() && active.get()) sectionSelection.call() }) }, []); - // Reset everything when the enable state changes. - enable.useOnChange((v) => { - adapter.setClip(v); + // Reset everything when the active state changes. + active.useOnChange((v) => { + adapter.setActive(v); visible.set(v); showOffsetPanel.set(false) @@ -85,9 +94,9 @@ export function useSectionBox( } }) - // Cannot change values if not enabled. - visible.useValidate((v) => enable.get() && v); - showOffsetPanel.useValidate((v) => enable.get() && v); + // Cannot change values if not active. + visible.useValidate((v) => active.get() && v); + showOffsetPanel.useValidate((v) => active.get() && v); // Update the section box on offset change. topOffset.useOnChange((v) => sectionBox.call(boxRef.current)); @@ -101,7 +110,7 @@ export function useSectionBox( visible.useOnChange((v) => adapter.setVisible(v)); // Update the box by combining the base box and the computed offsets. - const sectionBox = useArgActionRef((box: THREE.Box3) => { + const sectionBox = useFuncRef((box: THREE.Box3) => { if(box === undefined) return requestId.current ++; @@ -126,7 +135,7 @@ export function useSectionBox( }); return { - enable, + active, visible, auto, showOffsetPanel, diff --git a/src/vim-web/react-viewers/state/settingsApi.ts b/src/vim-web/react-viewers/state/settingsApi.ts new file mode 100644 index 000000000..1dbf049ef --- /dev/null +++ b/src/vim-web/react-viewers/state/settingsApi.ts @@ -0,0 +1,29 @@ +import { AnySettings } from '../settings/anySettings' +import { SettingsItem } from '../settings/settingsItem' + +/** +* Settings API managing settings applied to the viewer. +*/ +export type SettingsApi = { + // Double lambda is required to prevent react from using reducer pattern + // https://stackoverflow.com/questions/59040989/usestate-with-a-lambda-invokes-the-lambda-when-set + + /** + * Allows updating settings by providing a callback function. + * @param updater A function that updates the current settings. + */ + update : (updater: (settings: T) => void) => void + + /** + * Registers a callback function to be notified when settings are updated. + * @param callback A function to be called when settings are updated, receiving the updated settings. + */ + register : (callback: (settings: T) => void) => void + + /** + * Customizes the settings panel by providing a customizer function. + * @param customizer A function that modifies the settings items. + */ + customize : (customizer: (items: SettingsItem[]) => SettingsItem[]) => void + +} diff --git a/src/vim-web/react-viewers/state/sharedIsolation.ts b/src/vim-web/react-viewers/state/sharedIsolation.ts index f1647bfa8..4207983cf 100644 --- a/src/vim-web/react-viewers/state/sharedIsolation.ts +++ b/src/vim-web/react-viewers/state/sharedIsolation.ts @@ -1,23 +1,71 @@ import { RefObject, useEffect, useRef } from "react"; import { FuncRef, StateRef, useFuncRef, useStateRef } from "../helpers/reactUtils"; -import { ISignal } from "ste-signals"; +import type { ISignal } from '../../core-viewers/shared/events' export type VisibilityStatus = 'all' | 'allButSelection' |'onlySelection' | 'some' | 'none'; -export interface IsolationRef { - adapter: RefObject; +/** + * Controls element visibility and isolation in the viewer. + * Shared between WebGL and Ultra viewers. + * + * @example + * viewer.isolation.isolateSelection() // Show only selected elements + * viewer.isolation.showAll() // Reset visibility + * viewer.isolation.showGhost.set(true) // Show hidden elements as ghosts + */ +export interface IsolationApi { + /** Current visibility status (observable). */ visibility: StateRef + /** Whether auto-isolate is enabled (observable). When true, selecting an element auto-isolates it. */ autoIsolate: StateRef; + /** Whether the isolation settings panel is shown (observable). */ showPanel: StateRef; + /** Whether hidden elements are rendered as ghosts (observable). */ showGhost: StateRef; + /** Ghost material opacity 0-1 (observable). */ ghostOpacity: StateRef; + /** Whether transparent materials are rendered (observable). */ transparency: StateRef; + /** Whether room elements are shown (observable). */ showRooms: StateRef; - onAutoIsolate: FuncRef; - onVisibilityChange: FuncRef; + /** Hook called when auto-isolate triggers. Use `update()` to add middleware. */ + onAutoIsolate: FuncRef; + /** Hook called when visibility changes. Use `update()` to add middleware. */ + onVisibilityChange: FuncRef; + + /** Returns true if any elements are selected. */ + hasSelection(): boolean + /** Returns true if any selected elements are currently visible. */ + hasVisibleSelection(): boolean + /** Returns true if any selected elements are currently hidden. */ + hasHiddenSelection(): boolean + /** Clears the current selection. */ + clearSelection(): void + /** Shows only selected elements, hiding everything else. */ + isolateSelection(): void + /** Hides the currently selected elements. */ + hideSelection(): void + /** Makes the currently selected elements visible. */ + showSelection(): void + /** Isolates elements by their instance indices (only these will be visible). */ + isolate(instances: number[]): void + /** Shows elements by their instance indices. */ + show(instances: number[]): void + /** Hides elements by their instance indices. */ + hide(instances: number[]): void + /** Hides all elements. */ + hideAll(): void + /** Resets visibility — makes all elements visible. */ + showAll(): void } -export interface IsolationAdapter{ +/** @internal */ +export type IsolationApiInternal = IsolationApi & { + /** @internal */ + adapter: RefObject +} + +export interface IIsolationAdapter{ onSelectionChanged: ISignal, onVisibilityChange: ISignal, computeVisibility: () => VisibilityStatus, @@ -43,22 +91,21 @@ export interface IsolationAdapter{ getGhostOpacity(): number; setGhostOpacity(opacity: number): void; - enableTransparency(enable: boolean): void; + setTransparency(enabled: boolean): void; getShowRooms(): boolean; setShowRooms(show: boolean): void; } -export function useSharedIsolation(adapter : IsolationAdapter){ +export function useSharedIsolation(adapter : IIsolationAdapter){ const _adapter = useRef(adapter); const visibility = useStateRef(() => adapter.computeVisibility(), true); const autoIsolate = useStateRef(false); const showPanel = useStateRef(false); const showRooms = useStateRef(false); const showGhost = useStateRef(false); - const ghostOpacity = useStateRef(() => adapter.getGhostOpacity(), true); const transparency = useStateRef(true); - + const ghostOpacity = useStateRef(() => adapter.getGhostOpacity(), true); const onAutoIsolate = useFuncRef(() => { if(adapter.hasSelection()){ adapter.isolateSelection(); @@ -87,10 +134,9 @@ export function useSharedIsolation(adapter : IsolationAdapter){ }); showGhost.useOnChange((v) => adapter.showGhost(v)); + transparency.useOnChange((v) => adapter.setTransparency(v)); showRooms.useOnChange((v) => adapter.setShowRooms(v)); - transparency.useOnChange((v) => adapter.enableTransparency(v)); - ghostOpacity.useValidate((next, current) => { return next <= 0 ? current : next }); @@ -102,10 +148,23 @@ export function useSharedIsolation(adapter : IsolationAdapter){ autoIsolate, showPanel, showGhost, + transparency, showRooms, ghostOpacity, onAutoIsolate, onVisibilityChange, - transparency, - } as IsolationRef + + hasSelection: () => _adapter.current.hasSelection(), + hasVisibleSelection: () => _adapter.current.hasVisibleSelection(), + hasHiddenSelection: () => _adapter.current.hasHiddenSelection(), + clearSelection: () => _adapter.current.clearSelection(), + isolateSelection: () => _adapter.current.isolateSelection(), + hideSelection: () => _adapter.current.hideSelection(), + showSelection: () => _adapter.current.showSelection(), + isolate: (instances: number[]) => _adapter.current.isolate(instances), + show: (instances: number[]) => _adapter.current.show(instances), + hide: (instances: number[]) => _adapter.current.hide(instances), + hideAll: () => _adapter.current.hideAll(), + showAll: () => _adapter.current.showAll(), + } as IsolationApiInternal } \ No newline at end of file diff --git a/src/vim-web/react-viewers/state/viewerInputs.ts b/src/vim-web/react-viewers/state/viewerInputs.ts index 54bfe15d3..310a21c5e 100644 --- a/src/vim-web/react-viewers/state/viewerInputs.ts +++ b/src/vim-web/react-viewers/state/viewerInputs.ts @@ -1,10 +1,10 @@ import { useEffect } from "react"; -import { InputHandler } from "../../core-viewers/shared"; -import { CameraRef } from "./cameraState"; +import { type IInputHandler } from "../../core-viewers/shared"; +import { FramingApi } from "./cameraState"; // Input binding override for the viewer are defined here. -export function useViewerInput(handler: InputHandler, camera: CameraRef){ +export function useViewerInput(handler: IInputHandler, framing: FramingApi){ useEffect(() => { - handler.keyboard.registerKeyUp('KeyF', 'replace', () => camera.frameSelection.call()); + handler.keyboard.override('KeyF', 'up', () => framing.frameSelection.call()); }, []) } \ No newline at end of file diff --git a/src/vim-web/react-viewers/ultra/camera.ts b/src/vim-web/react-viewers/ultra/camera.ts index d43dd9948..605c2605d 100644 --- a/src/vim-web/react-viewers/ultra/camera.ts +++ b/src/vim-web/react-viewers/ultra/camera.ts @@ -1,14 +1,14 @@ -import * as Core from "../../core-viewers/ultra"; -import { useCamera } from "../state/cameraState"; -import { SectionBoxRef } from "../state/sectionBoxState"; +import * as Core from "../../core-viewers"; +import { useFraming } from "../state/cameraState"; +import { SectionBoxApi } from "../state/sectionBoxState"; -export function useUltraCamera(viewer: Core.Viewer, section: SectionBoxRef) { +export function useUltraFraming(viewer: Core.Ultra.Viewer, section: SectionBoxApi) { - return useCamera({ + return useFraming({ onSelectionChanged: viewer.selection.onSelectionChanged, - frameCamera: (box, duration) => void viewer.camera.frameBox(box, duration), - resetCamera: (duration) => viewer.camera.restoreSavedPosition(duration), + frameCamera: (box, duration) => void viewer.camera.lerp(duration).frame(box), + resetCamera: (duration) => viewer.camera.lerp(duration).reset(), getSelectionBox: () => viewer.selection.getBoundingBox(), getSceneBox: () => viewer.renderer.getBoundingBox(), }, section) -} +} diff --git a/src/vim-web/react-viewers/ultra/controlBar.ts b/src/vim-web/react-viewers/ultra/controlBar.ts index 6c144f355..a3cae4d04 100644 --- a/src/vim-web/react-viewers/ultra/controlBar.ts +++ b/src/vim-web/react-viewers/ultra/controlBar.ts @@ -1,26 +1,26 @@ -import * as Core from '../../core-viewers/ultra' +import * as Core from '../../core-viewers' import { ControlBarCustomization } from '../controlbar/controlBar' -import { ModalHandle } from '../panels' -import { CameraRef } from '../state/cameraState' +import { ModalApi } from '../panels/modal' +import { FramingApi } from '../state/cameraState' import { controlBarCamera, controlBarSectionBox, controlBarMiscUltra, controlBarVisibility } from '../state/controlBarState' -import { SectionBoxRef } from '../state/sectionBoxState' -import { IsolationRef } from '../state/sharedIsolation' +import { SectionBoxApi } from '../state/sectionBoxState' +import { IsolationApi } from '../state/sharedIsolation' import { SideState } from '../state/sideState' import { UltraSettings } from './settings' export function useUltraControlBar ( - viewer: Core.Viewer, - section: SectionBoxRef, - isolation: IsolationRef, - camera: CameraRef, + viewer: Core.Ultra.Viewer, + section: SectionBoxApi, + isolation: IsolationApi, + framing: FramingApi, settings: UltraSettings, side: SideState, - modal: ModalHandle, + modal: ModalApi, customization: ControlBarCustomization | undefined ) { let bar = [ - controlBarCamera(camera, settings.ui), + controlBarCamera(framing, settings.ui), controlBarVisibility(isolation, settings.ui), controlBarSectionBox(section, viewer.selection.any(), settings.ui), controlBarMiscUltra(modal, side, settings) diff --git a/src/vim-web/react-viewers/ultra/errors/fileLoadingError.tsx b/src/vim-web/react-viewers/ultra/errors/fileLoadingError.tsx index d7125f609..1ba6ddb83 100644 --- a/src/vim-web/react-viewers/ultra/errors/fileLoadingError.tsx +++ b/src/vim-web/react-viewers/ultra/errors/fileLoadingError.tsx @@ -1,6 +1,6 @@ import { MessageBoxProps } from '../../panels/messageBox' import * as style from '../../errors/errorStyle' -import * as Urls from '../../urls' + export function serverFileLoadingError (url: string): MessageBoxProps { return { diff --git a/src/vim-web/react-viewers/ultra/errors/fileOpeningError.tsx b/src/vim-web/react-viewers/ultra/errors/fileOpeningError.tsx index 788a6b99f..95de42132 100644 --- a/src/vim-web/react-viewers/ultra/errors/fileOpeningError.tsx +++ b/src/vim-web/react-viewers/ultra/errors/fileOpeningError.tsx @@ -1,6 +1,6 @@ import { MessageBoxProps } from '../../panels/messageBox' import * as style from '../../errors/errorStyle' -import * as Urls from '../../urls' + export function fileOpeningError (url: string): MessageBoxProps { return { diff --git a/src/vim-web/react-viewers/ultra/errors/serverCompatibilityError.tsx b/src/vim-web/react-viewers/ultra/errors/serverCompatibilityError.tsx index e423a6e01..d0d4073ef 100644 --- a/src/vim-web/react-viewers/ultra/errors/serverCompatibilityError.tsx +++ b/src/vim-web/react-viewers/ultra/errors/serverCompatibilityError.tsx @@ -1,6 +1,6 @@ import { MessageBoxProps } from '../../panels/messageBox' import * as style from '../../errors/errorStyle' -import * as Urls from '../../urls' + export function serverCompatibilityError (url: string, localVersion: string, remoteVersion: string): MessageBoxProps { return { diff --git a/src/vim-web/react-viewers/ultra/errors/serverConnectionError.tsx b/src/vim-web/react-viewers/ultra/errors/serverConnectionError.tsx index 1906c0f0b..d91e002fa 100644 --- a/src/vim-web/react-viewers/ultra/errors/serverConnectionError.tsx +++ b/src/vim-web/react-viewers/ultra/errors/serverConnectionError.tsx @@ -1,6 +1,6 @@ import { MessageBoxProps } from '../../panels/messageBox' import * as style from '../../errors/errorStyle' -import * as Urls from '../../urls' + import { isLocalUrl } from '../../../utils/url' export function serverConnectionError (url: string): MessageBoxProps { diff --git a/src/vim-web/react-viewers/ultra/errors/serverFileDownloadingError.tsx b/src/vim-web/react-viewers/ultra/errors/serverFileDownloadingError.tsx index eb2367c81..8b7c1492e 100644 --- a/src/vim-web/react-viewers/ultra/errors/serverFileDownloadingError.tsx +++ b/src/vim-web/react-viewers/ultra/errors/serverFileDownloadingError.tsx @@ -1,6 +1,6 @@ import { MessageBoxProps } from '../../panels/messageBox' import * as style from '../../errors/errorStyle' -import * as Urls from '../../urls' + import { isFilePathOrUri } from '../../../utils/url' import { fileOpeningError } from './fileOpeningError' diff --git a/src/vim-web/react-viewers/ultra/errors/serverStreamError.tsx b/src/vim-web/react-viewers/ultra/errors/serverStreamError.tsx index 434b6e9c3..fd18d40bf 100644 --- a/src/vim-web/react-viewers/ultra/errors/serverStreamError.tsx +++ b/src/vim-web/react-viewers/ultra/errors/serverStreamError.tsx @@ -1,6 +1,6 @@ import { MessageBoxProps } from '../../panels/messageBox' import * as style from '../../errors/errorStyle' -import * as Urls from '../../urls' + export function serverStreamError (url: string): MessageBoxProps { return { diff --git a/src/vim-web/react-viewers/ultra/errors/ultraErrors.ts b/src/vim-web/react-viewers/ultra/errors/ultraErrors.ts index 8f2449c2d..66ad0ee8e 100644 --- a/src/vim-web/react-viewers/ultra/errors/ultraErrors.ts +++ b/src/vim-web/react-viewers/ultra/errors/ultraErrors.ts @@ -23,7 +23,7 @@ export function getRequestErrorMessage (serverUrl: string, source: Core.Ultra.Vi case 'downloadingError': case 'unknown': case 'cancelled': - return Errors.serverFileDownloadingError(source.url, source.authToken, serverUrl) + return Errors.serverFileDownloadingError(source.url, source.headers?.['Authorization'], serverUrl) case 'serverDisconnected': return Errors.serverConnectionError(source.url) } diff --git a/src/vim-web/react-viewers/ultra/index.ts b/src/vim-web/react-viewers/ultra/index.ts index 4c8857607..c2b140ddc 100644 --- a/src/vim-web/react-viewers/ultra/index.ts +++ b/src/vim-web/react-viewers/ultra/index.ts @@ -1,3 +1,7 @@ -export * from './viewer' -export * from './viewerRef' -export * from './settings' +// Public API +export { createUltraViewer as createViewer, UltraViewerComponent as ViewerComponent } from './viewer' +export type { UltraViewerApi as ViewerApi } from './viewerApi' + +// Settings +export { getDefaultUltraSettings } from './settings' +export type { UltraSettings, PartialUltraSettings } from './settings' diff --git a/src/vim-web/react-viewers/ultra/isolation.ts b/src/vim-web/react-viewers/ultra/isolation.ts index 70c2b1b30..8b0b20037 100644 --- a/src/vim-web/react-viewers/ultra/isolation.ts +++ b/src/vim-web/react-viewers/ultra/isolation.ts @@ -1,18 +1,19 @@ -import { IsolationAdapter, useSharedIsolation as useSharedIsolation, VisibilityStatus } from "../state/sharedIsolation"; +import { IIsolationAdapter, useSharedIsolation as useSharedIsolation, VisibilityStatus } from "../state/sharedIsolation"; import * as Core from "../../core-viewers"; import { useStateRef } from "../helpers/reactUtils"; +import { VisibilityState, type IVisibilitySynchronizer } from "../../core-viewers/ultra/visibility"; -import VisibilityState = Core.Ultra.VisibilityState -import Viewer = Core.Ultra.Viewer -import Vim = Core.Ultra.Vim -import Element3D = Core.Ultra.Element3D +// Internal access — these properties exist on concrete classes but are hidden from public API +type Viewer = Core.Ultra.Viewer +type Vim = Core.Ultra.IUltraVim & { readonly visibility: IVisibilitySynchronizer } +type Element3D = Core.Ultra.IUltraElement3D & { state: VisibilityState } export function useUltraIsolation(viewer: Viewer){ const adapter = createAdapter(viewer) return useSharedIsolation(adapter) } -function createAdapter(viewer: Viewer): IsolationAdapter { +function createAdapter(viewer: Viewer): IIsolationAdapter { const ghost = useStateRef(false); @@ -20,7 +21,9 @@ function createAdapter(viewer: Viewer): IsolationAdapter { const hide = (objects: Element3D[] | 'all') =>{ const state = ghost.get() ? VisibilityState.GHOSTED : VisibilityState.HIDDEN if(objects === 'all'){ - viewer.vims.getAll().forEach(vim => {vim.visibility.setStateForAll(state)}) + for (const vim of viewer.vims as Vim[]) { + vim.visibility.setStateForAll(state) + } return } @@ -47,18 +50,18 @@ function createAdapter(viewer: Viewer): IsolationAdapter { clearSelection: () => viewer.selection.clear(), isolateSelection: () => { - hide('all') + hide('all') - for(const obj of viewer.selection.getAll()){ + for(const obj of viewer.selection.getAll() as Element3D[]){ obj.state = VisibilityState.HIGHLIGHTED } }, hideSelection: () => { - const objs = viewer.selection.getAll() + const objs = viewer.selection.getAll() as Element3D[] hide(objs) }, showSelection: () => { - viewer.selection.getAll().forEach(obj => { + (viewer.selection.getAll() as Element3D[]).forEach(obj => { obj.state = VisibilityState.VISIBLE }) }, @@ -67,10 +70,10 @@ function createAdapter(viewer: Viewer): IsolationAdapter { hide('all') }, showAll: () => { - for(const vim of viewer.vims.getAll()){ + for(const vim of viewer.vims as Vim[]){ vim.visibility.setStateForAll(VisibilityState.VISIBLE) } - viewer.selection.getAll().forEach(obj => { + ;(viewer.selection.getAll() as Element3D[]).forEach(obj => { obj.state = VisibilityState.HIGHLIGHTED }) }, @@ -78,32 +81,32 @@ function createAdapter(viewer: Viewer): IsolationAdapter { // TODO: Change this api to use elements isolate: (instances: number[]) => { hide('all') // Hide all objects - viewer.selection.getAll().forEach(obj => { + ;(viewer.selection.getAll() as Element3D[]).forEach(obj => { obj.state = VisibilityState.HIGHLIGHTED }) }, show: (instances: number[]) => { - for(const vim of viewer.vims.getAll()){ + for(const vim of viewer.vims){ for(const i of instances){ - vim.getElement(i).state = VisibilityState.VISIBLE + ;(vim.getElement(i) as Element3D).state = VisibilityState.VISIBLE } } }, hide: (instances: number[]) => { - for(const vim of viewer.vims.getAll()){ + for(const vim of viewer.vims){ for(const i of instances){ - const obj = vim.getElement(i) + const obj = vim.getElement(i) as Element3D hide([obj]) } } - const objs = viewer.selection.getAll() + const objs = viewer.selection.getAll() as Element3D[] hide(objs) }, showGhost: (show: boolean) => { ghost.set(show) - - for(const vim of viewer.vims.getAll()){ + + for(const vim of viewer.vims as Vim[]){ if(show){ vim.visibility.replaceState(VisibilityState.HIDDEN, VisibilityState.GHOSTED) } else { @@ -111,15 +114,13 @@ function createAdapter(viewer: Viewer): IsolationAdapter { } } }, - enableTransparency: (enable: boolean) => { - console.log("enableTransparency not implemented") - }, - getGhostOpacity: () => viewer.renderer.ghostOpacity, setGhostOpacity: (opacity: number) => { viewer.renderer.ghostOpacity = opacity }, + setTransparency: (enabled: boolean) => {console.log("setTransparency not implemented")}, + getShowRooms: () => true, setShowRooms: (show: boolean) => {console.log("setShowRooms not implemented")}, @@ -131,8 +132,8 @@ function checkSelectionState(viewer: Viewer, test: (state: VisibilityState) => b if(!viewer.selection.any()){ return false } - - return viewer.selection.getAll().every(obj => test(obj.state)) + + return (viewer.selection.getAll() as Element3D[]).every(obj => test(obj.state)) } function getVisibilityState(viewer: Viewer): VisibilityStatus { @@ -141,7 +142,7 @@ function getVisibilityState(viewer: Viewer): VisibilityStatus { let allButSelectionFlag = true; let onlySelectionFlag = true; - for (let v of viewer.vims.getAll()) { + for (let v of viewer.vims as Vim[]) { const allVisible = v.visibility.areAllInState([VisibilityState.VISIBLE, VisibilityState.HIGHLIGHTED]) const allHidden = v.visibility.areAllInState([VisibilityState.HIDDEN, VisibilityState.GHOSTED]) @@ -150,12 +151,12 @@ function getVisibilityState(viewer: Viewer): VisibilityStatus { onlySelectionFlag = onlySelection(viewer, v) allButSelectionFlag = allButSelection(viewer, v) } - + if (all) return 'all'; if (none) return 'none'; if (allButSelectionFlag) return 'allButSelection'; if (onlySelectionFlag) return 'onlySelection'; - + // If none of the above conditions are met, it must be 'some' return 'some'; } diff --git a/src/vim-web/react-viewers/ultra/modal.tsx b/src/vim-web/react-viewers/ultra/modal.tsx index 92c75d81d..5b687b26e 100644 --- a/src/vim-web/react-viewers/ultra/modal.tsx +++ b/src/vim-web/react-viewers/ultra/modal.tsx @@ -1,9 +1,9 @@ -import { ModalHandle } from "../panels/modal" +import { ModalApi } from "../panels/modal" import { getErrorMessage } from './errors/ultraErrors' import * as Core from '../../core-viewers' import { RefObject } from "react" -export function updateModal (modal: RefObject, state: Core.Ultra.ClientState) { +export function updateModal (modal: RefObject, state: Core.Ultra.ClientState) { const m = modal.current if (state.status === 'connected') { m.loading(undefined) @@ -20,9 +20,9 @@ export function updateModal (modal: RefObject, state: Core.Ultra.Cl } } -export async function updateProgress (request: Core.Ultra.ILoadRequest, modal: ModalHandle) { +export async function updateProgress (request: Core.Ultra.IUltraLoadRequest, modal: ModalApi) { 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/ultra/sectionBox.ts b/src/vim-web/react-viewers/ultra/sectionBox.ts index 0617d9a41..0ed9a175d 100644 --- a/src/vim-web/react-viewers/ultra/sectionBox.ts +++ b/src/vim-web/react-viewers/ultra/sectionBox.ts @@ -1,11 +1,11 @@ // useUltraSectionBox.ts import * as Core from '../../core-viewers'; -import { useSectionBox, SectionBoxAdapter, SectionBoxRef } from '../state/sectionBoxState'; +import { useSectionBox, ISectionBoxAdapter, SectionBoxApi } from '../state/sectionBoxState'; -export function useUltraSectionBox(viewer: Core.Ultra.Viewer): SectionBoxRef { - const ultraAdapter: SectionBoxAdapter = { - setClip: (b) => { - viewer.sectionBox.clip = b; +export function useUltraSectionBox(viewer: Core.Ultra.Viewer): SectionBoxApi { + const ultraAdapter: ISectionBoxAdapter = { + setActive: (b) => { + viewer.sectionBox.active = b; }, setVisible: (b) => { viewer.sectionBox.visible = b; diff --git a/src/vim-web/react-viewers/ultra/settings.ts b/src/vim-web/react-viewers/ultra/settings.ts index f3d8a01fc..412e70749 100644 --- a/src/vim-web/react-viewers/ultra/settings.ts +++ b/src/vim-web/react-viewers/ultra/settings.ts @@ -1,9 +1,19 @@ -import { RecursivePartial } from "../helpers/utils" +import { RecursivePartial } from "../../utils" import { UserBoolean } from "../settings/userBoolean" import { ControlBarCameraSettings, ControlBarCursorSettings, ControlBarMeasureSettings, ControlBarSectionBoxSettings, ControlBarVisibilitySettings } from "../state/controlBarState" export type PartialUltraSettings = RecursivePartial +/** + * React UI feature toggles for the Ultra viewer, passed to `React.Ultra.createViewer(container, settings)`. + * Controls which UI panels and toolbar buttons are shown. + * Access at runtime via `viewer.settings.update(s => { s.ui.panelControlBar = false })`. + * + * @example + * const viewer = await React.Ultra.createViewer(div, { + * ui: { panelControlBar: true, miscHelp: false } + * }) + */ export type UltraSettings = { ui: ControlBarCameraSettings & ControlBarCursorSettings & diff --git a/src/vim-web/react-viewers/ultra/viewer.tsx b/src/vim-web/react-viewers/ultra/viewer.tsx index 2ccc19c1f..06dadeb42 100644 --- a/src/vim-web/react-viewers/ultra/viewer.tsx +++ b/src/vim-web/react-viewers/ultra/viewer.tsx @@ -1,12 +1,12 @@ import * as Core from '../../core-viewers' -import { useSettings } from '../../react-viewers/settings/settingsState' +import { useSettings } from '../settings/settingsState' import {useRef, RefObject, useEffect, useState } from 'react' import { Container, createContainer } from '../container' import { createRoot } from 'react-dom/client' import { Overlay } from '../panels/overlay' -import { Modal, ModalHandle } from '../panels/modal' +import { Modal, ModalApi } from '../panels/modal' import { getRequestErrorMessage } from './errors/ultraErrors' import { updateModal, updateProgress as modalProgress } from './modal' import { ControlBar, ControlBarCustomization } from '../controlbar/controlBar' @@ -17,45 +17,53 @@ import { RestOfScreen } from '../panels/restOfScreen' import { LogoMemo } from '../panels/logo' import { whenTrue } from '../helpers/utils' import { useSideState } from '../state/sideState' -import { ViewerRef } from './viewerRef' +import { UltraViewerApi } from './viewerApi' import ReactTooltip from 'react-tooltip' -import { useUltraCamera } from './camera' +import { useUltraFraming } from './camera' import { useViewerInput } from '../state/viewerInputs' import { useUltraIsolation } from './isolation' import { IsolationPanel } from '../panels/isolationPanel' -import { GenericPanelHandle } from '../generic/genericPanel' +import { GenericPanelApi } from '../generic/genericPanel' import { ControllablePromise } from '../../utils' import { SettingsPanel } from '../settings/settingsPanel' import { SidePanelMemo } from '../panels/sidePanel' import { getDefaultUltraSettings, PartialUltraSettings, UltraSettings } from './settings' import { getUltraSettingsContent } from './settingsPanel' -import { SettingsCustomizer } from '../settings/settingsItem' +import { SettingsCustomization } from '../settings/settingsItem' import { isTrue } from '../settings/userBoolean' /** - * Creates a UI container along with a VIM.Viewer and its associated React viewer. - * @param container An optional container object. If none is provided, a container will be created. - * @returns An object containing the resulting container, reactRoot, and viewer. + * Creates an Ultra viewer with React UI for server-side rendered models. + * Returns an {@link UltraViewerApi} for programmatic interaction. + * + * @param container An optional container or DOM element. If none is provided, one will be created. + * @param settings React UI feature toggles (panels, buttons). See {@link UltraSettings}. + * @returns A promise resolving to the viewer API. + * + * @example + * const viewer = await React.Ultra.createViewer(document.getElementById('app')) + * await viewer.core.connect({ url: 'wss://server:8080' }) + * viewer.load({ url: 'model.vim' }) */ -export function createViewer ( +export function createUltraViewer ( container?: Container | HTMLElement, settings?: PartialUltraSettings -) : Promise { +) : Promise { - const controllablePromise = new ControllablePromise() + const controllablePromise = new ControllablePromise() const cmpContainer = container instanceof HTMLElement ? createContainer(container) : container ?? createContainer() // Create the viewer and container - const core = Core.Ultra.Viewer.createWithCanvas(cmpContainer.gfx) + const core = Core.Ultra.createViewer(cmpContainer.gfx) // Create the React root const reactRoot = createRoot(cmpContainer.ui) // Patch the viewer to clean up after itself - const attachDispose = (cmp : ViewerRef) => { + const attachDispose = (cmp : UltraViewerApi) => { cmp.dispose = () => { core.dispose() cmpContainer.dispose() @@ -65,11 +73,11 @@ export function createViewer ( } reactRoot.render( - controllablePromise.resolve(attachDispose(cmp))} + onMount = {(cmp : UltraViewerApi) => controllablePromise.resolve(attachDispose(cmp))} /> ) return controllablePromise.promise @@ -82,18 +90,18 @@ export function createViewer ( * @param onMount A callback function triggered when the viewer is mounted. Receives a reference to the Vim viewer. * @param settings Optional settings for configuring the Vim viewer's behavior. */ -export function Viewer (props: { +export function UltraViewerComponent (props: { container: Container core: Core.Ultra.Viewer settings?: PartialUltraSettings - onMount: (viewer: ViewerRef) => void}) { + onMount: (viewer: UltraViewerApi) => void}) { const settings = useSettings(props.settings ?? {}, getDefaultUltraSettings()) const sectionBoxRef = useUltraSectionBox(props.core) - const camera = useUltraCamera(props.core, sectionBoxRef) - const isolationPanelHandle = useRef(null) - const sectionBoxPanelHandle = useRef(null) - const modalHandle = useRef(null) + const framing = useUltraFraming(props.core, sectionBoxRef) + const isolationPanelHandle = useRef(null) + const sectionBoxPanelHandle = useRef(null) + const modalHandle = useRef(null) const side = useSideState(true, 400) const [_, setSelectState] = useState(0) @@ -103,14 +111,14 @@ export function Viewer (props: { props.core, sectionBoxRef, isolationRef, - camera, + framing, settings.value, side, modalHandle.current, _ =>_ ) - useViewerInput(props.core.inputs, camera) + useViewerInput(props.core.inputs, framing) // On First render useEffect(() => { @@ -131,15 +139,17 @@ export function Viewer (props: { setSelectState(i => (i+1)%2) } ) props.onMount({ + type: 'ultra', + container: props.container, core: props.core, get modal() { return modalHandle.current }, isolation: isolationRef, sectionBox: sectionBoxRef, - camera, + framing, settings: { update : settings.update, register : settings.register, - customize : (c: SettingsCustomizer) => settings.customizer.set(c) + customize : (c: SettingsCustomization) => settings.customizer.set(c) }, get isolationPanel(){ return isolationPanelHandle.current @@ -151,7 +161,8 @@ export function Viewer (props: { controlBar: { customize: (v) => setControlBarCustom(() => v) }, - load: patchLoad(props.core, modalHandle) + load: patchLoad(props.core, modalHandle), + unload: (vim) => props.core.unload(vim) }) }, []) @@ -181,7 +192,7 @@ export function Viewer (props: { show={isTrue(settings.value.ui.panelControlBar)} /> - + }}/> @@ -196,9 +207,9 @@ export function Viewer (props: { } -function patchLoad(viewer: Core.Ultra.Viewer, modal: RefObject) { - return function load (source: Core.Ultra.VimSource): Core.Ultra.ILoadRequest { - const request = viewer.loadVim(source) +function patchLoad(viewer: Core.Ultra.Viewer, modal: RefObject) { + return function load (source: Core.Ultra.VimSource): Core.Ultra.IUltraLoadRequest { + const request = viewer.load(source) // We don't want to block the main thread to get progress updates void modalProgress(request, modal.current) @@ -207,7 +218,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/react-viewers/ultra/viewerApi.ts b/src/vim-web/react-viewers/ultra/viewerApi.ts new file mode 100644 index 000000000..5e0b0de4e --- /dev/null +++ b/src/vim-web/react-viewers/ultra/viewerApi.ts @@ -0,0 +1,100 @@ +import * as Core from '../../core-viewers'; +import { ModalApi } from '../panels/modal'; +import { FramingApi } from '../state/cameraState'; +import { SectionBoxApi } from '../state/sectionBoxState'; +import { IsolationApi } from '../state/sharedIsolation'; +import { ControlBarApi } from '../controlbar/controlBar'; +import { GenericPanelApi } from '../generic/genericPanel'; +import { SettingsApi } from '../state/settingsApi'; +import { UltraSettings } from './settings'; +import { Container } from '../container'; + +export type UltraViewerApi = { + /** + * Discriminant to distinguish Ultra from WebGL viewer. + */ + type: 'ultra' + + /** + * HTML structure containing the viewer. + */ + container: Container + + /** + * The underlying Ultra core viewer. Provides direct access to the server connection, + * camera, selection, raycaster, renderer, and section box. + * + * Common uses: + * - `viewer.core.camera.lerp(1).frame(element)` — animated camera movement + * - `viewer.core.camera.snap().set(pos, target)` — instant camera placement + * - `viewer.core.selection.select(element)` — programmatic selection + * - `viewer.core.inputs.pointerMode` — change interaction mode + * - `viewer.core.sectionBox` — direct section box manipulation + * - `viewer.core.renderer.ghostColor` — ghost rendering settings + */ + core: Core.Ultra.Viewer; + + /** + * API to manage the modal dialog. + */ + modal: ModalApi; + + /** + * API to manage the section box. + */ + sectionBox: SectionBoxApi; + + /** + * API to customize the control. + */ + controlBar: ControlBarApi + + /** + * High-level framing API with semantic operations (frame selection, auto-camera). + * For low-level camera control (snap/lerp, set position), use {@link core}.camera instead. + * @see {@link FramingApi} + */ + framing: FramingApi + + /** + * Isolation API managing element visibility and isolation state. + * @see {@link IsolationApi} + */ + isolation: IsolationApi + + /** + * Settings API managing UI feature toggles applied to the viewer. + * Use `update()` to modify settings at runtime. + */ + settings: SettingsApi + + /** + * API to interact with the isolation panel. + */ + isolationPanel : GenericPanelApi + + /** + * API to interact with the section box panel. + */ + sectionBoxPanel : GenericPanelApi + + /** + * Disposes of the viewer and its resources. + */ + dispose: () => void; + + /** + * Loads a VIM file via the Ultra server. + * Wraps core.load() with connection management, progress UI (loading modal), + * and error reporting. For headless loading, use core.load() directly. + * @param source The VIM source (url and optional headers) + * @returns LoadRequest to track progress and get result + */ + load(source: Core.Ultra.VimSource): Core.Ultra.IUltraLoadRequest; + + /** + * Unloads a vim from the viewer and disposes it. + * @param vim The vim to unload + */ + unload(vim: Core.Ultra.IUltraVim): void; +}; diff --git a/src/vim-web/react-viewers/ultra/viewerRef.ts b/src/vim-web/react-viewers/ultra/viewerRef.ts deleted file mode 100644 index 99cac71a7..000000000 --- a/src/vim-web/react-viewers/ultra/viewerRef.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { RefObject } from 'react'; -import * as Core from '../../core-viewers/ultra'; -import { ModalHandle } from '../panels/modal'; -import { CameraRef } from '../state/cameraState'; -import { SectionBoxRef } from '../state/sectionBoxState'; -import { IsolationRef } from '../state/sharedIsolation'; -import { ControlBarRef } from '../controlbar'; -import { GenericPanelHandle } from '../generic/'; -import { SettingsRef } from '../webgl'; -import { UltraSettings } from './settings'; - -export type ViewerRef = { - /** - * The Vim viewer instance associated with the viewer. - */ - core: Core.Viewer; - - /** - * API to manage the modal dialog. - */ - modal: ModalHandle; - - /** - * API to manage the section box. - */ - sectionBox: SectionBoxRef; - - /** - * API to customize the control. - */ - controlBar: ControlBarRef - - /** - * Camera API to interact with the viewer camera at a higher level. - */ - camera: CameraRef - - isolation: IsolationRef - - settings: SettingsRef - - /** - * API to interact with the isolation panel. - */ - isolationPanel : GenericPanelHandle - - /** - * API to interact with the isolation panel. - */ - sectionBoxPanel : GenericPanelHandle - - /** - * Disposes of the viewer and its resources. - */ - dispose: () => void; - - /** - * Loads a file into the viewer. - * @param url The URL of the file to load. - */ - load(url: Core.VimSource): Core.ILoadRequest; -}; diff --git a/src/vim-web/react-viewers/urls.ts b/src/vim-web/react-viewers/urls.ts deleted file mode 100644 index 160aac593..000000000 --- a/src/vim-web/react-viewers/urls.ts +++ /dev/null @@ -1,3 +0,0 @@ - -export const supportUltra = 'https://docs.vimaec.com/docs/vim-for-windows/configuring-vim-ultra' -export const supportControls = 'https://docs.vimaec.com/docs/vim-cloud/webgl-navigation-and-controls-guide' diff --git a/src/vim-web/react-viewers/webgl/camera.ts b/src/vim-web/react-viewers/webgl/camera.ts index 5e3ae78ca..6a1e59191 100644 --- a/src/vim-web/react-viewers/webgl/camera.ts +++ b/src/vim-web/react-viewers/webgl/camera.ts @@ -1,9 +1,9 @@ import * as Core from "../../core-viewers"; -import { useCamera } from "../state/cameraState"; -import { SectionBoxRef } from "../state/sectionBoxState"; +import { useFraming } from "../state/cameraState"; +import { SectionBoxApi } from "../state/sectionBoxState"; -export function useWebglCamera(viewer: Core.Webgl.Viewer, section: SectionBoxRef) { - return useCamera({ +export function useWebglFraming(viewer: Core.Webgl.Viewer, section: SectionBoxApi) { + return useFraming({ onSelectionChanged: viewer.selection.onSelectionChanged, frameCamera: (box, duration) => viewer.camera.lerp(duration).frame(box), resetCamera: (duration) => viewer.camera.lerp(duration).reset(), diff --git a/src/vim-web/react-viewers/webgl/index.ts b/src/vim-web/react-viewers/webgl/index.ts index 4cf53ed07..76a77d4db 100644 --- a/src/vim-web/react-viewers/webgl/index.ts +++ b/src/vim-web/react-viewers/webgl/index.ts @@ -1,15 +1,7 @@ +// Public API +export { createWebglViewer as createViewer, WebglViewerComponent as ViewerComponent } from './viewer' +export type { WebglViewerApi as ViewerApi, OpenSettings } from './viewerApi' -// Full exports -export * from './viewer'; -export * from './viewerRef'; -export * from './settings' - -// Type exports -export type * from './loading'; - -// Not exported -// export * from './camera'; -// export * from './inputsBindings'; -// export * from './isolation'; -// export * from './sectionBox'; -// export * from './viewerState'; \ No newline at end of file +// Settings +export { getDefaultSettings } from './settings' +export type { WebglSettings, PartialWebglSettings } from './settings' diff --git a/src/vim-web/react-viewers/webgl/inputsBindings.ts b/src/vim-web/react-viewers/webgl/inputsBindings.ts index bdb1c3a36..5221ec78d 100644 --- a/src/vim-web/react-viewers/webgl/inputsBindings.ts +++ b/src/vim-web/react-viewers/webgl/inputsBindings.ts @@ -4,34 +4,34 @@ import * as Core from '../../core-viewers' import { SideState } from '../state/sideState' -import { CameraRef } from '../state/cameraState' -import { IsolationRef } from '../state/sharedIsolation' +import { FramingApi } from '../state/cameraState' +import { IsolationApi } from '../state/sharedIsolation' export function applyWebglBindings( viewer: Core.Webgl.Viewer, - camera: CameraRef, - isolation: IsolationRef, + framing: FramingApi, + isolation: IsolationApi, sideState: SideState) { const k = viewer.inputs.keyboard - k.registerKeyUp("F4", 'replace', () => sideState.toggleContent('settings')) - k.registerKeyUp("NumpadDivide", 'replace', () => sideState.toggleContent('settings')) - k.registerKeyUp("KeyF", 'replace', () => camera.frameSelection.call()) - k.registerKeyUp("KeyI", 'replace', () =>{ - if(isolation.adapter.current.hasVisibleSelection() && isolation.visibility.get() !== 'onlySelection'){ - isolation.adapter.current.isolateSelection() + k.override("F4", 'up', () => sideState.toggleContent('settings')) + k.override("NumpadDivide", 'up', () => sideState.toggleContent('settings')) + k.override("KeyF", 'up', () => framing.frameSelection.call()) + k.override("KeyI", 'up', () =>{ + if(isolation.hasVisibleSelection() && isolation.visibility.get() !== 'onlySelection'){ + isolation.isolateSelection() } else{ - isolation.adapter.current.showAll() + isolation.showAll() } }) - k.registerKeyUp("escape", 'replace', () => viewer.selection.clear()) - k.registerKeyUp("KeyV", 'replace', () => { - if(isolation.adapter.current.hasVisibleSelection()){ - isolation.adapter.current.hideSelection() + k.override("escape", 'up', () => viewer.selection.clear()) + k.override("KeyV", 'up', () => { + if(isolation.hasVisibleSelection()){ + isolation.hideSelection() } else{ - isolation.adapter.current.showSelection() + isolation.showSelection() } }) } diff --git a/src/vim-web/react-viewers/webgl/isolation.ts b/src/vim-web/react-viewers/webgl/isolation.ts index 6b322d64f..e0a382d92 100644 --- a/src/vim-web/react-viewers/webgl/isolation.ts +++ b/src/vim-web/react-viewers/webgl/isolation.ts @@ -1,27 +1,29 @@ import * as Core from "../../core-viewers"; -import { Element3D, Selectable } from "../../core-viewers/webgl"; -import { IsolationAdapter, useSharedIsolation as useSharedIsolation, VisibilityStatus } from "../state/sharedIsolation"; +import { ISelectable } from "../../core-viewers/webgl"; +import { IIsolationAdapter, useSharedIsolation as useSharedIsolation, VisibilityStatus } from "../state/sharedIsolation"; export function useWebglIsolation(viewer: Core.Webgl.Viewer){ const adapter = createWebglIsolationAdapter(viewer) return useSharedIsolation(adapter) } -function createWebglIsolationAdapter(viewer: Core.Webgl.Viewer): IsolationAdapter { - var transparency: boolean = true; +function createWebglIsolationAdapter(viewer: Core.Webgl.Viewer): IIsolationAdapter { var ghost: boolean = false; + var transparency: boolean = true; var rooms: boolean = false; function updateMaterials(){ - viewer.renderer.modelMaterial = - !ghost && transparency ? undefined - : ghost && transparency ? [undefined, viewer.materials.ghost] - : !ghost && !transparency ? viewer.materials.simple - : ghost && !transparency ? [viewer.materials.simple, viewer.materials.ghost] - : (() => { throw new Error("Unreachable state in isolation materials") })(); + const m = viewer.materials + viewer.renderer.modelMaterial = new Core.Webgl.MaterialSet( + m.modelOpaqueMaterial, + transparency ? m.modelTransparentMaterial : m.modelOpaqueMaterial, + ghost ? m.ghostMaterial : undefined + ) } - function updateVisibility(elements: 'all' | Selectable[], predicate: (object: Selectable) => boolean){ + // Don't call updateMaterials() immediately - let RenderScene default handle initial state + + function updateVisibility(elements: 'all' | ISelectable[], predicate: (object: ISelectable) => boolean){ if(elements === 'all'){ for(let v of viewer.vims){ for(let o of v.getAllElements()){ @@ -84,13 +86,6 @@ function createWebglIsolationAdapter(viewer: Core.Webgl.Viewer): IsolationAdapte } }, - enableTransparency: (enable: boolean) => { - if(transparency !== enable){ - transparency = enable; - updateMaterials(); - }; - }, - showGhost: (show: boolean) => { ghost = show; updateMaterials(); @@ -99,6 +94,11 @@ function createWebglIsolationAdapter(viewer: Core.Webgl.Viewer): IsolationAdapte getGhostOpacity: () => viewer.materials.ghostOpacity, setGhostOpacity: (opacity: number) => viewer.materials.ghostOpacity = opacity, + setTransparency: (enabled: boolean) => { + transparency = enabled; + updateMaterials(); + }, + getShowRooms: () => rooms, setShowRooms: (show: boolean) => { if(rooms !== show){ diff --git a/src/vim-web/react-viewers/webgl/loading.ts b/src/vim-web/react-viewers/webgl/loading.ts index fc71ccbdb..0a96373e4 100644 --- a/src/vim-web/react-viewers/webgl/loading.ts +++ b/src/vim-web/react-viewers/webgl/loading.ts @@ -2,10 +2,10 @@ * @module viw-webgl-react */ -import * as Errors from '../errors' +import { serverFileDownloadingError } from '../errors/errors' import * as Core from '../../core-viewers' import { LoadRequest } from '../helpers/loadRequest' -import { ModalHandle } from '../panels/modal' +import { ModalApi } from '../panels/modal' import { UltraSuggestion } from '../panels/loadingBox' import { WebglSettings } from './settings' @@ -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 @@ -36,10 +30,14 @@ export type LoadingError = { */ export class ComponentLoader { private _viewer : Core.Webgl.Viewer - private _modal: React.RefObject + private _modal: React.RefObject private _addLink : boolean = false - constructor (viewer : Core.Webgl.Viewer, modal: React.RefObject, settings: WebglSettings) { + constructor ( + viewer : Core.Webgl.Viewer, + modal: React.RefObject, + settings: WebglSettings + ) { this._viewer = viewer this._modal = modal // TODO: Enable this when we are ready to support it @@ -49,17 +47,17 @@ export class ComponentLoader { /** * Event emitter for progress updates. */ - onProgress (p: Core.Webgl.IProgressLogs) { + onProgress (p: Core.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 }) } /** - * Event emitter for completion notifications. + * Event emitter for completion notifications. */ onDone () { this._modal.current?.loading(undefined) @@ -69,87 +67,56 @@ export class ComponentLoader { * Event emitter for error notifications. */ onError (e: LoadingError) { - this._modal.current?.message(Errors.serverFileDownloadingError(e.url)) + this._modal.current?.message(serverFileDownloadingError(e.url)) } /** - * 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. + * Opens a vim file without loading geometry. + * Use this for querying BIM data or selective loading. + * Call vim.load() or vim.load(subset) 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) */ - 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 + open (source: Core.Webgl.RequestSource, settings: OpenSettings = {}): Core.Webgl.IWebglLoadRequest { + return this.loadInternal(source, settings, false) } /** - * Creates a new load request for the provided source and settings. + * 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 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) { - return new LoadRequest({ - onProgress: (p) => this.onProgress(p), - onError: (e) => this.onError(e), - onDone: () => this.onDone() - }, source, settings) - } - - /* - * 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) + load (source: Core.Webgl.RequestSource, settings: OpenSettings = {}): Core.Webgl.IWebglLoadRequest { + return this.loadInternal(source, settings, true) } - /** - * 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 loadInternal (source: Core.Webgl.RequestSource, settings: OpenSettings, loadGeometry: boolean) { + const request = this._viewer.load(source, settings) + + return new LoadRequest( + { + onProgress: (p) => this.onProgress(p), + onError: (e) => this.onError(e), + onDone: () => this.onDone() + }, + request, + source.url, + (vim) => this.initVim(vim, settings, loadGeometry) + ) } - private initVim (vim : Core.Webgl.Vim, settings: AddSettings) { - this._viewer.add(vim) - vim.onLoadingUpdate.subscribe(() => { - this._viewer.gizmos.loading.visible = vim.isLoading - if (settings.autoFrame !== false && !vim.isLoading) { + private async initVim (vim: Core.Webgl.IWebglVim, settings: AddSettings, loadGeometry: boolean) { + if (loadGeometry) { + await vim.load() + if (settings.autoFrame !== false) { this._viewer.camera.snap().frame(vim) this._viewer.camera.save() } - }) - if (settings.loadEmpty !== true) { - void vim.loadAll() } } } diff --git a/src/vim-web/react-viewers/webgl/sectionBox.ts b/src/vim-web/react-viewers/webgl/sectionBox.ts index 2dc45a5aa..863ca043e 100644 --- a/src/vim-web/react-viewers/webgl/sectionBox.ts +++ b/src/vim-web/react-viewers/webgl/sectionBox.ts @@ -1,11 +1,11 @@ // useVimSectionBox.ts import * as Core from '../../core-viewers'; -import {SectionBoxAdapter, SectionBoxRef, useSectionBox } from '../state/sectionBoxState'; +import {ISectionBoxAdapter, SectionBoxApi, useSectionBox } from '../state/sectionBoxState'; -export function useWebglSectionBox(viewer: Core.Webgl.Viewer): SectionBoxRef { - const vimAdapter: SectionBoxAdapter = { - setClip: (b) => { - viewer.gizmos.sectionBox.clip = b; +export function useWebglSectionBox(viewer: Core.Webgl.Viewer): SectionBoxApi { + const vimAdapter: ISectionBoxAdapter = { + setActive: (b) => { + viewer.gizmos.sectionBox.active = b; }, setVisible: (b) => { viewer.gizmos.sectionBox.visible = b; diff --git a/src/vim-web/react-viewers/webgl/settings.ts b/src/vim-web/react-viewers/webgl/settings.ts index 58bd49b94..62a63867d 100644 --- a/src/vim-web/react-viewers/webgl/settings.ts +++ b/src/vim-web/react-viewers/webgl/settings.ts @@ -5,8 +5,15 @@ import { ControlBarCameraSettings, ControlBarCursorSettings, ControlBarMeasureSe export type PartialWebglSettings = RecursivePartial /** - * Complete settings configuration for the Vim viewer - * @interface Settings + * React UI feature toggles, passed to `React.Webgl.createViewer(container, settings)`. + * Controls which UI panels and toolbar buttons are shown. + * Access at runtime via `viewer.settings.update(s => { s.ui.panelBimTree = false })`. + * Not to be confused with {@link ViewerSettings} (renderer config) or {@link VimSettings} (per-model transform). + * + * @example + * const viewer = await React.Webgl.createViewer(div, { + * ui: { panelBimTree: false, miscHelp: false } + * }) */ export type WebglSettings = { capacity: { diff --git a/src/vim-web/react-viewers/webgl/settingsPanel.ts b/src/vim-web/react-viewers/webgl/settingsPanel.ts index 8f6faee57..8194a805c 100644 --- a/src/vim-web/react-viewers/webgl/settingsPanel.ts +++ b/src/vim-web/react-viewers/webgl/settingsPanel.ts @@ -1,6 +1,6 @@ import { THREE } from "../.."; import { Viewer } from "../../core-viewers/webgl"; -import { isTrue } from "../settings"; +import { isTrue } from "../settings/userBoolean"; import { SettingsItem } from "../settings/settingsItem"; import { SettingsPanelKeys } from "../settings/settingsKeys"; import { getControlBarCameraSettings, getControlBarCursorSettings, getControlBarSectionBoxSettings, getControlBarVisibilitySettings } from "../settings/settingsPanelContent"; diff --git a/src/vim-web/react-viewers/webgl/viewer.tsx b/src/vim-web/react-viewers/webgl/viewer.tsx index 0ded9cc0d..02dcdcad5 100644 --- a/src/vim-web/react-viewers/webgl/viewer.tsx +++ b/src/vim-web/react-viewers/webgl/viewer.tsx @@ -25,42 +25,49 @@ import { addPerformanceCounter } from '../panels/performance' import { applyWebglBindings } from './inputsBindings' import { CursorManager } from '../helpers/cursor' import { useSettings } from '../settings/settingsState' -import { TreeActionRef } from '../bim/bimTree' +import { TreeActionApi } from '../bim/bimTree' import { Container, createContainer } from '../container' import { useViewerState } from './viewerState' import { LogoMemo } from '../panels/logo' -import { ViewerRef } from './viewerRef' +import { WebglViewerApi } from './viewerApi' import { useBimInfo } from '../bim/bimInfoData' import { whenTrue } from '../helpers/utils' import { ComponentLoader } from './loading' -import { Modal, ModalHandle } from '../panels/modal' +import { Modal, ModalApi } from '../panels/modal' import { SectionBoxPanel } from '../panels/sectionBoxPanel' import { useWebglSectionBox } from './sectionBox' -import { useWebglCamera } from './camera' +import { useWebglFraming } from './camera' import { useViewerInput } from '../state/viewerInputs' import { IsolationPanel } from '../panels/isolationPanel' import { useWebglIsolation } from './isolation' -import { GenericPanelHandle } from '../generic' +import { GenericPanelApi } from '../generic/genericPanel' import { ControllablePromise } from '../../utils' -import { SettingsCustomizer } from '../settings/settingsItem' +import { SettingsCustomization } from '../settings/settingsItem' import { getDefaultSettings, PartialWebglSettings, WebglSettings } from './settings' import { isTrue } from '../settings/userBoolean' import { SettingsPanel } from '../settings/settingsPanel' import { applyWebglSettings, getWebglSettingsContent } from './settingsPanel' /** - * Creates a UI container along with a VIM.Viewer and its associated React viewer. - * @param container An optional container object. If none is provided, a container will be created. - * @param settings UI Component settings. -* @param coreSettings Viewer settings. - * @returns An object containing the resulting container, reactRoot, and viewer. + * Creates a WebGL viewer with full React UI (BIM tree, context menu, control bar, etc.). + * Returns a {@link WebglViewerApi} for programmatic interaction. + * + * @param container An optional container or DOM element. If none is provided, one will be created. + * @param settings React UI feature toggles (panels, buttons). See {@link WebglSettings}. + * @param coreSettings Core renderer config (camera, materials, lighting). See {@link ViewerSettings}. + * @returns A promise resolving to the viewer API. + * + * @example + * const viewer = await React.Webgl.createViewer(document.getElementById('app')) + * const vim = await viewer.load({ url: 'model.vim' }).getVim() + * viewer.framing.frameScene.call() */ -export function createViewer ( +export function createWebglViewer ( container?: Container | HTMLElement, settings: PartialWebglSettings = {}, coreSettings: Core.Webgl.PartialViewerSettings = {} -) : Promise { - const controllablePromise = new ControllablePromise() +) : Promise { + const controllablePromise = new ControllablePromise() // Create the container const cmpContainer = container instanceof HTMLElement @@ -68,14 +75,14 @@ export function createViewer ( : container ?? createContainer() // Create the viewer inside the container - const viewer = new Core.Webgl.Viewer(coreSettings) + const viewer = Core.Webgl.createViewer(coreSettings) viewer.viewport.reparent(cmpContainer.gfx) // Create the React root const reactRoot = createRoot(cmpContainer.ui) // Patch the viewer to clean up after itself - const patchRef = (cmp : ViewerRef) => { + const patchRef = (cmp : WebglViewerApi) => { cmp.dispose = () => { viewer.dispose() cmpContainer.dispose() @@ -87,10 +94,10 @@ export function createViewer ( } reactRoot.render( - controllablePromise.resolve(patchRef(cmp))} + onMount = {(cmp : WebglViewerApi) => controllablePromise.resolve(patchRef(cmp))} settings={settings} /> ) @@ -104,23 +111,23 @@ export function createViewer ( * @param onMount A callback function triggered when the viewer is mounted. Receives a reference to the Vim viewer. * @param settings Optional settings for configuring the Vim viewer's behavior. */ -export function Viewer (props: { +export function WebglViewerComponent (props: { container: Container viewer: Core.Webgl.Viewer - onMount: (viewer: ViewerRef) => void + onMount: (viewer: WebglViewerApi) => void settings?: PartialWebglSettings }) { const settings = useSettings(props.settings ?? {}, getDefaultSettings(), (s) => applyWebglSettings(s)) - const modal = useRef(null) + const modal = useRef(null) const sectionBoxRef = useWebglSectionBox(props.viewer) - const isolationPanelHandle = useRef(null) - const sectionBoxPanelHandle = useRef(null) + const isolationPanelHandle = useRef(null) + const sectionBoxPanelHandle = useRef(null) - const camera = useWebglCamera(props.viewer, sectionBoxRef) + const framing = useWebglFraming(props.viewer, sectionBoxRef) const cursor = useMemo(() => new CursorManager(props.viewer), []) const loader = useRef(new ComponentLoader(props.viewer, modal, settings.value)) - useViewerInput(props.viewer.inputs, camera) + useViewerInput(props.viewer.inputs, framing) const side = useSideState( isTrue(settings.value.ui.panelBimTree) || @@ -132,10 +139,10 @@ export function Viewer (props: { const bimInfoRef = useBimInfo() const viewerState = useViewerState(props.viewer) - const treeRef = useRef() + const treeRef = useRef() const performanceRef = useRef(null) const isolationRef = useWebglIsolation(props.viewer) - const controlBar = useControlBar(props.viewer, camera, modal.current, side, cursor, settings.value, sectionBoxRef, isolationRef, controlBarCustom) + const controlBar = useControlBar(props.viewer, framing, modal.current, side, cursor, settings.value, sectionBoxRef, isolationRef, controlBarCustom) useEffect(() => { side.setHasBim(viewerState.vim.get()?.bim !== undefined) @@ -163,22 +170,25 @@ export function Viewer (props: { // Setup custom input scheme props.viewer.viewport.canvas.tabIndex = 0 - applyWebglBindings(props.viewer, camera, isolationRef, side) + applyWebglBindings(props.viewer, framing, isolationRef, side) // Register context menu const subContext = props.viewer.inputs.onContextMenu.subscribe(showContextMenu) props.onMount({ + type: 'webgl', 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), + unload: (vim) => props.viewer.unload(vim), isolation: isolationRef, - camera, + framing, settings: { update : settings.update, register : settings.register, - customize : (c: SettingsCustomizer) => settings.customizer.set(c) + customize : (c: SettingsCustomization) => settings.customizer.set(c) }, get isolationPanel(){ return isolationPanelHandle.current @@ -211,7 +221,7 @@ export function Viewer (props: { <> { - + @@ -256,7 +266,7 @@ export function Viewer (props: { Core.Webgl.IWebglLoadRequest + + /** + * Opens a vim file without loading geometry. + * Wraps core.load() without building geometry. Use for BIM-only queries or + * selective loading via vim.load(subset). For headless opening, use core.load() directly. + * @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) => Core.Webgl.IWebglLoadRequest + + /** + * Unloads a vim from the viewer and disposes it. + * @param vim The vim to unload + */ + unload: (vim: Core.Webgl.IWebglVim) => void + + /** + * Isolation API managing isolation state in the viewer. + */ + isolation: IsolationApi + + /** + * Section box API managing the section box in the viewer. + */ + sectionBox: SectionBoxApi + + /** + * Context menu API managing the content and behavior of the context menu. + */ + contextMenu: ContextMenuApi + + /** + * Control bar API managing the content and behavior of the control bar. + */ + controlBar: ControlBarApi + + /** + * Settings API managing settings applied to the viewer. + */ + settings: SettingsApi + + /** + * Message API to interact with the loading box. + */ + modal: ModalApi + + /** + * High-level framing API with semantic operations (frame selection, auto-camera). + * For low-level camera control (orbit, pan, zoom, snap/lerp), use {@link core}.camera instead. + * @see {@link FramingApi} + */ + framing: FramingApi + + /** + * API To interact with the BIM info panel. + */ + bimInfo: BimInfoPanelApi + + /** + * API to interact with the isolation panel. + */ + isolationPanel : GenericPanelApi + + /** + * API to interact with the section box panel. + */ + sectionBoxPanel : GenericPanelApi + + /** + * Cleans up and releases resources used by the viewer. + */ + dispose: () => void +} diff --git a/src/vim-web/react-viewers/webgl/viewerRef.ts b/src/vim-web/react-viewers/webgl/viewerRef.ts deleted file mode 100644 index 0b5614578..000000000 --- a/src/vim-web/react-viewers/webgl/viewerRef.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * @module public-api - */ - -import * as Core from '../../core-viewers' -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 { ComponentLoader } 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' -/** -* Settings API managing settings applied to the viewer. -*/ -export type SettingsRef = { - // Double lambda is required to prevent react from using reducer pattern - // https://stackoverflow.com/questions/59040989/usestate-with-a-lambda-invokes-the-lambda-when-set - - /** - * Allows updating settings by providing a callback function. - * @param updater A function that updates the current settings. - */ - update : (updater: (settings: T) => void) => void - - /** - * Registers a callback function to be notified when settings are updated. - * @param callback A function to be called when settings are updated, receiving the updated settings. - */ - register : (callback: (settings: T) => void) => void - - /** - * Customizes the settings panel by providing a customizer function. - * @param customizer A function that modifies the settings items. - */ - customize : (customizer: (items: SettingsItem[]) => SettingsItem[]) => void - -} - - - -/** - * Reference to manage help message functionality in the viewer. - */ -export type HelpRef = { - /** - * Displays the help message. - * @param value Boolean value to show or hide the help message. - * @returns void - */ - show(value: boolean): void - - /** - * Returns the current state of the help message. - * @returns boolean indicating if help message is currently shown - */ - isShow(): boolean -} - -/** - * Root-level API of the Vim viewer. - */ -export type ViewerRef = { - /** - * HTML structure containing the viewer. - */ - container: Container - - /** - * Vim WebGL viewer around which the WebGL viewer is built. - */ - core: Core.Webgl.Viewer - - /** - * Vim WebGL loader to download VIMs. - */ - loader: ComponentLoader - - /** - * Isolation API managing isolation state in the viewer. - */ - isolation: IsolationRef - - /** - * Section box API managing the section box in the viewer. - */ - sectionBox: SectionBoxRef - - /** - * Context menu API managing the content and behavior of the context menu. - */ - contextMenu: ContextMenuRef - - /** - * Control bar API managing the content and behavior of the control bar. - */ - controlBar: ControlBarRef - - /** - * Settings API managing settings applied to the viewer. - */ - settings: SettingsRef - - /** - * Message API to interact with the loading box. - */ - modal: ModalHandle - - /** - * Camera API to interact with the viewer camera at a higher level. - */ - camera: CameraRef - - /** - * API To interact with the BIM info panel. - */ - bimInfo: BimInfoPanelRef - - /** - * API to interact with the isolation panel. - */ - isolationPanel : GenericPanelHandle - - /** - * API to interact with the isolation panel. - */ - sectionBoxPanel : GenericPanelHandle - - /** - * Cleans up and releases resources used by the viewer. - */ - dispose: () => void -} diff --git a/src/vim-web/react-viewers/webgl/viewerState.ts b/src/vim-web/react-viewers/webgl/viewerState.ts index 6fa4c1133..70ae5a047 100644 --- a/src/vim-web/react-viewers/webgl/viewerState.ts +++ b/src/vim-web/react-viewers/webgl/viewerState.ts @@ -8,8 +8,8 @@ import { AugmentedElement, getElements } from '../helpers/element' import { StateRef, useStateRef } from '../helpers/reactUtils' export type ViewerState = { - vim: StateRef - selection: StateRef + vim: StateRef + selection: StateRef elements: StateRef filter: StateRef } @@ -21,11 +21,11 @@ export function useViewerState (viewer: Core.Webgl.Viewer) : ViewerState { } const getSelection = () => { - return [...viewer.selection.getAll()].filter(o => o.type === 'Element3D') + return [...viewer.selection.getAll()].filter((o): o is Core.Webgl.IElement3D => o.type === 'Element3D') } - const vim = useStateRef(getVim()) - const selection = useStateRef(getSelection()) + const vim = useStateRef(getVim()) + const selection = useStateRef(getSelection()) const allElements = useStateRef([]) const filteredElements = useStateRef([]) const filter = useStateRef('') 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/debounce.ts b/src/vim-web/utils/debounce.ts index 161586575..4e808222a 100644 --- a/src/vim-web/utils/debounce.ts +++ b/src/vim-web/utils/debounce.ts @@ -1,5 +1,6 @@ -import { ISignal, SignalDispatcher } from 'ste-signals'; +import type { ISignal } from '../core-viewers/shared/events' +import { SignalDispatcher } from 'ste-signals' export function debounce void>(func: T, delay: number): [(...args: Parameters) => void, () => void] { let timeoutId: ReturnType; diff --git a/src/vim-web/utils/index.ts b/src/vim-web/utils/index.ts index 8a1816f7f..803b4a8d3 100644 --- a/src/vim-web/utils/index.ts +++ b/src/vim-web/utils/index.ts @@ -1,10 +1,9 @@ export * from './array' +export * from './asyncQueue' export * from './debounce' -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/interfaces.ts b/src/vim-web/utils/interfaces.ts deleted file mode 100644 index 173e32a3b..000000000 --- a/src/vim-web/utils/interfaces.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface ForEachable { - forEach(callback: (value: T) => void): void; -} \ 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 diff --git a/src/vim-web/utils/validation.ts b/src/vim-web/utils/validation.ts index fe0d75715..f8647029f 100644 --- a/src/vim-web/utils/validation.ts +++ b/src/vim-web/utils/validation.ts @@ -2,6 +2,7 @@ import * as THREE from 'three' import * as Core from '../core-viewers' import { isURL } from './url' import { RGBA } from '../core-viewers/ultra/rpcTypes' +import { materialHandles, type MaterialHandle } from '../core-viewers/ultra/rpcClient' export class Validation { //= =========================================================================== @@ -75,7 +76,7 @@ export class Validation { } static isMaterialHandle (handle: number): boolean { - if (!Core.Ultra.materialHandles.includes(handle as Core.Ultra.MaterialHandle)) { + if (!materialHandles.includes(handle as MaterialHandle)) { console.warn(`Invalid material handle ${handle}. Aborting operation.`) return false } diff --git a/tsconfig.types.json b/tsconfig.types.json index a58a0e489..1de891c05 100644 --- a/tsconfig.types.json +++ b/tsconfig.types.json @@ -3,6 +3,7 @@ "compilerOptions": { "declaration": true, "emitDeclarationOnly": true, + "stripInternal": true, "outDir": "./dist/types", "rootDir": "./src/vim-web" },