diff --git a/CHANGELOG.md b/CHANGELOG.md index 796ef872b..7ebae2275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package root +## Unreleased + +- Add VR/AR controls in UI to start/end WebXR sessions and toggle AR DOM overlay (#558) + - dat.GUI: new `XR` folder with `Enter VR`, `Enter AR`, `AR DOM overlay`, `Exit XR` + - Phoenix menu: new `XR` section exposing the same actions + - Angular UI: `VR` and `AR` toggles wired to `EventDisplay` XR handlers + +- Add per-collection "Extend to radius" option for tracks (#177) + - New helper: `RKHelper.extrapolateFromLastPosition(track, radius)` + - UI: dat.GUI and Phoenix menu controls to toggle extension and set radius + - Scene update: `SceneManager.extendCollectionTracks(collectionName, radius, enable)` + diff --git a/guides/developers/README.md b/guides/developers/README.md index d2233378d..713b0da7d 100644 --- a/guides/developers/README.md +++ b/guides/developers/README.md @@ -12,6 +12,7 @@ * [Phoenix event display](./event-display.md) * [Event data format](./event_data_format.md) * [Event data loader](./event-data-loader.md) +* [Track extension to radius](./track-extension.md) * [Using JSROOT](./using-jsroot.md) * [Running with XR (AR/VR) support](./running-with-xr-support.md) * [Convert GDML/ROOT Geometry to GLTF](./convert-gdml-to-gltf.md) diff --git a/guides/developers/running-with-xr-support.md b/guides/developers/running-with-xr-support.md index 50e33d4bd..a2d72cc90 100644 --- a/guides/developers/running-with-xr-support.md +++ b/guides/developers/running-with-xr-support.md @@ -14,3 +14,15 @@ Now navigate to `https://localhost:4200` (notice the **https**) in your browser ## Debugging XR in the browser Check out the [WebXR API Emulator extension](https://github.com/MozillaReality/WebXR-emulator-extension#how-to-use) to test and debug XR directly in the browser. + +## Using the new XR UI + +You can now start XR sessions directly from the Phoenix UI: + +- In dat.GUI: open the `XR` folder and click `Enter VR` or `Enter AR`. Use `AR DOM overlay` to show Phoenix overlays on top of the AR scene, and `Exit XR` to leave. +- In the Phoenix menu: open the `XR` section and use the same actions (`Enter VR`, `Enter AR`, `AR DOM overlay`, `Exit XR`). +- In the Angular app UI: the top UI menu includes `VR` and `AR` toggles that call into the same XR session handlers. + +Notes: +- WebXR requires a compatible browser/device and HTTPS. When developing locally, use the SSL start script above. +- AR scales the scene down and reduces camera near to suit real-world scale. Exiting AR restores previous values automatically. diff --git a/guides/developers/track-extension.md b/guides/developers/track-extension.md new file mode 100644 index 000000000..a083e3663 --- /dev/null +++ b/guides/developers/track-extension.md @@ -0,0 +1,96 @@ +# Track Extension to Radius + +## Overview + +Phoenix supports optionally extending tracks with measured hits out to a specified transverse radius using Runge-Kutta propagation. This feature addresses issue #177 and allows tracks that already have measurements (e.g., `CombinedInDetTracks`) to be extended so they reach the calorimeter region, similar to tracks without measurements that are automatically propagated. + +## User Interface + +The extension feature is available on a **per-collection basis** through both the dat.GUI menu and Phoenix menu. + +### dat.GUI + +For each track collection, you'll find: +- **Extend to radius** (checkbox): Toggle to enable/disable track extension +- **Radius** (slider, 100-5000 mm): Set the target transverse radius + +### Phoenix Menu + +Under each track collection's "Draw Options": +- **Extend to radius** (checkbox): Toggle extension on/off +- **Extend radius** (slider, 100-5000 mm): Configure target radius + +Changes take effect immediately — toggling or adjusting the radius rebuilds the track geometries in the scene. + +## Implementation Details + +### RKHelper + +A new method `RKHelper.extrapolateFromLastPosition(track, radius)` extrapolates from the last measured hit outward until the track reaches the specified transverse radius (or propagation limits are hit). + +**Parameters:** +- `track`: Track object with `pos` (measured hits) and `dparams` (track parameters) +- `radius`: Target transverse radius in mm + +**Returns:** Array of additional position points `[x, y, z][]` (does not include the last measured point) + +### SceneManager + +The `SceneManager.extendCollectionTracks(collectionName, radius, enable)` method applies extension to all tracks in a collection: + +1. Retrieves the collection group from EventData +2. For each track: + - Calls `RKHelper.extrapolateFromLastPosition` if enabled + - Rebuilds the tube and line geometries using measured + extrapolated points + - Persists extension state in `userData`: + - `extendedToRadius`: boolean (enabled/disabled) + - `extendRadius`: number (radius in mm) + - `extendedPos`: number[][] (array of extrapolated points) + +**Note:** The original `track.pos` array is never modified — extrapolated points are stored separately. + +### Performance Considerations + +For collections with thousands of tracks: +- **Throttling**: Consider debouncing UI slider changes (e.g., only apply on `onFinishChange`) +- **Worker threads**: For very large datasets, compute extrapolation in a Web Worker to avoid blocking the main thread +- **Current implementation**: Uses synchronous RK propagation; suitable for typical event sizes (< 1000 tracks per collection) + +## Example Usage + +```typescript +// Enable extension for "CombinedInDetTracks" collection to 1500 mm radius +sceneManager.extendCollectionTracks('CombinedInDetTracks', 1500, true); + +// Disable extension (revert to measured-only) +sceneManager.extendCollectionTracks('CombinedInDetTracks', 1500, false); + +// Access extension state +const trackGroup = collection.children[0]; +const params = trackGroup.userData; +if (params.extendedToRadius) { + console.log(`Extended to ${params.extendRadius} mm`); + console.log(`Extrapolated points:`, params.extendedPos); +} +``` + +## Testing + +Unit test for `RKHelper.extrapolateFromLastPosition`: +- `packages/phoenix-event-display/src/tests/helpers/rk-helper.test.ts` + +Integration test for `SceneManager.extendCollectionTracks`: +- `packages/phoenix-event-display/src/tests/managers/three-manager/scene-manager.test.ts` + +## Related Files + +- `packages/phoenix-event-display/src/helpers/rk-helper.ts` +- `packages/phoenix-event-display/src/managers/three-manager/scene-manager.ts` +- `packages/phoenix-event-display/src/managers/ui-manager/dat-gui-ui.ts` +- `packages/phoenix-event-display/src/managers/ui-manager/phoenix-menu/phoenix-menu-ui.ts` + +## See Also + +- [Event display guide](./event-display.md) +- [Event data format](./event_data_format.md) +- Issue #177: "Optionally extend all tracks to a radius" diff --git a/packages/phoenix-event-display/src/helpers/rk-helper.ts b/packages/phoenix-event-display/src/helpers/rk-helper.ts index 0fee19b11..e11e692fc 100644 --- a/packages/phoenix-event-display/src/helpers/rk-helper.ts +++ b/packages/phoenix-event-display/src/helpers/rk-helper.ts @@ -94,4 +94,80 @@ export class RKHelper { return positions.concat(extrapolatedPos); } + + /** + * Extrapolate track from its last measured position out to a given transverse radius. + * Returns only the appended positions (does not include the last measured point). + * @param track Track which is to be extrapolated (should have `pos` and `dparams`) + * @param radius transverse radius in mm to extrapolate to + */ + public static extrapolateFromLastPosition( + track: { pos?: number[][]; dparams?: any }, + radius: number, + ): number[][] { + if (!track?.dparams) return []; + + const lastPosArr = + track.pos && track.pos.length ? track.pos[track.pos.length - 1] : null; + if (!lastPosArr) return []; + + const lastPos = new Vector3(lastPosArr[0], lastPosArr[1], lastPosArr[2]); + + // Infer start direction using last two measured points if available + let startDir: Vector3 | null = null; + if (track.pos && track.pos.length > 1) { + const prev = track.pos[track.pos.length - 2]; + const prevV = new Vector3(prev[0], prev[1], prev[2]); + startDir = lastPos.clone().sub(prevV).normalize(); + } + + const dparams = track.dparams; + const d0 = dparams[0]; + const z0 = dparams[1]; + const phi = dparams[2]; + let theta = dparams[3]; + const qop = dparams[4]; + + if (theta < 0) theta += Math.PI; + + let p: number; + if (qop !== 0) p = Math.abs(1 / qop); + else p = Number.MAX_VALUE; + const q = Math.round(p * qop); + + if (!startDir) + startDir = CoordinateHelper.sphericalToCartesian( + p, + theta, + phi, + ).normalize(); + + const inbounds = (pos: Vector3) => + Math.sqrt(pos.x * pos.x + pos.y * pos.y) <= radius; + + const traj = RungeKutta.propagate( + lastPos, + startDir, + p, + q, + 5, + 1500, + inbounds, + ); + + const extrapolatedPos = traj.map((val) => [ + val.pos.x, + val.pos.y, + val.pos.z, + ]); + + // Remove any point equal to lastPos (first point of traj may be identical) + const eps = 1e-6; + return extrapolatedPos.filter((pArr) => { + const dx = pArr[0] - lastPos.x; + const dy = pArr[1] - lastPos.y; + const dz = pArr[2] - lastPos.z; + return Math.abs(dx) > eps || Math.abs(dy) > eps || Math.abs(dz) > eps; + }); + } } diff --git a/packages/phoenix-event-display/src/managers/three-manager/scene-manager.ts b/packages/phoenix-event-display/src/managers/three-manager/scene-manager.ts index 68f772567..4d2faf14e 100644 --- a/packages/phoenix-event-display/src/managers/three-manager/scene-manager.ts +++ b/packages/phoenix-event-display/src/managers/three-manager/scene-manager.ts @@ -18,6 +18,10 @@ import { Quaternion, DoubleSide, BoxGeometry, + CatmullRomCurve3, + TubeGeometry, + MeshToonMaterial, + Line, type Object3DEventMap, } from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; @@ -25,6 +29,7 @@ import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'; import { Font } from 'three/examples/jsm/loaders/FontLoader.js'; import { Cut } from '../../lib/models/cut.model'; import { CoordinateHelper } from '../../helpers/coordinate-helper'; +import { RKHelper } from '../../helpers/rk-helper'; import HelvetikerFont from './fonts/helvetiker_regular.typeface.json'; /** @@ -356,6 +361,93 @@ export class SceneManager { return this.getObjectsGroup(SceneManager.EVENT_DATA_ID); } + /** + * Optionally extend all tracks in a named collection out to a transverse radius. + * Rebuilds the per-track geometries (tube + line) by appending RK extrapolated points. + * NOTE: For large collections, consider throttling this method or computing on a worker thread. + * @param collectionName name of the collection group under EventData + * @param radius transverse radius in mm to extend to + * @param enable whether to enable (append extrapolated points) or disable (revert to measured only) + */ + public extendCollectionTracks( + collectionName: string, + radius: number, + enable: boolean, + ) { + const eventData = this.getEventData(); + if (!eventData) return; + const collection = eventData.getObjectByName(collectionName) as Group; + if (!collection) return; + + for (const child of Object.values(collection.children)) { + const trackGroup = child as Group; + const trackParams = (trackGroup as any).userData; + if (!trackParams) continue; + if (!trackParams.pos || !trackParams.dparams) continue; + + const basePos: number[][] = trackParams.pos; + let positions: number[][] = basePos; + let extendedPos: number[][] = []; + + if (enable) { + const extra = RKHelper.extrapolateFromLastPosition(trackParams, radius); + if (extra && extra.length) { + positions = basePos.concat(extra); + extendedPos = extra; + } + } + + // Persist extension state in userData for downstream code/export + trackParams.extendedToRadius = enable; + trackParams.extendRadius = radius; + trackParams.extendedPos = extendedPos; + + // Build new geometries from positions + const points: Vector3[] = positions.map( + (p) => new Vector3(p[0], p[1], p[2]), + ); + const curve = new CatmullRomCurve3(points); + const vertices = curve.getPoints(50); + + // Find tube (Mesh) and line (Line) children + let tubeObject: Mesh | undefined; + let lineObject: Line | undefined; + for (const obj of trackGroup.children) { + if ( + (obj as any).type === 'Mesh' && + (obj as any).material && + (obj as any).name !== 'Track' + ) { + tubeObject = obj as Mesh; + } + if ((obj as any).type === 'Line') { + lineObject = obj as Line; + } + } + + const linewidth = trackParams.linewidth ? trackParams.linewidth : 2; + + if (tubeObject) { + try { + const newGeo: any = new TubeGeometry(curve, undefined, linewidth); + if (tubeObject.geometry) tubeObject.geometry.dispose?.(); + tubeObject.geometry = newGeo; + } catch (e) { + // Fall back silently if TubeGeometry not available + } + } + + if (lineObject) { + const newLineGeom = new BufferGeometry().setFromPoints(vertices); + if (lineObject.geometry) lineObject.geometry.dispose?.(); + lineObject.geometry = newLineGeom; + } + + // Update trackGroup userData to reflect current state + (trackGroup as any).userData = trackParams; + } + } + /** * Get geometries inside the scene. * @returns A group of objects with geometries. diff --git a/packages/phoenix-event-display/src/managers/ui-manager/dat-gui-ui.ts b/packages/phoenix-event-display/src/managers/ui-manager/dat-gui-ui.ts index 81af57d8d..987d801b1 100644 --- a/packages/phoenix-event-display/src/managers/ui-manager/dat-gui-ui.ts +++ b/packages/phoenix-event-display/src/managers/ui-manager/dat-gui-ui.ts @@ -8,6 +8,8 @@ import { } from 'three'; import { ThreeManager } from '../three-manager/index'; import { SceneManager } from '../three-manager/scene-manager'; +import { XRSessionType } from '../three-manager/xr/xr-manager'; +import { ARManager } from '../three-manager/xr/ar-manager'; import { Cut } from '../../lib/models/cut.model'; import type { PhoenixUI } from './phoenix-ui'; @@ -271,6 +273,8 @@ export class DatGUIMenuUI implements PhoenixUI { this.guiParameters[collectionName] = { show: true, color: 0x000000, + extendTracks: false, + extendRadius: 1500, randomColor: () => this.three.getColorManager().collectionColorRandom(collectionName), resetCut: () => @@ -303,6 +307,23 @@ export class DatGUIMenuUI implements PhoenixUI { this.three.getColorManager().collectionColor(collectionName, value), ); colorMenu.setValue(collectionColor?.getHex()); + // Option to optionally extend measured tracks to a radius + collFolder + .add(this.guiParameters[collectionName], 'extendTracks') + .name('Extend to radius') + .onChange((value: boolean) => { + const radius = this.guiParameters[collectionName].extendRadius; + this.sceneManager.extendCollectionTracks(collectionName, radius, value); + }); + collFolder + .add(this.guiParameters[collectionName], 'extendRadius', 100, 5000) + .name('Radius') + .onFinishChange((value: number) => { + const enabled = this.guiParameters[collectionName].extendTracks; + if (enabled) { + this.sceneManager.extendCollectionTracks(collectionName, value, true); + } + }); collFolder .add(this.guiParameters[collectionName], 'randomColor') .name('Random Color'); @@ -458,4 +479,38 @@ export class DatGUIMenuUI implements PhoenixUI { public getEventDataTypeFolder(typeName: string): GUI { return this.eventFolder.__folders[typeName]; } + + /** Add XR (VR/AR) controls to dat.GUI */ + public addXRControls(): void { + const xrFolder = this.gui.addFolder('XR'); + + const params = { + enterVR: () => + this.three.initXRSession(XRSessionType.VR, () => { + // No-op on end for dat.GUI + }), + enterAR: () => + this.three.initXRSession(XRSessionType.AR, () => { + // No-op on end for dat.GUI + }), + exitXR: () => { + // Try ending both in case we don't know which one is active + this.three.endXRSession(XRSessionType.VR); + this.three.endXRSession(XRSessionType.AR); + }, + arDomOverlay: ARManager.enableDomOverlay, + }; + + xrFolder + .add({ note: 'Requires HTTPS/WebXR capable browser' }, 'note') + .name('Info'); + + xrFolder.add(params, 'enterVR').name('Enter VR'); + xrFolder.add(params, 'enterAR').name('Enter AR'); + xrFolder + .add(params, 'arDomOverlay') + .name('AR DOM overlay') + .onChange((v: boolean) => (ARManager.enableDomOverlay = v)); + xrFolder.add(params, 'exitXR').name('Exit XR'); + } } diff --git a/packages/phoenix-event-display/src/managers/ui-manager/index.ts b/packages/phoenix-event-display/src/managers/ui-manager/index.ts index ce0d65f85..2eaa734e5 100644 --- a/packages/phoenix-event-display/src/managers/ui-manager/index.ts +++ b/packages/phoenix-event-display/src/managers/ui-manager/index.ts @@ -124,6 +124,8 @@ export class UIManager { configuration.forceColourTheme.toLocaleLowerCase() == 'dark', ); } + // XR controls (VR/AR) in both menus if present + this.uiMenus.forEach((menu) => menu.addXRControls?.()); // State manager this.stateManager = new StateManager(); if (configuration.phoenixMenuRoot) { diff --git a/packages/phoenix-event-display/src/managers/ui-manager/phoenix-menu/phoenix-menu-ui.ts b/packages/phoenix-event-display/src/managers/ui-manager/phoenix-menu/phoenix-menu-ui.ts index 1edf3bbab..731318a5a 100644 --- a/packages/phoenix-event-display/src/managers/ui-manager/phoenix-menu/phoenix-menu-ui.ts +++ b/packages/phoenix-event-display/src/managers/ui-manager/phoenix-menu/phoenix-menu-ui.ts @@ -7,6 +7,8 @@ import { } from 'three'; import { SceneManager } from '../../three-manager/scene-manager'; import { ThreeManager } from '../../three-manager/index'; +import { XRSessionType } from '../../three-manager/xr/xr-manager'; +import { ARManager } from '../../three-manager/xr/ar-manager'; import { PhoenixMenuNode } from './phoenix-menu-node'; import { Cut } from '../../../lib/models/cut.model'; import { ColorByOptionKeys, ColorOptions } from '../color-options'; @@ -26,6 +28,10 @@ export class PhoenixMenuUI implements PhoenixUI { private labelsFolder: PhoenixMenuNode; /** Manager for managing functions of the three.js scene. */ private sceneManager: SceneManager; + /** Track per-collection extend-to-radius state for Phoenix menu */ + private collectionExtendState: { + [key: string]: { enabled: boolean; radius: number }; + } = {}; /** * Create Phoenix menu UI with different controls related to detector geometry and event data. @@ -356,6 +362,40 @@ export class PhoenixMenuUI implements PhoenixUI { value, ), }); + + // Extension controls for tracks: add checkbox and radius slider + // Maintain state in this.collectionExtendState + if (!this.collectionExtendState[collectionName]) { + this.collectionExtendState[collectionName] = { + enabled: false, + radius: 1500, + }; + } + drawOptionsNode.addConfig({ + type: 'checkbox', + label: 'Extend to radius', + isChecked: this.collectionExtendState[collectionName].enabled, + onChange: (value: boolean) => { + this.collectionExtendState[collectionName].enabled = value; + const radius = this.collectionExtendState[collectionName].radius; + this.sceneManager.extendCollectionTracks(collectionName, radius, value); + }, + }); + + drawOptionsNode.addConfig({ + type: 'slider', + label: 'Extend radius', + min: 100, + max: 5000, + step: 10, + allowCustomValue: true, + onChange: (value: number) => { + this.collectionExtendState[collectionName].radius = value; + if (this.collectionExtendState[collectionName].enabled) { + this.sceneManager.extendCollectionTracks(collectionName, value, true); + } + }, + }); } /** @@ -488,4 +528,46 @@ export class PhoenixMenuUI implements PhoenixUI { this.eventFolder.loadStateFromJSON(this.eventFolderState); } } + + /** Add XR (VR/AR) controls to the Phoenix menu */ + public addXRControls(): void { + const xrRoot = this.phoenixMenuRoot.addChild('XR', undefined, 'vr'); + + // Enter VR / AR + xrRoot.addConfig({ + type: 'button', + label: 'Enter VR', + onClick: () => + this.three.initXRSession(XRSessionType.VR, () => { + // Session ended callback - nothing special here + }), + }); + + xrRoot.addConfig({ + type: 'button', + label: 'Enter AR', + onClick: () => + this.three.initXRSession(XRSessionType.AR, () => { + // Session ended callback - nothing special here + }), + }); + + // AR DOM overlay toggle + xrRoot.addConfig({ + type: 'checkbox', + label: 'AR DOM overlay', + isChecked: ARManager.enableDomOverlay, + onChange: (v: boolean) => (ARManager.enableDomOverlay = v), + }); + + // Exit XR (attempt both types) + xrRoot.addConfig({ + type: 'button', + label: 'Exit XR', + onClick: () => { + this.three.endXRSession(XRSessionType.VR); + this.three.endXRSession(XRSessionType.AR); + }, + }); + } } diff --git a/packages/phoenix-event-display/src/managers/ui-manager/phoenix-ui.ts b/packages/phoenix-event-display/src/managers/ui-manager/phoenix-ui.ts index 7d9f9a56d..5c24b1b66 100644 --- a/packages/phoenix-event-display/src/managers/ui-manager/phoenix-ui.ts +++ b/packages/phoenix-event-display/src/managers/ui-manager/phoenix-ui.ts @@ -73,4 +73,9 @@ export interface PhoenixUI { * @returns Folder of the event data type. */ getEventDataTypeFolder(typeName: string): T | undefined; + + /** + * Add XR (VR/AR) controls to the UI. + */ + addXRControls(): void; } diff --git a/packages/phoenix-event-display/src/tests/helpers/rk-helper.test.ts b/packages/phoenix-event-display/src/tests/helpers/rk-helper.test.ts index 035417c5c..0dc6cf720 100644 --- a/packages/phoenix-event-display/src/tests/helpers/rk-helper.test.ts +++ b/packages/phoenix-event-display/src/tests/helpers/rk-helper.test.ts @@ -83,4 +83,17 @@ describe('RKHelper', () => { [0, 0, 0], ]); }); + + it('should extrapolate from last measured position to a given radius', () => { + const track = { + dparams: [0, 0, 0, 1.5707963705062866, 0.001], + pos: [[0, 0, 0]], + }; + + const out = RKHelper.extrapolateFromLastPosition(track, 100); + expect(Array.isArray(out)).toBe(true); + if (out.length > 0) { + expect(out[0].length).toBe(3); + } + }); }); diff --git a/packages/phoenix-event-display/src/tests/managers/three-manager/scene-manager.test.ts b/packages/phoenix-event-display/src/tests/managers/three-manager/scene-manager.test.ts index d6c6b7cfc..3a08df7a7 100644 --- a/packages/phoenix-event-display/src/tests/managers/three-manager/scene-manager.test.ts +++ b/packages/phoenix-event-display/src/tests/managers/three-manager/scene-manager.test.ts @@ -389,5 +389,43 @@ describe('SceneManager', () => { expect(objName.name).toBe('TestCube'); expect(objName.parent.type).toBe('Scene'); }); + + it('should extend collection tracks to a radius and persist extension state', () => { + // Create mock track collection + const eventData = sceneManager.getEventData(); + const tracksTypeGroup = sceneManager.addEventDataTypeGroup('Tracks'); + const collection = new Group(); + collection.name = 'TestTracks'; + tracksTypeGroup.add(collection); + + // Add a mock track with pos and dparams + const trackGroup = new Group(); + trackGroup.name = 'Track'; + const trackParams = { + pos: [ + [0, 0, 0], + [100, 0, 0], + [200, 0, 0], + ], + dparams: [0, 0, 0, 1.5707963705062866, 0.001], + }; + (trackGroup as any).userData = trackParams; + collection.add(trackGroup); + + // Enable extension to radius 500 + sceneManager.extendCollectionTracks('TestTracks', 500, true); + + // Verify extension state is persisted in userData + const updatedParams = (trackGroup as any).userData; + expect(updatedParams.extendedToRadius).toBe(true); + expect(updatedParams.extendRadius).toBe(500); + expect(Array.isArray(updatedParams.extendedPos)).toBe(true); + + // Disable extension + sceneManager.extendCollectionTracks('TestTracks', 500, false); + const disabledParams = (trackGroup as any).userData; + expect(disabledParams.extendedToRadius).toBe(false); + expect(disabledParams.extendedPos.length).toBe(0); + }); }); });