Warning, /firebird/packages/firebird-core/src/painters/trajectory.painter.ts is written in an unsupported language. File is not indexed.
0001 import {EventPiecePainter, PainterConfigView, PainterMeta} from "./event-piece-painter";
0002 import {EventPiece} from "../model/event-piece";
0003 import {PointTrajectoryPiece} from "../model/point-trajectory.piece";
0004
0005 import {Color, Object3D} from "three";
0006 // Import from three/webgpu, NEVER from three/src/... : a deep src import can
0007 // be served as a second copy of the whole three node system (seen with the
0008 // vite dep optimizer), whose module-level TSL stack state is separate from
0009 // the renderer's — the material build then throws "No stack defined for
0010 // assign operation" and tracks render as bare hairlines.
0011 import {Line2NodeMaterial} from "three/webgpu";
0012 import {LineGeometry} from "three/examples/jsm/lines/LineGeometry.js";
0013 import {Line2} from "three/examples/jsm/lines/webgpu/Line2.js";
0014
0015
0016
0017 /** Example color set. Feel free to refine or expand. */
0018 export enum NeonTrackColors {
0019 Red = 0xFF0007,
0020 Pink = 0xCF00FF,
0021 Violet = 0x5400FF,
0022 Blue = 0x0097FF,
0023 DeepBlue = 0x003BFF,
0024 Teal = 0x00FFD1,
0025 Green = 0x13FF00,
0026 Salad = 0x8CFF00,
0027 Yellow = 0xFFEE00,
0028 Orange = 0xFF3500,
0029 Gray = 0xAAAAAA,
0030 }
0031
0032 /**
0033 * The pid-mode track color, shared by the per-track and batched trajectory
0034 * painters so switching between them keeps the palette.
0035 */
0036 export function pidColorFor(pdg: number, charge: number): number {
0037 switch (pdg) {
0038 case 22: return NeonTrackColors.Yellow; // γ
0039 case -22: return NeonTrackColors.Salad; // optical photon
0040 case 11: return NeonTrackColors.Blue; // e⁻
0041 case -11: return NeonTrackColors.Orange; // e⁺
0042 case 211: return NeonTrackColors.Pink; // π⁺
0043 case -211: return NeonTrackColors.Teal; // π⁻
0044 case 2212: return NeonTrackColors.Violet; // proton
0045 case 2112: return NeonTrackColors.Green; // neutron
0046 }
0047 if (charge > 0) return NeonTrackColors.Red;
0048 if (charge < 0) return NeonTrackColors.DeepBlue;
0049 return NeonTrackColors.Gray;
0050 }
0051
0052 /** Whether pid-mode draws this particle dashed (photons and neutrons). */
0053 export function pidIsDashed(pdg: number): boolean {
0054 return pdg === 22 || pdg === 2112;
0055 }
0056
0057 /**
0058 * We'll keep each line's full data in a small structure so we can rebuild partial geometry.
0059 */
0060 interface TrajectoryRenderContext {
0061 collectionIndex: number; // Trajectory id == index in the piece columns
0062 lineObj: Line2; // the Line2 object in the scene
0063 points: number[][]; // the raw array of [x, y, z, t, dx, dy, dz, dt]
0064 lineMaterial: Line2NodeMaterial; // the material used
0065 startTime: number; // The time of the first point
0066 endTime: number; // The end of the last point
0067 params: Record<string, any>; // Track parameters (column name -> value at this index)
0068 lastPaintIndex: number; // This is needed for partial track draw optimization
0069 }
0070
0071 /**
0072 * Painter that draws lines for a "PointTrajectory" piece,
0073 * supporting partial display based on time.
0074 */
0075 export class TrajectoryPainter extends EventPiecePainter {
0076
0077 /**
0078 * Knobs: coloring mode (particle id, momentum magnitude, or one solid
0079 * color) and the line width in world units (mm) — 30 reads as a clear but
0080 * thin ribbon at detector scale; values in the hundreds cover whole
0081 * subdetectors. Live: changes restyle existing lines without a rebuild.
0082 */
0083 static meta: PainterMeta = {
0084 id: 'trajectory-lines',
0085 forPieceTypes: [PointTrajectoryPiece.type],
0086 label: 'Trajectory lines',
0087 configs: [
0088 { key: 'colorMode', default: 'pid', options: ['pid', 'momentum', 'solid'], label: 'Coloring' },
0089 { key: 'lineWidth', default: 30, min: 1, max: 300, step: 1, label: 'Line width [mm]' },
0090 { key: 'color', default: '#00b7ff', label: 'Solid color' },
0091 ],
0092 };
0093
0094 /** A small array to store each line's data and references. */
0095 public trajectories: TrajectoryRenderContext[] = [];
0096 private timeColumnIndex = 3; // TODO check that line has time column
0097
0098 public readonly trackColorHighlight = 0xff4081; // vivid pink for highlight
0099 public readonly trackWidthFactor = 2; // how many times thicker when highlighted
0100
0101 /** Momentum ceiling [MeV] of this piece, for the momentum color scale. */
0102 private maxMomentum = 0;
0103
0104 constructor(parentNode: Object3D, piece: EventPiece, config?: PainterConfigView) {
0105 super(parentNode, piece, config);
0106
0107 if (piece.type !== PointTrajectoryPiece.type) {
0108 throw new Error("Wrong piece type given to TrajectoryPainter.");
0109 }
0110
0111 // Build lines at construction
0112 this.initLines();
0113 this.applyStyles();
0114 }
0115
0116 /**
0117 * Builds the Line2 objects for each trajectory in the piece.
0118 * Initially, we set them fully visible (or we could set them invisible).
0119 */
0120 private initLines() {
0121
0122 const piece = this.piece as PointTrajectoryPiece;
0123 const columnNames = Object.keys(piece.columns);
0124 let noPointsWarned = 0;
0125
0126 // [load-timing] accumulators: where construction time goes across ALL
0127 // lines of this piece (materials vs geometry vs distances vs the rest)
0128 const buildStart = performance.now();
0129 let materialMs = 0, geometryMs = 0, distancesMs = 0;
0130 let builtLines = 0;
0131 let stepStart = 0;
0132
0133 for (let trajIndex = 0; trajIndex < piece.count; trajIndex++) {
0134
0135 // Collect this trajectory's parameters from the columns (id == index)
0136 const paramsDict: Record<string, any> = {};
0137 for (const columnName of columnNames) {
0138 paramsDict[columnName] = piece.columns[columnName][trajIndex];
0139 }
0140
0141 const points = piece.points[trajIndex];
0142
0143 // Check we have enough points to build at least something!
0144 if (points.length <= 1) {
0145 if (noPointsWarned < 10) {
0146 const result = Object.entries(paramsDict)
0147 .map(([key, value]) => `${key}:${value}`)
0148 .join(", ");
0149 console.warn(`Trajectory has ${points.length} points. This can't be. Track parameters: ${result}`);
0150 noPointsWarned++;
0151 }
0152 continue; // Skip this line!
0153 }
0154
0155 // Create proper material
0156 stepStart = performance.now();
0157 const lineMaterial = this.createLine2NodeMaterial(paramsDict);
0158 materialMs += performance.now() - stepStart;
0159
0160
0161 // We'll start by building a geometry with *all* points, and rely on paint() to do partial logic.
0162 stepStart = performance.now();
0163 const geometry = new LineGeometry();
0164 const fullPositions = this.generateFlatXYZ(points);
0165 geometry.setPositions(fullPositions);
0166 geometryMs += performance.now() - stepStart;
0167
0168
0169 const line2 = new Line2(geometry, lineMaterial as any);
0170 stepStart = performance.now();
0171 line2.computeLineDistances();
0172 distancesMs += performance.now() - stepStart;
0173 builtLines++;
0174
0175 // Add to the scene
0176 this.parentNode.add(line2);
0177
0178 let startTime = 0;
0179 let endTime = 0;
0180 if (points[0].length > this.timeColumnIndex) {
0181 startTime = points[0][this.timeColumnIndex];
0182 endTime = points[points.length - 1][this.timeColumnIndex]
0183 }
0184
0185 const trajData: TrajectoryRenderContext = {
0186 collectionIndex: trajIndex,
0187 lineObj: line2,
0188 lineMaterial: lineMaterial,
0189 points: points,
0190 startTime: startTime,
0191 endTime: endTime,
0192 params: paramsDict,
0193 lastPaintIndex: 0,
0194 }
0195
0196 trajData.lineObj.name = this.getNodeName(trajData, piece.count);
0197 trajData.lineObj.userData["track_params"] = trajData.params;
0198
0199 // Selection mapping: trajectory id ≡ index; the Line2 is the entity object
0200 this.registerEntityObject(trajIndex, trajData.lineObj);
0201
0202
0203 // Highlight captures the CURRENT material state at highlight time (not
0204 // at build time): the color/width knobs restyle materials live, and a
0205 // build-time snapshot would restore stale values.
0206 trajData.lineObj.userData["highlightFunction"] = () => {
0207 const mat = trajData.lineObj.material as unknown as Line2NodeMaterial;
0208 if (trajData.lineObj.userData["origColor"] === undefined) {
0209 trajData.lineObj.userData["origColor"] = mat.color.getHex();
0210 trajData.lineObj.userData["origWidth"] = mat.linewidth;
0211 }
0212 mat.color.setHex(this.trackColorHighlight);
0213 mat.linewidth = (trajData.lineObj.userData["origWidth"] as number) * this.trackWidthFactor;
0214 mat.needsUpdate = true;
0215 };
0216
0217 trajData.lineObj.userData["unhighlightFunction"] = () => {
0218 // Restore original properties
0219 if (trajData.lineObj.userData["origColor"] !== undefined) {
0220 const mat = trajData.lineObj.material as unknown as Line2NodeMaterial;
0221 mat.color.setHex(trajData.lineObj.userData["origColor"]);
0222 mat.linewidth = trajData.lineObj.userData["origWidth"];
0223 mat.needsUpdate = true;
0224 delete trajData.lineObj.userData["origColor"];
0225 delete trajData.lineObj.userData["origWidth"];
0226 }
0227 };
0228
0229 // Keep the data
0230 this.trajectories.push(trajData);
0231 }
0232
0233 const totalMs = performance.now() - buildStart;
0234 if (totalMs > 10) {
0235 const otherMs = totalMs - materialMs - geometryMs - distancesMs;
0236 console.log(`[load-timing] TrajectoryPainter '${this.pieceName}' build ${totalMs.toFixed(1)} ms ` +
0237 `(${builtLines} lines): materials ${materialMs.toFixed(1)}, geometry ${geometryMs.toFixed(1)}, ` +
0238 `lineDistances ${distancesMs.toFixed(1)}, other ${otherMs.toFixed(1)}`);
0239 }
0240 }
0241
0242 /**
0243 * Creates a new solid Line2NodeMaterial.
0244 * NOTE: We must create fresh materials instead of using .clone() because
0245 * Line2NodeMaterial.clone() in three.js v0.183 does NOT copy _useWorldUnits,
0246 * _useDash, or linewidth, causing all cloned materials to have 1px lines.
0247 */
0248 private newSolidMaterial(color: NeonTrackColors, linewidth?: number): Line2NodeMaterial {
0249 return new Line2NodeMaterial({
0250 color: color,
0251 linewidth: linewidth ?? this.config.value<number>('lineWidth'),
0252 worldUnits: true,
0253 dashed: false,
0254 alphaToCoverage: true,
0255 });
0256 }
0257
0258 private newDashedMaterial(color: NeonTrackColors): Line2NodeMaterial {
0259 return new Line2NodeMaterial({
0260 color: color,
0261 linewidth: this.config.value<number>('lineWidth'),
0262 worldUnits: true,
0263 dashed: true,
0264 dashSize: 100,
0265 gapSize: 100,
0266 alphaToCoverage: true,
0267 });
0268 }
0269
0270 /**
0271 * Creates or picks a line material based on PDG or charge, etc.
0272 * Reads the "pdg" and "charge" columns when the writer declared them.
0273 */
0274 private createLine2NodeMaterial(params: Record<string, any>) {
0275
0276 let pdg = 0, charge = 0;
0277 if (typeof params["pdg"] === "number") {
0278 pdg = Math.floor(params["pdg"]);
0279 }
0280 if (typeof params["charge"] === "number") {
0281 charge = params["charge"];
0282 }
0283
0284 // Minimal PDG-based color logic
0285 // ---------- PDG‑specific cases ----------
0286 switch (pdg) {
0287 case 22: return this.newDashedMaterial(NeonTrackColors.Yellow); // γ
0288 case -22: return this.newSolidMaterial(NeonTrackColors.Salad, 3); // optical photon
0289 case 11: return this.newSolidMaterial(NeonTrackColors.Blue); // e⁻
0290 case -11: return this.newSolidMaterial(NeonTrackColors.Orange); // e⁺
0291 case 211: return this.newSolidMaterial(NeonTrackColors.Pink); // π⁺
0292 case -211: return this.newSolidMaterial(NeonTrackColors.Teal); // π⁻
0293 case 2212: return this.newSolidMaterial(NeonTrackColors.Violet); // proton
0294 case 2112: return this.newDashedMaterial(NeonTrackColors.Green); // neutron
0295 }
0296
0297 // ---------- Fallback by charge ----------
0298 if (charge > 0) return this.newSolidMaterial(NeonTrackColors.Red);
0299 if (charge < 0) return this.newSolidMaterial(NeonTrackColors.DeepBlue);
0300
0301 // Neutral fallback
0302 return this.newSolidMaterial(NeonTrackColors.Gray);
0303 }
0304
0305 /** |p| [MeV] from the px/py/pz columns, or null when the writer omitted them. */
0306 private momentumOf(params: Record<string, any>): number | null {
0307 const px = params['px'], py = params['py'], pz = params['pz'];
0308 if (typeof px !== 'number' || typeof py !== 'number' || typeof pz !== 'number') return null;
0309 return Math.sqrt(px * px + py * py + pz * pz);
0310 }
0311
0312 /** The pid-mode color, matching the material choice in createLine2NodeMaterial. */
0313 private pidColorOf(params: Record<string, any>): number {
0314 const pdg = typeof params['pdg'] === 'number' ? Math.floor(params['pdg']) : 0;
0315 const charge = typeof params['charge'] === 'number' ? params['charge'] : 0;
0316 return pidColorFor(pdg, charge);
0317 }
0318
0319 /** Restyle existing lines from the config knobs. Called live on knob changes. */
0320 override onConfigChanged(): void {
0321 this.applyStyles();
0322 }
0323
0324 /**
0325 * Applies colorMode/lineWidth/color to all line materials in place —
0326 * no geometry rebuild, so knob changes are instant even for large events.
0327 */
0328 private applyStyles(): void {
0329 const colorMode = this.config.value<string>('colorMode');
0330 const lineWidth = this.config.value<number>('lineWidth');
0331 const solidColor = this.config.value<string>('color');
0332
0333 if (colorMode === 'momentum' && this.maxMomentum === 0) {
0334 for (const track of this.trajectories) {
0335 this.maxMomentum = Math.max(this.maxMomentum, this.momentumOf(track.params) ?? 0);
0336 }
0337 }
0338
0339 const scratch = new Color();
0340 for (const track of this.trajectories) {
0341 const material = track.lineObj.material as unknown as Line2NodeMaterial;
0342 const params = track.params;
0343
0344 // Optical photons keep their deliberately thin lines
0345 const isOpticalPhoton = typeof params['pdg'] === 'number' && Math.floor(params['pdg']) === -22;
0346 material.linewidth = isOpticalPhoton ? Math.min(3, lineWidth) : lineWidth;
0347
0348 if (colorMode === 'solid') {
0349 material.color.set(solidColor);
0350 } else if (colorMode === 'momentum') {
0351 const momentum = this.momentumOf(params);
0352 if (momentum === null || this.maxMomentum <= 0) {
0353 material.color.setHex(NeonTrackColors.Gray);
0354 } else {
0355 // Blue (low) -> red (high), linear in |p|
0356 const fraction = Math.min(1, momentum / this.maxMomentum);
0357 material.color.copy(scratch.setHSL(0.66 * (1 - fraction), 1, 0.5));
0358 }
0359 } else {
0360 material.color.setHex(this.pidColorOf(params));
0361 }
0362 material.needsUpdate = true;
0363
0364 // A restyle invalidates any pending highlight-restore snapshot
0365 delete track.lineObj.userData['origColor'];
0366 delete track.lineObj.userData['origWidth'];
0367 }
0368 }
0369
0370 /**
0371 * Helper to flatten the [x, y, z, t, ...] points into [x0, y0, z0, x1, y1, z1, ...].
0372 * We skip anything beyond the first 3 indices in each point array, because
0373 * x=0,y=1,z=2 are the first three.
0374 */
0375 private generateFlatXYZ(points: number[][]): number[] {
0376 const flat: number[] = [];
0377 for (let i = 0; i < points.length; i++) {
0378 flat.push(points[i][0], points[i][1], points[i][2]); // x,y,z
0379 }
0380 return flat;
0381 }
0382
0383 /**
0384 * The main Paint method, called each time the user updates "time."
0385 * If time is null - timeless mode, we show the entire tracks. Otherwise, we show partial up to that time.
0386 */
0387 public override paint(time: number | null): void {
0388
0389 if (time === null) {
0390 this.paintNoTime();
0391 } else {
0392 this.paintAtTime(time);
0393 }
0394 }
0395
0396 private paintNoTime() {
0397 for (const track of this.trajectories) {
0398 // Show all points
0399 track.lineObj.visible = true;
0400 track.lineObj.geometry.instanceCount = track.points.length - 1;
0401 }
0402 }
0403
0404
0405 /**
0406 * Improved fastPaint function with proper boundary checking between time points
0407 * @param time Current simulation time
0408 */
0409 public paintAtTime(time: number): void {
0410 // First pass: categorize tracks as fully visible, partial, or hidden
0411 const partialTracks: TrajectoryRenderContext[] = [];
0412
0413 for (const track of this.trajectories) {
0414 // Hide tracks that haven't started yet
0415 if (track.startTime > time) {
0416 track.lineObj.visible = false;
0417 track.lastPaintIndex = -1; // if time moves forward, and we start showing track the next time
0418 continue;
0419 }
0420
0421 // Show track
0422 track.lineObj.visible = true;
0423
0424 // If track has already ended, show it completely
0425 if (track.endTime <= time) {
0426 track.lineObj.geometry.instanceCount = track.points.length - 1;
0427
0428 // if next paint the time moves backward, and we start hiding track parts,
0429 // we want lastPaintIndex to correspond to fully rendered track
0430 track.lastPaintIndex = this.trajectories.length - 1;
0431 continue;
0432 }
0433
0434 // This track is only partially visible and will be treated the next
0435 partialTracks.push(track);
0436 }
0437
0438 // Second pass: handle partially visible tracks
0439 for (const track of partialTracks) {
0440 // Validate lastPaintIndex
0441 if (track.lastPaintIndex < 0 || track.lastPaintIndex >= track.points.length) {
0442 track.lastPaintIndex = 0;
0443 }
0444
0445 // Find the correct interval where the current time falls
0446 // This is the key improvement: check if we need to move to next/previous point
0447 // rather than just searching forward or backward arbitrarily
0448
0449 let needToUpdate = true;
0450 while (needToUpdate) {
0451 needToUpdate = false;
0452
0453 // Check if we should move forward to next point
0454 if (track.lastPaintIndex < track.points.length - 1 &&
0455 time >= track.points[track.lastPaintIndex + 1][this.timeColumnIndex]) {
0456 track.lastPaintIndex++;
0457 needToUpdate = true;
0458 }
0459 // Check if we should move backward to previous point
0460 else if (track.lastPaintIndex > 0 &&
0461 time < track.points[track.lastPaintIndex][this.timeColumnIndex]) {
0462 track.lastPaintIndex--;
0463 needToUpdate = true;
0464 }
0465 }
0466
0467 // At this point, we've found the correct index where:
0468 // time is between points[lastPaintIndex] and points[lastPaintIndex+1]
0469 // Show points up to and including lastPaintIndex
0470 track.lineObj.geometry.instanceCount = track.lastPaintIndex + 1;
0471 }
0472 }
0473
0474 /**
0475 * Dispose all line objects, geometry, materials
0476 */
0477 public override dispose(): void {
0478 for (const ld of this.trajectories) {
0479 const geom = ld.lineObj.geometry as LineGeometry;
0480 geom.dispose();
0481 ld.lineMaterial.dispose();
0482
0483 if (this.parentNode) {
0484 this.parentNode.remove(ld.lineObj);
0485 }
0486 }
0487 this.trajectories = [];
0488 super.dispose();
0489 }
0490
0491 private getNodeName(trajData: TrajectoryRenderContext, trajCount: number) {
0492
0493 // Calculate the number of digits needed (order of magnitude + 1)
0494 const padLength = Math.floor(Math.log10(trajCount)) + 1;
0495
0496 // Use padStart to pad the string representation with leading zeros
0497 const indexStr = String(trajData.collectionIndex).padStart(padLength, ' ');
0498
0499
0500 let name = "track"
0501 if ("type" in trajData.params) {
0502 name = trajData.params["type"]
0503 } else if ("pdg" in trajData.params) {
0504 name = trajData.params["pdg"]
0505 } else if ("charge" in trajData.params) {
0506 const charge = parseFloat(trajData.params["charge"]);
0507 if (Math.abs(charge) < 0.00001) {
0508 name = "NeuTrk";
0509 }
0510 if (charge > 0) {
0511 name = "PosTrk";
0512 } else if (charge < 0) {
0513 name = "NegTrk";
0514 }
0515 }
0516 name = "[" + name + "]"
0517
0518 let time = "no-t"
0519 if (Math.abs(trajData.startTime) > 0.000001 || Math.abs(trajData.endTime) > 0.000001) {
0520 time = `t:${trajData.startTime.toFixed(1)}-${trajData.endTime.toFixed(1)}`;
0521 }
0522
0523 let momentum = "no-p"
0524 if ("px" in trajData.params && "py" in trajData.params && "pz" in trajData.params) {
0525 let px = parseFloat(trajData.params["px"]);
0526 let py = parseFloat(trajData.params["py"]);
0527 let pz = parseFloat(trajData.params["pz"]);
0528 momentum = "p:" + (Math.sqrt(px * px + py * py + pz * pz) / 1000.0).toFixed(3);
0529 }
0530
0531 return `${indexStr} ${trajData.collectionIndex} ${name} ${momentum} ${time}`;
0532 }
0533 }