Skip to content

Commit 2795d0c

Browse files
authored
Merge pull request #757 from DhanashreePetare/feature/track-extension-177
feat: add per-collection track extension to radius (#177)
2 parents 7b64d2b + 0488003 commit 2795d0c

File tree

9 files changed

+380
-0
lines changed

9 files changed

+380
-0
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
77

88
**Note:** Version bump only for package root
99

10+
## Unreleased
11+
12+
- Add per-collection "Extend to radius" option for tracks (#177)
13+
- New helper: `RKHelper.extrapolateFromLastPosition(track, radius)`
14+
- UI: dat.GUI and Phoenix menu controls to toggle extension and set radius
15+
- Scene update: `SceneManager.extendCollectionTracks(collectionName, radius, enable)`
16+
1017

1118

1219

guides/developers/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* [Phoenix event display](./event-display.md)
1313
* [Event data format](./event_data_format.md)
1414
* [Event data loader](./event-data-loader.md)
15+
* [Track extension to radius](./track-extension.md)
1516
* [Using JSROOT](./using-jsroot.md)
1617
* [Running with XR (AR/VR) support](./running-with-xr-support.md)
1718
* [Convert GDML/ROOT Geometry to GLTF](./convert-gdml-to-gltf.md)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Track Extension to Radius
2+
3+
## Overview
4+
5+
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.
6+
7+
## User Interface
8+
9+
The extension feature is available on a **per-collection basis** through both the dat.GUI menu and Phoenix menu.
10+
11+
### dat.GUI
12+
13+
For each track collection, you'll find:
14+
- **Extend to radius** (checkbox): Toggle to enable/disable track extension
15+
- **Radius** (slider, 100-5000 mm): Set the target transverse radius
16+
17+
### Phoenix Menu
18+
19+
Under each track collection's "Draw Options":
20+
- **Extend to radius** (checkbox): Toggle extension on/off
21+
- **Extend radius** (slider, 100-5000 mm): Configure target radius
22+
23+
Changes take effect immediately — toggling or adjusting the radius rebuilds the track geometries in the scene.
24+
25+
## Implementation Details
26+
27+
### RKHelper
28+
29+
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).
30+
31+
**Parameters:**
32+
- `track`: Track object with `pos` (measured hits) and `dparams` (track parameters)
33+
- `radius`: Target transverse radius in mm
34+
35+
**Returns:** Array of additional position points `[x, y, z][]` (does not include the last measured point)
36+
37+
### SceneManager
38+
39+
The `SceneManager.extendCollectionTracks(collectionName, radius, enable)` method applies extension to all tracks in a collection:
40+
41+
1. Retrieves the collection group from EventData
42+
2. For each track:
43+
- Calls `RKHelper.extrapolateFromLastPosition` if enabled
44+
- Rebuilds the tube and line geometries using measured + extrapolated points
45+
- Persists extension state in `userData`:
46+
- `extendedToRadius`: boolean (enabled/disabled)
47+
- `extendRadius`: number (radius in mm)
48+
- `extendedPos`: number[][] (array of extrapolated points)
49+
50+
**Note:** The original `track.pos` array is never modified — extrapolated points are stored separately.
51+
52+
### Performance Considerations
53+
54+
For collections with thousands of tracks:
55+
- **Throttling**: Consider debouncing UI slider changes (e.g., only apply on `onFinishChange`)
56+
- **Worker threads**: For very large datasets, compute extrapolation in a Web Worker to avoid blocking the main thread
57+
- **Current implementation**: Uses synchronous RK propagation; suitable for typical event sizes (< 1000 tracks per collection)
58+
59+
## Example Usage
60+
61+
```typescript
62+
// Enable extension for "CombinedInDetTracks" collection to 1500 mm radius
63+
sceneManager.extendCollectionTracks('CombinedInDetTracks', 1500, true);
64+
65+
// Disable extension (revert to measured-only)
66+
sceneManager.extendCollectionTracks('CombinedInDetTracks', 1500, false);
67+
68+
// Access extension state
69+
const trackGroup = collection.children[0];
70+
const params = trackGroup.userData;
71+
if (params.extendedToRadius) {
72+
console.log(`Extended to ${params.extendRadius} mm`);
73+
console.log(`Extrapolated points:`, params.extendedPos);
74+
}
75+
```
76+
77+
## Testing
78+
79+
Unit test for `RKHelper.extrapolateFromLastPosition`:
80+
- `packages/phoenix-event-display/src/tests/helpers/rk-helper.test.ts`
81+
82+
Integration test for `SceneManager.extendCollectionTracks`:
83+
- `packages/phoenix-event-display/src/tests/managers/three-manager/scene-manager.test.ts`
84+
85+
## Related Files
86+
87+
- `packages/phoenix-event-display/src/helpers/rk-helper.ts`
88+
- `packages/phoenix-event-display/src/managers/three-manager/scene-manager.ts`
89+
- `packages/phoenix-event-display/src/managers/ui-manager/dat-gui-ui.ts`
90+
- `packages/phoenix-event-display/src/managers/ui-manager/phoenix-menu/phoenix-menu-ui.ts`
91+
92+
## See Also
93+
94+
- [Event display guide](./event-display.md)
95+
- [Event data format](./event_data_format.md)
96+
- Issue #177: "Optionally extend all tracks to a radius"

packages/phoenix-event-display/src/helpers/rk-helper.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,80 @@ export class RKHelper {
9494

9595
return positions.concat(extrapolatedPos);
9696
}
97+
98+
/**
99+
* Extrapolate track from its last measured position out to a given transverse radius.
100+
* Returns only the appended positions (does not include the last measured point).
101+
* @param track Track which is to be extrapolated (should have `pos` and `dparams`)
102+
* @param radius transverse radius in mm to extrapolate to
103+
*/
104+
public static extrapolateFromLastPosition(
105+
track: { pos?: number[][]; dparams?: any },
106+
radius: number,
107+
): number[][] {
108+
if (!track?.dparams) return [];
109+
110+
const lastPosArr =
111+
track.pos && track.pos.length ? track.pos[track.pos.length - 1] : null;
112+
if (!lastPosArr) return [];
113+
114+
const lastPos = new Vector3(lastPosArr[0], lastPosArr[1], lastPosArr[2]);
115+
116+
// Infer start direction using last two measured points if available
117+
let startDir: Vector3 | null = null;
118+
if (track.pos && track.pos.length > 1) {
119+
const prev = track.pos[track.pos.length - 2];
120+
const prevV = new Vector3(prev[0], prev[1], prev[2]);
121+
startDir = lastPos.clone().sub(prevV).normalize();
122+
}
123+
124+
const dparams = track.dparams;
125+
const d0 = dparams[0];
126+
const z0 = dparams[1];
127+
const phi = dparams[2];
128+
let theta = dparams[3];
129+
const qop = dparams[4];
130+
131+
if (theta < 0) theta += Math.PI;
132+
133+
let p: number;
134+
if (qop !== 0) p = Math.abs(1 / qop);
135+
else p = Number.MAX_VALUE;
136+
const q = Math.round(p * qop);
137+
138+
if (!startDir)
139+
startDir = CoordinateHelper.sphericalToCartesian(
140+
p,
141+
theta,
142+
phi,
143+
).normalize();
144+
145+
const inbounds = (pos: Vector3) =>
146+
Math.sqrt(pos.x * pos.x + pos.y * pos.y) <= radius;
147+
148+
const traj = RungeKutta.propagate(
149+
lastPos,
150+
startDir,
151+
p,
152+
q,
153+
5,
154+
1500,
155+
inbounds,
156+
);
157+
158+
const extrapolatedPos = traj.map((val) => [
159+
val.pos.x,
160+
val.pos.y,
161+
val.pos.z,
162+
]);
163+
164+
// Remove any point equal to lastPos (first point of traj may be identical)
165+
const eps = 1e-6;
166+
return extrapolatedPos.filter((pArr) => {
167+
const dx = pArr[0] - lastPos.x;
168+
const dy = pArr[1] - lastPos.y;
169+
const dz = pArr[2] - lastPos.z;
170+
return Math.abs(dx) > eps || Math.abs(dy) > eps || Math.abs(dz) > eps;
171+
});
172+
}
97173
}

packages/phoenix-event-display/src/managers/three-manager/scene-manager.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,18 @@ import {
1818
Quaternion,
1919
DoubleSide,
2020
BoxGeometry,
21+
CatmullRomCurve3,
22+
TubeGeometry,
23+
MeshToonMaterial,
24+
Line,
2125
type Object3DEventMap,
2226
} from 'three';
2327
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
2428
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js';
2529
import { Font } from 'three/examples/jsm/loaders/FontLoader.js';
2630
import { Cut } from '../../lib/models/cut.model';
2731
import { CoordinateHelper } from '../../helpers/coordinate-helper';
32+
import { RKHelper } from '../../helpers/rk-helper';
2833
import HelvetikerFont from './fonts/helvetiker_regular.typeface.json';
2934

3035
/**
@@ -356,6 +361,93 @@ export class SceneManager {
356361
return this.getObjectsGroup(SceneManager.EVENT_DATA_ID);
357362
}
358363

364+
/**
365+
* Optionally extend all tracks in a named collection out to a transverse radius.
366+
* Rebuilds the per-track geometries (tube + line) by appending RK extrapolated points.
367+
* NOTE: For large collections, consider throttling this method or computing on a worker thread.
368+
* @param collectionName name of the collection group under EventData
369+
* @param radius transverse radius in mm to extend to
370+
* @param enable whether to enable (append extrapolated points) or disable (revert to measured only)
371+
*/
372+
public extendCollectionTracks(
373+
collectionName: string,
374+
radius: number,
375+
enable: boolean,
376+
) {
377+
const eventData = this.getEventData();
378+
if (!eventData) return;
379+
const collection = eventData.getObjectByName(collectionName) as Group;
380+
if (!collection) return;
381+
382+
for (const child of Object.values(collection.children)) {
383+
const trackGroup = child as Group;
384+
const trackParams = (trackGroup as any).userData;
385+
if (!trackParams) continue;
386+
if (!trackParams.pos || !trackParams.dparams) continue;
387+
388+
const basePos: number[][] = trackParams.pos;
389+
let positions: number[][] = basePos;
390+
let extendedPos: number[][] = [];
391+
392+
if (enable) {
393+
const extra = RKHelper.extrapolateFromLastPosition(trackParams, radius);
394+
if (extra && extra.length) {
395+
positions = basePos.concat(extra);
396+
extendedPos = extra;
397+
}
398+
}
399+
400+
// Persist extension state in userData for downstream code/export
401+
trackParams.extendedToRadius = enable;
402+
trackParams.extendRadius = radius;
403+
trackParams.extendedPos = extendedPos;
404+
405+
// Build new geometries from positions
406+
const points: Vector3[] = positions.map(
407+
(p) => new Vector3(p[0], p[1], p[2]),
408+
);
409+
const curve = new CatmullRomCurve3(points);
410+
const vertices = curve.getPoints(50);
411+
412+
// Find tube (Mesh) and line (Line) children
413+
let tubeObject: Mesh | undefined;
414+
let lineObject: Line | undefined;
415+
for (const obj of trackGroup.children) {
416+
if (
417+
(obj as any).type === 'Mesh' &&
418+
(obj as any).material &&
419+
(obj as any).name !== 'Track'
420+
) {
421+
tubeObject = obj as Mesh;
422+
}
423+
if ((obj as any).type === 'Line') {
424+
lineObject = obj as Line;
425+
}
426+
}
427+
428+
const linewidth = trackParams.linewidth ? trackParams.linewidth : 2;
429+
430+
if (tubeObject) {
431+
try {
432+
const newGeo: any = new TubeGeometry(curve, undefined, linewidth);
433+
if (tubeObject.geometry) tubeObject.geometry.dispose?.();
434+
tubeObject.geometry = newGeo;
435+
} catch (e) {
436+
// Fall back silently if TubeGeometry not available
437+
}
438+
}
439+
440+
if (lineObject) {
441+
const newLineGeom = new BufferGeometry().setFromPoints(vertices);
442+
if (lineObject.geometry) lineObject.geometry.dispose?.();
443+
lineObject.geometry = newLineGeom;
444+
}
445+
446+
// Update trackGroup userData to reflect current state
447+
(trackGroup as any).userData = trackParams;
448+
}
449+
}
450+
359451
/**
360452
* Get geometries inside the scene.
361453
* @returns A group of objects with geometries.

packages/phoenix-event-display/src/managers/ui-manager/dat-gui-ui.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ export class DatGUIMenuUI implements PhoenixUI<GUI> {
271271
this.guiParameters[collectionName] = {
272272
show: true,
273273
color: 0x000000,
274+
extendTracks: false,
275+
extendRadius: 1500,
274276
randomColor: () =>
275277
this.three.getColorManager().collectionColorRandom(collectionName),
276278
resetCut: () =>
@@ -303,6 +305,23 @@ export class DatGUIMenuUI implements PhoenixUI<GUI> {
303305
this.three.getColorManager().collectionColor(collectionName, value),
304306
);
305307
colorMenu.setValue(collectionColor?.getHex());
308+
// Option to optionally extend measured tracks to a radius
309+
collFolder
310+
.add(this.guiParameters[collectionName], 'extendTracks')
311+
.name('Extend to radius')
312+
.onChange((value: boolean) => {
313+
const radius = this.guiParameters[collectionName].extendRadius;
314+
this.sceneManager.extendCollectionTracks(collectionName, radius, value);
315+
});
316+
collFolder
317+
.add(this.guiParameters[collectionName], 'extendRadius', 100, 5000)
318+
.name('Radius')
319+
.onFinishChange((value: number) => {
320+
const enabled = this.guiParameters[collectionName].extendTracks;
321+
if (enabled) {
322+
this.sceneManager.extendCollectionTracks(collectionName, value, true);
323+
}
324+
});
306325
collFolder
307326
.add(this.guiParameters[collectionName], 'randomColor')
308327
.name('Random Color');

packages/phoenix-event-display/src/managers/ui-manager/phoenix-menu/phoenix-menu-ui.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export class PhoenixMenuUI implements PhoenixUI<PhoenixMenuNode> {
2626
private labelsFolder: PhoenixMenuNode;
2727
/** Manager for managing functions of the three.js scene. */
2828
private sceneManager: SceneManager;
29+
/** Track per-collection extend-to-radius state for Phoenix menu */
30+
private collectionExtendState: {
31+
[key: string]: { enabled: boolean; radius: number };
32+
} = {};
2933

3034
/**
3135
* Create Phoenix menu UI with different controls related to detector geometry and event data.
@@ -356,6 +360,40 @@ export class PhoenixMenuUI implements PhoenixUI<PhoenixMenuNode> {
356360
value,
357361
),
358362
});
363+
364+
// Extension controls for tracks: add checkbox and radius slider
365+
// Maintain state in this.collectionExtendState
366+
if (!this.collectionExtendState[collectionName]) {
367+
this.collectionExtendState[collectionName] = {
368+
enabled: false,
369+
radius: 1500,
370+
};
371+
}
372+
drawOptionsNode.addConfig({
373+
type: 'checkbox',
374+
label: 'Extend to radius',
375+
isChecked: this.collectionExtendState[collectionName].enabled,
376+
onChange: (value: boolean) => {
377+
this.collectionExtendState[collectionName].enabled = value;
378+
const radius = this.collectionExtendState[collectionName].radius;
379+
this.sceneManager.extendCollectionTracks(collectionName, radius, value);
380+
},
381+
});
382+
383+
drawOptionsNode.addConfig({
384+
type: 'slider',
385+
label: 'Extend radius',
386+
min: 100,
387+
max: 5000,
388+
step: 10,
389+
allowCustomValue: true,
390+
onChange: (value: number) => {
391+
this.collectionExtendState[collectionName].radius = value;
392+
if (this.collectionExtendState[collectionName].enabled) {
393+
this.sceneManager.extendCollectionTracks(collectionName, value, true);
394+
}
395+
},
396+
});
359397
}
360398

361399
/**

0 commit comments

Comments
 (0)