Back to home page

EIC code displayed by LXR

 
 

    


Warning, /firebird/packages/firebird-ng/display/src/lib/services/event-display.service.ts is written in an unsupported language. File is not indexed.

0001 import {computed, effect, inject, Injectable, signal, untracked, WritableSignal} from '@angular/core';
0002 import {Subscription} from 'rxjs';
0003 import {Group as TweenGroup, Tween} from '@tweenjs/tween.js';
0004 import {ThreeService} from './three.service';
0005 import {GeometryService} from './geometry.service';
0006 import {DataModelService} from './data-model.service';
0007 import {CommandBusService, ConfigProperty, ConfigService, resolveRegistry} from '@dexvis/app-features';
0008 import {
0009   BatchStatusService,
0010   COLLISION_INTRO,
0011   CollisionIntro,
0012   DataSelectionService,
0013   DEX_EVENTS_SOURCE_CONFIG,
0014   EVENT_DATA_LAYER,
0015   GEOMETRY_URL_CONFIG,
0016   injectEventLoaders,
0017   injectGeometryLoaders,
0018   isRootSource,
0019   PAINTERS,
0020   ROOT_COLLECTIONS_CONFIG,
0021   ROOT_EVENT_RANGE_CONFIG,
0022   ROOT_EVENTS_SOURCE_CONFIG,
0023   UrlService,
0024 } from '@dexvis/firebird-ng/api';
0025 import {disposeHierarchy} from '@dexvis/threejs-tree-editor';
0026 import {
0027   DataExchange,
0028   DataModelPainter,
0029   Event,
0030   PiecePainterConstructor,
0031   painterIdOf,
0032   sourceName,
0033   type DataSource,
0034   type EventDataLoader,
0035   type GeometryDataLoader,
0036   type LoaderContext,
0037 } from '@dexvis/firebird-core';
0038 import {Group, Object3D, Vector2, Vector3} from "three";
0039 import {GeometryPostProcessingService} from "./geometry-post-processing.service";
0040 import {PainterConfigService} from "./painter-config.service";
0041 import type {RenderViewImpl} from "./render-view";
0042 import {MessageService} from "./message.service";
0043 
0044 
0045 /**
0046  * Identifies an events source the way the display compares sources: the URL,
0047  * plus for a ROOT file the entries and collection groups that were converted
0048  * (the same file with another range is another source).
0049  */
0050 interface EventsSourceId {
0051   url: string;
0052   /** For a ROOT source: entry numbers as typed ('0', '0-4', '1,3'). */
0053   entries?: string;
0054   /** For a ROOT source: collection groups, a comma list or an array; empty means all. */
0055   collections?: string | string[];
0056 }
0057 
0058 /** One requested events load (see `EventDisplayService.runEventsLoad()`). */
0059 interface EventsLoadRequest {
0060   /** False once a newer events load was requested; its data is then not shown. */
0061   readonly isCurrent: boolean;
0062 }
0063 
0064 /** Options of `EventDisplayService.attach()`. */
0065 export interface DisplayAttachOptions {
0066   /**
0067    * Loads the configured geometry and events once attached. Default true.
0068    * The queued startup commands run either way.
0069    */
0070   autoLoad?: boolean;
0071   /**
0072    * Runs once the renderer is attached, before the configured sources load:
0073    * host setup that needs the scene, such as extra views. A function it
0074    * returns runs when the disposer is called.
0075    */
0076   onAttached?: () => void | (() => void);
0077 }
0078 
0079 /** What a load request loads, and the configured source when it was requested. */
0080 interface LoadTarget {
0081   /** The source's key; null for a picked file or an unnamed document. */
0082   key: string | null;
0083   /** The configured source's key ('' for none) at request time. */
0084   configKey: string;
0085 }
0086 
0087 /** The comparison key of an events source; see EventsSourceId. */
0088 function eventsSourceKey(source: EventsSourceId): string {
0089   const url = source.url.trim();
0090   if (source.entries === undefined && source.collections === undefined) return url;
0091   const groups = Array.isArray(source.collections) ? source.collections : (source.collections ?? '').split(',');
0092   const collections = groups.map(group => group.trim()).filter(Boolean).join(',');
0093   return `${url}#entries=${(source.entries ?? '').trim() || '0'}&collections=${collections}`;
0094 }
0095 
0096 function errorText(error: unknown): string {
0097   return error instanceof Error ? error.message : String(error);
0098 }
0099 
0100 
0101 @Injectable({
0102   providedIn: 'root',
0103 })
0104 export class EventDisplayService {
0105 
0106   private eventsByName = new Map<string, any>();
0107   private eventsArray: any[] = [];
0108 
0109   selectedEventKey: string | undefined;
0110 
0111   // Time
0112   //private eventDisplayMode: WritableSignal<DisplayMode> = signal(DisplayMode.Timeless);
0113   public eventTime: WritableSignal<number | null> = signal(0);
0114 
0115   // Animation cycling
0116   public animationIsCycling: WritableSignal<boolean> = signal(false);
0117 
0118   // Whether camera moves (zoom/pan) during time animation
0119   public animateCameraMovement: boolean = false;
0120 
0121 
0122   // Time range and step of the time slider. Signals: the time control's
0123   // dialog changes them, and templates outside the dialog read them.
0124   /** End of the event time range [ns]. */
0125   readonly maxTime = signal(200);
0126   /** Start of the event time range [ns]. */
0127   readonly minTime = signal(0);
0128   private readonly animationSpeedSignal = signal(1.0);
0129   /** Time step per animation tick and per step button (at least 0.1). */
0130   readonly animationSpeed = this.animationSpeedSignal.asReadonly();
0131 
0132 
0133   // Time animation. Every tween leaves the group when it completes or
0134   // stops: tween.js keeps finished tweens in a group until removed.
0135   private tweenGroup = new TweenGroup();
0136   private tween: Tween<any> | null = null;
0137 
0138   /** The collision intro playing now (it ends early on a new playback). */
0139   private introTween: Tween<{elapsed: number}> | null = null;
0140   /** The registered collision intro (withCollisionIntro); null when none. */
0141   private readonly collisionIntroLoader = inject(COLLISION_INTRO, {optional: true});
0142   private collisionIntroClass: Promise<(new () => CollisionIntro) | null> | null = null;
0143   /** True when a collision intro is registered: offline recordings offer it. */
0144   readonly hasCollisionIntro = this.collisionIntroLoader !== null;
0145 
0146   // Painter that draws the event
0147   private painter: DataModelPainter = new DataModelPainter();
0148 
0149   /** Experiment-specific geometry adjustments (withGeometryPostProcessor). */
0150   private geometryPostProcessing = inject(GeometryPostProcessingService);
0151 
0152   /** Batch/headless readiness flags (window.firebird) — loads report through it. */
0153   private batchStatus = inject(BatchStatusService);
0154 
0155   /** Startup command queue (?dex=, ?geometry=, ?cmd=, server startupCommands). */
0156   private commandBus = inject(CommandBusService);
0157 
0158   /** The user-visible error channel; see reportError(). */
0159   private messages = inject(MessageService);
0160 
0161   /** Which kind of events load runs for the footer spinners; null when none. */
0162   private readonly eventsLoading = signal<'dex' | 'root' | null>(null);
0163 
0164   /** Loading indicators for the display pages' footers (service-owned so
0165    * every page shows the same state). Each shows the LATEST requested load:
0166    * a superseded load that ends later does not touch them. */
0167   readonly loadingDex = computed(() => this.eventsLoading() === 'dex');
0168   readonly loadingEdm = computed(() => this.eventsLoading() === 'root');
0169   readonly loadingGeometry = signal(false);
0170 
0171   /** Bumped by every requested events / geometry load; the latest one wins. */
0172   private eventsGeneration = 0;
0173   private geometryGeneration = 0;
0174   /** Releases the readiness count of the latest events request (once). */
0175   private releaseEventsReadiness: (() => void) | null = null;
0176 
0177   /**
0178    * What the latest events / geometry request loads and the configured source
0179    * at that moment. A display remount compares the config against both: what
0180    * a deep link, a startup command or a picked file put on screen stays until
0181    * the configured source changes. Cleared when that request fails, so a
0182    * remount retries the configured source.
0183    */
0184   private eventsTarget: LoadTarget | null = null;
0185   private geometryTarget: LoadTarget | null = null;
0186 
0187   /** Painter selection + knob keys (painters.byPiece.*) over ConfigService. */
0188   private painterConfig = inject(PainterConfigService);
0189   private dataSelection = inject(DataSelectionService);
0190   /** Registered loaders (withGeometryLoader, withEventLoader), asked in registration order. */
0191   private geometryLoaders = injectGeometryLoaders();
0192   private eventLoaders = injectEventLoaders();
0193   /** Aborts the latest geometry / events load when a newer one starts. */
0194   private geometryAbort: AbortController | null = null;
0195   private eventsAbort: AbortController | null = null;
0196 
0197   /** Painter config keys subscribed for live updates, with their
0198    * subscriptions so watchers of vanished pieces can be pruned. */
0199   private watchedPainterKeys = new Map<string, Subscription>();
0200 
0201   /** Resolves when all lazily-registered painters (withLazyPainter) are in. */
0202   private paintersReady: Promise<void>;
0203 
0204   constructor(
0205     public three: ThreeService,
0206     private geomService: GeometryService,
0207     private config: ConfigService,
0208     private dataService: DataModelService,
0209     private urlService: UrlService
0210   ) {
0211 
0212     // Painters come from DI (PAINTERS token, `withPainter()` features).
0213     // Group factories are registered by the provideFirebird app initializer.
0214     // Every load awaits paintersReady.
0215     this.paintersReady = this.registerPainters(inject(PAINTERS, {optional: true}) ?? []);
0216 
0217     // Painter selection and knobs are config keys (painters.byPiece.*):
0218     // - the selector resolves which registered painter draws each piece,
0219     //   watching the key so a selection change rebuilds the painters;
0220     // - the config view hands the knob values to the painter instance,
0221     //   watching each knob so changes restyle live (onConfigChanged).
0222     this.painter.painterSelector = (piece, candidates) => {
0223       if (candidates.length === 0) return undefined;
0224       const property = this.painterConfig.selectionProperty(piece.name, candidates);
0225       this.watchPainterKey(property, () => this.rebuildPainters());
0226       return this.painterConfig.resolveSelection(piece.name, candidates);
0227     };
0228     this.painter.configViewProvider = (piece, painterClass) => {
0229       for (const property of this.painterConfig.knobProperties(piece, painterClass).values()) {
0230         this.watchPainterKey(property, () => {
0231           this.painter.painterFor(piece.name)?.onConfigChanged();
0232           this.three.invalidate();
0233         });
0234       }
0235       return this.painterConfig.buildConfigView(piece, painterClass);
0236     };
0237 
0238     // Connect painter to its scene place
0239     this.painter.setThreeSceneParent(this.three.sceneEvent);
0240 
0241     // On time change
0242     effect(() => {
0243       const time = this.eventTime();
0244       this.painter.paint(time);
0245       this.stampEventLayers();
0246       this.three.invalidate();
0247     }, {debugName: "EventDisplayService.OnTimeChange"});
0248 
0249     // On current entry change (event selector, show-event command, cycling).
0250     // Load paths present their first entry right away; for those the
0251     // identity check in presentEntry() makes this a no-op.
0252     effect(() => {
0253       const event = this.dataService.currentEntry();
0254       if (event === null) return;
0255       untracked(() => this.presentEntry(event));
0256     }, {debugName: "EventDisplayService.OnEventChange"});
0257   }
0258 
0259   /**
0260    * Registers the contributed painters in REGISTRATION order once every
0261    * lazy chunk resolved: the first painter registered for a piece type is
0262    * its default, and the order the chunks happen to arrive in must not
0263    * change that. A later painter with the `meta.id` of an earlier one for
0264    * the same type replaces it in place. A painter module that fails to load,
0265    * or a painter that declares reserved knob names, is reported once and
0266    * left out; its pieces fall back to another registered painter or stay
0267    * undrawn.
0268    */
0269   private async registerPainters(registrations: ReadonlyArray<{
0270     forPieceType: string;
0271     painterClass?: PiecePainterConstructor;
0272     load?: () => Promise<PiecePainterConstructor>;
0273   }>): Promise<void> {
0274     const classes = await Promise.all(registrations.map(async registration => {
0275       try {
0276         return registration.painterClass ?? await registration.load?.() ?? null;
0277       } catch (error) {
0278         this.reportError(`The painter for '${registration.forPieceType}' pieces failed to load: ${errorText(error)}`);
0279         return null;
0280       }
0281     }));
0282     const classesByType = new Map<string, PiecePainterConstructor[]>();
0283     registrations.forEach((registration, index) => {
0284       const painterClass = classes[index];
0285       if (!painterClass) return;
0286       const list = classesByType.get(registration.forPieceType) ?? [];
0287       list.push(painterClass);
0288       classesByType.set(registration.forPieceType, list);
0289     });
0290     for (const [pieceType, painterClasses] of classesByType) {
0291       for (const painterClass of resolveRegistry(painterClasses, painterIdOf)) {
0292         try {
0293           this.painter.registerPainter(pieceType, painterClass);
0294         } catch (error) {
0295           this.reportError(`The painter for '${pieceType}' pieces was not registered: ${errorText(error)}`);
0296         }
0297       }
0298     }
0299   }
0300 
0301   /**
0302    * Makes `entry` the painted entry: builds its piece painters, paints it,
0303    * routes its objects to the event layer, applies per-piece visibility,
0304    * drops config watchers of vanished pieces, and calls the ThreeExtensions'
0305    * `onEventLoaded`. This is the one "entry became current" path: load paths
0306    * call it at once (through showEntry), the entry-change effect calls it for
0307    * event switches. An entry that is already painted is skipped, so
0308    * extensions hear about each load and each switch exactly once.
0309    *
0310    * @returns False when the entry already was the painted one.
0311    */
0312   private presentEntry(entry: Event): boolean {
0313     if (this.painter.getEntry() === entry) return false;
0314     this.painter.setEntry(entry);
0315     this.painter.paint(null);
0316     this.stampEventLayers();
0317     this.applyPieceVisibility();
0318     this.pruneStalePainterWatchers();
0319     // Let ThreeExtensions react to the freshly painted event
0320     // (notifyEventLoaded also invalidates the render loop)
0321     this.three.notifyEventLoaded(entry);
0322     return true;
0323   }
0324 
0325   // ****************************************************
0326   // *************** THREE SETUP ************************
0327   // ****************************************************
0328 
0329   /**
0330    * Puts the display into `host`: the shared canvas and the main view move
0331    * there, the canvas follows the host's size, the configured geometry and
0332    * events load, and the queued startup commands run (deep links, server
0333    * startup commands, batch). The services and the scene outlive the host:
0334    * attaching again later (a page revisit) moves the same scene back, and the
0335    * configured sources reload only when they changed (see loadFromConfig).
0336    *
0337    * The renderer starts asynchronously. A host disposed before it started is
0338    * never set up, and only the latest attached host keeps the canvas.
0339    *
0340    * @returns The disposer: call it when the host goes away. It runs the
0341    *   cleanup `onAttached` returned and stops the render loop until the
0342    *   next host attaches.
0343    */
0344   attach(host: HTMLElement, options: DisplayAttachOptions = {}): () => void {
0345     let disposed = false;
0346     let hostCleanup: (() => void) | void = undefined;
0347     let resizeObserver: ResizeObserver | undefined;
0348     let resizeTimer: ReturnType<typeof setTimeout> | undefined;
0349     // The host's size comes from the page layout (panes, window), never from
0350     // the canvas inside it, so observing it cannot feed back
0351     const fitCanvas = () => this.three.setSize(host.clientWidth, host.clientHeight);
0352 
0353     this.initThree(host)
0354       .then(attached => {
0355         if (!attached || disposed) return;
0356         hostCleanup = options.onAttached?.();
0357         // Debounced: dragging a pane resizes the host every animation frame
0358         resizeObserver = new ResizeObserver(() => {
0359           clearTimeout(resizeTimer);
0360           resizeTimer = setTimeout(fitCanvas, 50);
0361         });
0362         resizeObserver.observe(host);
0363         fitCanvas();
0364         if (options.autoLoad ?? true) {
0365           this.autoLoadAndRunStartup();
0366         } else {
0367           void this.runStartupCommands();
0368         }
0369       })
0370       .catch(error => this.reportError(`The display failed to start: ${errorText(error)}`));
0371 
0372     return () => {
0373       if (disposed) return;
0374       disposed = true;
0375       hostCleanup?.();
0376       resizeObserver?.disconnect();
0377       clearTimeout(resizeTimer);
0378       // The data selector's Show reloads only while a display is attached
0379       this.dataSelection.detachDisplay();
0380       this.three.detach();
0381     };
0382   }
0383 
0384   /**
0385    * Moves the scene into `container` and wires the display services.
0386    * Resolves to false when another attach or a detach superseded this one
0387    * while the renderer initialized: the caller must then skip its own setup.
0388    */
0389   private async initThree(container: HTMLElement): Promise<boolean> {
0390     // init() starts the render loop. It reports false when the host went
0391     // away (or another host attached) while it waited.
0392     if (!await this.three.init(container)) return false;
0393     // The data selector's Show button reloads through the same config path
0394     // the startup uses (see loadFromConfig). Attached only while a display
0395     // is mounted: the config page applies without a display, and the next
0396     // display host then loads on its own attach.
0397     this.dataSelection.attachDisplay(parts => this.loadFromConfig(parts));
0398     this.painter.setThreeSceneParent(this.three.sceneEvent);
0399 
0400     // Advances the tween group each frame. A stable bound member, not an
0401     // inline closure: initThree runs once per attach, and addFrameCallback
0402     // deduplicates by function identity: an inline closure would stack one
0403     // registration per page visit.
0404     this.three.addFrameCallback(this.tweenFrameCallback);
0405 
0406     this.wireDisplayTracksOnTop();
0407     return true;
0408   }
0409 
0410   /** Guards the one-time config subscription (initThree runs per page mount). */
0411   private displayTracksOnTopWired = false;
0412 
0413   /**
0414    * Applies the display page's tracks-over-geometry mode to the main view.
0415    * Runs on every page init (the quad page overrides the main view's flag
0416    * from its own quadView.main key right after its views are created) and
0417    * subscribes once so the tool-panel toggle takes effect live.
0418    */
0419   private wireDisplayTracksOnTop(): void {
0420     const property = this.config.getConfigOrCreate<boolean>('display.tracksOnTop', false);
0421     const apply = (onTop: boolean) => {
0422       const main = this.three.views[0];
0423       if (main) {
0424         main.tracksOnTop = onTop;
0425         main.dirty = true;
0426       }
0427     };
0428     apply(property.value);
0429     if (!this.displayTracksOnTopWired) {
0430       this.displayTracksOnTopWired = true;
0431       property.subject.subscribe(apply);
0432     }
0433   }
0434 
0435   /** See initThree — identity matters for addFrameCallback deduplication.
0436    * A PLAYING tween re-invalidates the loop each frame, sustaining the
0437    * animation chain under render-on-demand; the chain ends when every tween
0438    * stopped or completed. Each tween's onComplete/onStop removes it from the
0439    * group: tween.js keeps ended tweens in a group (update() defaults to
0440    * preserve=true), and the group would grow with every animation. */
0441   private readonly tweenFrameCallback = () => {
0442     if (!this.tweenGroup.allStopped()) {
0443       this.tweenGroup.update();
0444       this.three.invalidate();
0445     }
0446   };
0447 
0448 
0449   /** The painter drawing the named piece of the current event, or null. */
0450   painterFor(pieceName: string) {
0451     return this.painter.painterFor(pieceName);
0452   }
0453 
0454   /** Painter classes registered for a piece type (panel dropdown options). */
0455   paintersForType(pieceType: string) {
0456     return this.painter.paintersForType(pieceType);
0457   }
0458 
0459   /** Subscribes once per config key; runs `action` on every later change. */
0460   private watchPainterKey(property: ConfigProperty<any>, action: () => void): void {
0461     if (this.watchedPainterKeys.has(property.key)) return;
0462     let isFirst = true; // changes$ replays the current value on subscribe
0463     const subscription = property.changes$.subscribe(() => {
0464       if (isFirst) {
0465         isFirst = false;
0466         return;
0467       }
0468       action();
0469     });
0470     this.watchedPainterKeys.set(property.key, subscription);
0471   }
0472 
0473   /**
0474    * Drops watchers whose piece is absent from the current entry — piece names
0475    * come and go with data files, and a vanished piece must not keep a live
0476    * config subscription. Watchers for still-present pieces are recreated by
0477    * the painter hooks during paint, so pruning after setEntry is safe.
0478    * Key shapes: painters.byPiece.<piece> and painters.byPiece.<piece>.<knob>.
0479    */
0480   private pruneStalePainterWatchers(): void {
0481     const entry = this.painter.getEntry();
0482     if (!entry) return;
0483     const activePieces = new Set(entry.pieces.map(piece => piece.name));
0484     for (const [key, subscription] of this.watchedPainterKeys) {
0485       const pieceName = key.split('.')[2];
0486       if (pieceName && !activePieces.has(pieceName)) {
0487         subscription.unsubscribe();
0488         this.watchedPainterKeys.delete(key);
0489       }
0490     }
0491   }
0492 
0493   /** Rebuilds the piece painters of the current entry (painter selection changed). */
0494   private rebuildPainters(): void {
0495     const entry = this.painter.getEntry();
0496     if (!entry) return;
0497     this.painter.setEntry(entry);
0498     this.painter.paint(this.eventTime());
0499     this.stampEventLayers();
0500     this.applyPieceVisibility();
0501     this.three.invalidate();
0502   }
0503 
0504   /**
0505    * Applies the per-piece visibility config (`painters.byPiece.<name>.visible`)
0506    * to each painter's root node. Runs after every (re)paint of an entry and
0507    * again on every toggle — the model tree eye and deep links flip the same
0508    * config key. A hidden piece keeps its painter and objects; only the group
0509    * node is switched, so showing it again is instant.
0510    */
0511   private applyPieceVisibility(): void {
0512     for (const painter of this.painter.getPainters()) {
0513       const property = this.painterConfig.visibilityProperty(painter.pieceName);
0514       this.watchPainterKey(property, () => this.applyPieceVisibility());
0515       painter.node.visible = property.value !== false;
0516     }
0517     this.three.invalidate();
0518   }
0519 
0520   /**
0521    * Routes every painted event object to EVENT_DATA_LAYER (see
0522    * geometry-slice.ts for the layer scheme). Layers are per-object and
0523    * painters create objects with the default layer, so the stamp runs after
0524    * every paint that may have created objects. Idempotent and cheap — the
0525    * event subtree is small.
0526    */
0527   private stampEventLayers(): void {
0528     this.three.sceneEvent?.traverse(object => object.layers.set(EVENT_DATA_LAYER));
0529   }
0530 
0531   // ****************************************************
0532   // *************** TIME *******************************
0533   // ****************************************************
0534 
0535   public updateEventTime(time: number) {
0536     this.eventTime.set(time);
0537   }
0538 
0539   getMaxTime(): number {
0540     return this.maxTime();
0541   }
0542 
0543   getMinTime(): number {
0544     return this.minTime();
0545   }
0546 
0547   /** Sets the time range the slider and the animations cover. */
0548   setTimeRange(minTime: number, maxTime: number): void {
0549     this.minTime.set(minTime);
0550     this.maxTime.set(maxTime);
0551   }
0552 
0553   /** Sets the time step; values below 0.1 are raised to 0.1 (never a zero step). */
0554   setAnimationSpeed(value: number): void {
0555     this.animationSpeedSignal.set(Math.max(0.1, value));
0556   }
0557 
0558   private get timeStepSize(): number {
0559     return this.animationSpeedSignal();
0560   }
0561 
0562 
0563   animateTime() {
0564     let time = this.eventTime() ?? this.minTime();
0565     const timeToTravel = this.maxTime() - time;
0566 
0567     // Speed: the higher the animationSpeed, the faster (less duration)
0568     const baseMsPerUnit = 200;
0569     const speed = this.animationSpeed();
0570 
0571     const duration = timeToTravel * (baseMsPerUnit / speed);
0572 
0573     this.animateCurrentTime(this.maxTime(), duration);
0574   }
0575 
0576 
0577   /** Stops the time animation, and a collision intro that would start one when it ends. */
0578   stopTimeAnimation(): void {
0579     // onStop takes each tween out of the tween group
0580     this.introTween?.stop();
0581     if (this.tween) {
0582       this.tween.stop();
0583       this.tween = null;
0584     }
0585   }
0586 
0587   rewindTime() {
0588     this.updateEventTime(0);
0589   }
0590 
0591   animateCurrentTime(targetTime: number, duration: number): void {
0592     if (this.tween) {
0593       this.stopTimeAnimation();
0594     }
0595 
0596     const tween: Tween<{currentTime: number}> = new Tween({currentTime: this.eventTime() ?? this.minTime()}, this.tweenGroup)
0597       .to({currentTime: targetTime}, duration)
0598       .onUpdate((obj) => {
0599         this.eventTime.set(obj.currentTime);
0600         if (this.animateCameraMovement) {
0601           this.followTimeWithCamera(obj.currentTime);
0602         }
0603       }).onStop((time)=>{
0604         this.tweenGroup.remove(tween);
0605         console.log(`[eventDisplay]: time animation stopped at: ${time}`);
0606       }).onComplete(()=>{
0607         this.tweenGroup.remove(tween);
0608         if (this.tween === tween) this.tween = null;
0609         if(this.animationIsCycling()) {
0610           this.dataService.setNextEntry();
0611           setTimeout(() => { void this.animateWithCollision();}, 1);
0612         }
0613       })
0614       .start();
0615     this.tween = tween;
0616 
0617     // Seed the render-on-demand chain: the first rendered frame advances the
0618     // tween, whose update re-invalidates until it completes.
0619     this.three.invalidate();
0620   }
0621 
0622   /**
0623    * Camera motion of `animateCameraMovement` during the time animation: the
0624    * camera and its target travel along the beam (+Z) with the event time,
0625    * zooming in during the first 50 ns.
0626    */
0627   private followTimeWithCamera(time: number): void {
0628     const dz = Math.max(time / 10, 25);
0629     if (time < 50) {
0630       this.dollyCamera(-5);
0631     }
0632     this.three.camera.position.setZ(this.three.camera.position.z + dz);
0633     this.three.controls.target.setZ(this.three.controls.target.z + dz);
0634     this.three.camera.updateMatrix();
0635   }
0636 
0637   /** Moves the main camera `distance` mm toward its orbit target (negative: away). */
0638   private dollyCamera(distance: number): void {
0639     const direction = new Vector3().subVectors(this.three.controls.target, this.three.camera.position).normalize();
0640     this.three.camera.position.addScaledVector(direction, distance);
0641   }
0642 
0643   /** The registered collision intro class, loaded once; null when none is registered or it failed to load. */
0644   private loadCollisionIntro(): Promise<(new () => CollisionIntro) | null> {
0645     this.collisionIntroClass ??= this.collisionIntroLoader
0646       ? this.collisionIntroLoader().catch(error => {
0647         this.reportError(`The collision intro failed to load: ${errorText(error)}`);
0648         return null;
0649       })
0650       : Promise.resolve(null);
0651     return this.collisionIntroClass;
0652   }
0653 
0654   /**
0655    * Adds an intro's objects to the event data: a group under sceneEvent on
0656    * the event layer (never clipped, drawn in every view).
0657    *
0658    * @returns The group, to remove when the intro ends.
0659    */
0660   private beginCollisionIntro(intro: CollisionIntro): Group {
0661     const group = new Group();
0662     group.name = 'CollisionIntro';
0663     intro.begin(group);
0664     this.three.addEventObject(group);
0665     return group;
0666   }
0667 
0668   private endCollisionIntro(intro: CollisionIntro, group: Group): void {
0669     intro.end();
0670     this.three.sceneEvent.remove(group);
0671     this.three.invalidate();
0672   }
0673 
0674   /**
0675    * Plays the collision intro (`withCollisionIntro`), then the time
0676    * animation from the start. Without a registered intro the time animation
0677    * starts at once. The `animate-collision` command calls this.
0678    */
0679   async animateWithCollision(): Promise<void> {
0680     // Stop after the await: a playback requested meanwhile has started its
0681     // intro by then, and this one replaces it
0682     const IntroClass = await this.loadCollisionIntro();
0683     this.stopTimeAnimation();
0684     this.rewindTime();
0685     if (!IntroClass) {
0686       this.animateTime();
0687       return;
0688     }
0689 
0690     // The tween group drives the intro: the render loop advances it on every
0691     // rendered frame (tweenFrameCallback), and its updates keep the frames
0692     // coming until it ends.
0693     const intro = new IntroClass();
0694     const group = this.beginCollisionIntro(intro);
0695     const finish = () => {
0696       this.tweenGroup.remove(tween);
0697       if (this.introTween === tween) this.introTween = null;
0698       this.endCollisionIntro(intro, group);
0699     };
0700     const tween: Tween<{elapsed: number}> = new Tween({elapsed: 0}, this.tweenGroup)
0701       .to({elapsed: intro.durationMs}, intro.durationMs)
0702       .onUpdate(({elapsed}) => {
0703         intro.update(elapsed);
0704         if (this.animateCameraMovement) this.dollyCamera(3);
0705       })
0706       .onStop(finish)
0707       .onComplete(() => {
0708         finish();
0709         this.animateTime();
0710       })
0711       .start();
0712     this.introTween = tween;
0713     // Seed the render-on-demand chain (see animateCurrentTime).
0714     this.three.invalidate();
0715   }
0716 
0717   timeStepBack(): void {
0718     const t = this.eventTime() ?? this.minTime();
0719     this.updateEventTime(Math.max(t - this.timeStepSize, this.minTime()));
0720   }
0721 
0722 
0723   timeStep(): void {
0724     const t = this.eventTime();
0725     if (t == null) return;
0726     this.updateEventTime(Math.min(t + this.timeStepSize, this.maxTime()));
0727   }
0728 
0729   exitTimedDisplay() {
0730     this.stopTimeAnimation();
0731     this.eventTime.set(null);
0732     this.three.invalidate();
0733   }
0734 
0735   // Animation cycling methods
0736   startAnimationCycling() {
0737     this.animationIsCycling.set(true);
0738     // TODO: Implement animation cycling logic
0739   }
0740 
0741   stopAnimationCycling() {
0742     this.animationIsCycling.set(false);
0743     // TODO: Stop animation cycling logic
0744   }
0745 
0746   // Entry navigation
0747   setNextEntry() {
0748     this.dataService.setNextEntry();
0749   }
0750 
0751   // ****************************************************
0752   // *************** DATA LOADING ***********************
0753   // ****************************************************
0754   //
0755   // Every load goes through the loader registries (withGeometryLoader,
0756   // withEventLoader): the first registered loader whose canLoad() claims the
0757   // source returns the data, and this service alone puts it on screen. The
0758   // same path serves the config keys, picked files, the data selector's Show,
0759   // the open-geometry / open-dex commands and packs.
0760 
0761   /**
0762    * Config-driven auto-load with startup-command awareness: the one code
0763    * path every display host uses, so landing on any of them covers the same
0764    * sources. Queued startup commands (?dex=..., ?geometry=..., ?cmd=...,
0765    * server startupCommands) replace the config-driven load for the data
0766    * types they carry; the queue itself runs after the loads are kicked off.
0767    *
0768    * On a return to a display page the scene is still there (the services are
0769    * singletons), so the config is reconciled with what is displayed: a
0770    * deep-linked, command-loaded or picked geometry or event source stays
0771    * until the configured source changes, or until the data selector applies a
0772    * choice. Failures reach the user through reportError().
0773    */
0774   private autoLoadAndRunStartup(): void {
0775     const startupCommands = this.commandBus.peekStartupCommands();
0776     const startupHas = (type: string) => startupCommands.some(c => c.type === type);
0777     // Applied on the config page while no display was mounted: the user asked
0778     // for these, so they load even when the config value did not change
0779     const requested = this.dataSelection.takePendingReload();
0780     if (!startupHas('open-dex')) this.loadEventsFromConfig(requested.events);
0781     if (!startupHas('open-geometry')) this.loadGeometryFromConfig(requested.geometry);
0782     void this.runStartupCommands();
0783   }
0784 
0785   /**
0786    * Runs the queued startup commands. Each failure reaches reportError()
0787    * before `window.firebird.startupCommandsDone` turns true.
0788    */
0789   private async runStartupCommands(): Promise<void> {
0790     await this.commandBus.runStartupCommands({
0791       onFailure: ({command, message}) => {
0792         // The command's main argument names what failed (open-dex: the URL)
0793         const argument = [command['url'], command['value'], command['name']]
0794           .find(value => typeof value === 'string' && value !== '');
0795         const label = argument ? `${command.type}:${argument}` : command.type;
0796         this.reportError(`Startup command '${label}' failed: ${message}`);
0797       },
0798     });
0799   }
0800 
0801   /**
0802    * The one user-visible error channel for loads and startup commands: logs
0803    * the message, shows it to the user (MessageService), and lists it in
0804    * `window.firebird.errors`, where batch tools such as `pyrobird screenshot`
0805    * read it.
0806    */
0807   reportError(message: string): void {
0808     console.error(`[eventDisplay]: ${message}`);
0809     this.messages.addMessage('error', message);
0810     this.batchStatus.addError(message);
0811   }
0812 
0813   /**
0814    * Loads geometry and events from the config keys (`geometry.selectedGeometry`,
0815    * `events.dexEventsSource`, or `events.rootEventSource` with
0816    * `events.rootEventRange` and `events.rootCollections`). Files the user
0817    * picked in the data selector are waiting in DataSelectionService and load
0818    * instead of the keys.
0819    *
0820    * This is THE config load path: the data selector's Show button (through
0821    * DataSelectionService.apply) calls it, and attach() shares its parts.
0822    *
0823    * @param parts Which halves to load; both by default.
0824    * @param options.explicit True (the default) loads every configured source
0825    *   that is not what the display already shows or is loading. False is the
0826    *   remount rule of attach().
0827    */
0828   loadFromConfig(parts: { events?: boolean; geometry?: boolean } = {}, options: { explicit?: boolean } = {}): void {
0829     const explicit = options.explicit ?? true;
0830     if (parts.events ?? true) this.loadEventsFromConfig(explicit);
0831     if (parts.geometry ?? true) this.loadGeometryFromConfig(explicit);
0832   }
0833 
0834   /** The configured geometry URL ('' for none). */
0835   private configuredGeometry(): string {
0836     // declare, not getConfig: on a direct page landing nothing declared the
0837     // key yet, and only declaration applies pending URL/server values for it.
0838     return (this.config.declare(GEOMETRY_URL_CONFIG).value || '').trim();
0839   }
0840 
0841   /**
0842    * The configured events source: the DEX key when it is set, otherwise the
0843    * ROOT key. The data selector keeps only one of the two keys set.
0844    */
0845   private configuredEventsUrl(): string {
0846     const dex = (this.config.declare(DEX_EVENTS_SOURCE_CONFIG).value || '').trim();
0847     return dex || (this.config.declare(ROOT_EVENTS_SOURCE_CONFIG).value || '').trim();
0848   }
0849 
0850   /**
0851    * How the display compares an events source with what it shows: the URL,
0852    * plus for a ROOT file the configured entry range and collection groups
0853    * (the same file with another range is another source). Undefined for a
0854    * picked file, which has no name to compare.
0855    */
0856   private eventsSourceIdOf(source: DataSource): EventsSourceId | undefined {
0857     if (typeof source !== 'string') return undefined;
0858     if (!isRootSource(source)) return {url: source};
0859     return {
0860       url: source,
0861       entries: this.config.declare(ROOT_EVENT_RANGE_CONFIG).value || ROOT_EVENT_RANGE_CONFIG.default,
0862       collections: this.config.declare(ROOT_COLLECTIONS_CONFIG).value || '',
0863     };
0864   }
0865 
0866   private configuredEventsKey(): string {
0867     const url = this.configuredEventsUrl();
0868     return url ? eventsSourceKey(this.eventsSourceIdOf(url)!) : '';
0869   }
0870 
0871   /**
0872    * Whether the configured source `configKey` should load, given the latest
0873    * request of that kind. Never when that request already loads exactly this
0874    * source. An explicit load (Show) runs otherwise; a remount runs only when
0875    * the configured source changed since that request.
0876    */
0877   private isConfigLoadDue(target: LoadTarget | null, configKey: string, explicit: boolean): boolean {
0878     if (!target) return true;
0879     if (target.key === configKey) return false;
0880     return explicit || target.configKey !== configKey;
0881   }
0882 
0883   private loadEventsFromConfig(explicit: boolean): void {
0884     const source = this.dataSelection.takePickedEventsFile() ?? this.configuredEventsUrl();
0885     if (!source) {
0886       console.log('[eventDisplay]: No event source configured, skipping.');
0887       return;
0888     }
0889     if (typeof source === 'string' && !this.isConfigLoadDue(this.eventsTarget, this.configuredEventsKey(), explicit)) {
0890       console.log(`[eventDisplay]: Events from '${source}' need no load (shown, loading, or replaced by a deep link, command or picked file).`);
0891       return;
0892     }
0893     this.openEvents(source)
0894       .catch(error => this.reportError(`Could not load events from '${sourceName(source)}': ${errorText(error)}`));
0895   }
0896 
0897   private loadGeometryFromConfig(explicit: boolean): void {
0898     const pickedFile = this.dataSelection.takePickedGeometryFile();
0899     const configured = this.configuredGeometry();
0900     if (!pickedFile) {
0901       if (!configured) {
0902         console.log('[eventDisplay]: No geometry configured, skipping.');
0903         return;
0904       }
0905       if (!this.isConfigLoadDue(this.geometryTarget, configured, explicit)) {
0906         console.log(`[eventDisplay]: Geometry '${configured}' needs no load (shown, loading, or replaced by a deep link, command or picked file).`);
0907         return;
0908       }
0909     }
0910     const source = pickedFile ?? configured;
0911     // A load still running (from another page visit) is superseded by this one
0912     this.openGeometry(source)
0913       .then(root => {
0914         if (!root) console.log('[eventDisplay]: Geometry load superseded by a newer one.');
0915       })
0916       .catch(error => this.reportError(
0917         `Could not load geometry from '${sourceName(source)}': ${errorText(error)}. Open 'Configure' to choose another.`));
0918   }
0919 
0920   /**
0921    * The first registered loader that claims `source` by its name.
0922    *
0923    * @throws Error naming the source and the formats the loaders know.
0924    */
0925   private claimingLoader<T extends GeometryDataLoader | EventDataLoader>(loaders: T[], source: DataSource, kind: string): T {
0926     const loader = loaders.find(candidate => candidate.canLoad(source));
0927     if (!loader) {
0928       const known = loaders.map(known => `${known.meta.label} (${known.meta.fileExtensions.join(', ')})`).join('; ');
0929       throw new Error(`No ${kind} loader claims '${sourceName(source)}'. Known formats: ${known || 'none'}`);
0930     }
0931     return loader;
0932   }
0933 
0934   /** What a loader gets with each load; `signal` aborts when a newer load of the kind starts. */
0935   private loaderContext(signal: AbortSignal): LoaderContext {
0936     return {
0937       resolveUrl: url => this.urlService.resolveDownloadUrl(url),
0938       signal,
0939     };
0940   }
0941 
0942   /**
0943    * Loads detector geometry through the first registered geometry loader
0944    * that claims the source, and puts it on screen in place of the current
0945    * geometry: scaled to millimeters, themed, post-processed, copied into the
0946    * projection views' geometry slice. Starting a newer geometry load aborts
0947    * this one.
0948    *
0949    * @returns The geometry root, or null when a newer geometry load replaced
0950    *   this one (that load owns the screen).
0951    * @throws Error with the reason: no loader claims the source, or the
0952    *   loader's failure.
0953    */
0954   async openGeometry(source: DataSource): Promise<Object3D | null> {
0955     const loader = this.claimingLoader(this.geometryLoaders, source, 'geometry');
0956     const generation = ++this.geometryGeneration;
0957     this.geometryAbort?.abort();
0958     const abort = new AbortController();
0959     this.geometryAbort = abort;
0960     const target: LoadTarget = {key: typeof source === 'string' ? source.trim() : null, configKey: this.configuredGeometry()};
0961     this.geometryTarget = target;
0962     this.loadingGeometry.set(true);
0963     this.batchStatus.beginGeometryLoad();
0964     let shown = false;
0965     try {
0966       const root = await loader.load(source, this.loaderContext(abort.signal));
0967       if (generation !== this.geometryGeneration) return null;
0968       shown = await this.showGeometry(root, loader.millimetersPerUnit ?? 1, generation);
0969       return shown ? root : null;
0970     } catch (error) {
0971       // A replaced load ends with its loader's abort: not a failure
0972       if (generation !== this.geometryGeneration) return null;
0973       if (this.geometryTarget === target) this.geometryTarget = null;
0974       throw error;
0975     } finally {
0976       // Every path ends here, the superseded one included: a load that never
0977       // reports its end would keep window.firebird.ready false forever
0978       this.batchStatus.endGeometryLoad(shown);
0979       if (generation === this.geometryGeneration) {
0980         this.loadingGeometry.set(false);
0981         this.geometryAbort = null;
0982       }
0983     }
0984   }
0985 
0986   /**
0987    * Replaces the geometry on screen with `root`, unless a newer geometry load
0988    * starts meanwhile.
0989    *
0990    * @param millimetersPerUnit The loader's length unit (ROOT: 10).
0991    * @returns False when a newer load superseded this one.
0992    */
0993   private async showGeometry(root: Object3D, millimetersPerUnit: number, generation: number): Promise<boolean> {
0994     const sceneGeometry = this.three.sceneGeometry;
0995 
0996     // One geometry at a time. The hover highlight parks a mesh's own
0997     // material; give it back first so the disposal reaches it.
0998     if (sceneGeometry.children.length > 0) {
0999       this.three.clearHoverHighlight();
1000       disposeHierarchy(sceneGeometry, /* disposeSelf= */ false);
1001     }
1002 
1003     await this.geomService.postProcessing(root, this.three.clipPlanes);
1004     // A newer load started during post-processing: the scene is its now
1005     if (generation !== this.geometryGeneration) return false;
1006 
1007     sceneGeometry.add(root);
1008     // The container carries the unit: post-processors that regroup nodes
1009     // directly under it keep the scale
1010     sceneGeometry.scale.setScalar(millimetersPerUnit);
1011     // Worker-built geometry has matrixAutoUpdate off: update the matrices by hand
1012     sceneGeometry.updateMatrix();
1013     sceneGeometry.updateMatrixWorld(true);
1014     this.geomService.geometry.set(root);
1015 
1016     // Experiment-specific adjustments (withGeometryPostProcessor). They see
1017     // the geometry in place and scaled, before the first frame shows it.
1018     const failures = await this.geometryPostProcessing.process({
1019       geometry: root,
1020       sceneGeometry,
1021       scene: this.three.scene,
1022       renderer: this.three.renderer,
1023       clippingPlanes: this.three.clipPlanes,
1024       fastMaterials: this.geomService.geometryFastAndUgly.value,
1025     });
1026     for (const failure of failures) {
1027       this.reportError(`Geometry post-processor '${failure.id}' failed: ${errorText(failure.error)}`);
1028     }
1029     // A newer load started meanwhile: it replaces this geometry when it ends
1030     if (generation !== this.geometryGeneration) return false;
1031 
1032     // Rebuilds the projection views' geometry copy (when a slice exists),
1033     // queues the picking BVHs and schedules the render that shows the new
1034     // geometry under on-demand rendering.
1035     this.three.geometryChanged();
1036     return true;
1037   }
1038 
1039   /**
1040    * Loads events through the first registered event loader that claims the
1041    * source, and shows the first event. Starting a newer events load (or
1042    * showDexDocument) aborts this one.
1043    *
1044    * @returns The events, or null when a newer events load replaced this one.
1045    * @throws Error with the reason: no loader claims the source, the loader's
1046    *   failure (HTTP status, unsupported DEX version, ...), no events.
1047    */
1048   async openEvents(source: DataSource): Promise<DataExchange | null> {
1049     const loader = this.claimingLoader(this.eventLoaders, source, 'event');
1050     const kind = isRootSource(source) ? 'root' : 'dex';
1051     return this.runEventsLoad(kind, this.eventsSourceIdOf(source), async (request, signal) => {
1052       const start = performance.now();
1053       const data = await loader.loadEvents(source, this.loaderContext(signal));
1054       // modelMs includes fetch, conversion and parsing; data-fetching.utils
1055       // logs the finer stages
1056       return this.showLoaded(data, request, performance.now() - start);
1057     });
1058   }
1059 
1060   /**
1061    * Shows a DEX document that is already in memory, such as one a pack
1062    * produced: parses it and shows its first event, as a new events load.
1063    *
1064    * @param source What the document came from, for messages and so that a
1065    *   later configured load of the same source is skipped.
1066    * @returns The parsed events, or null when a newer events load replaced
1067    *   this one first.
1068    * @throws Error when the object is not DEX 1.0 or holds no events.
1069    */
1070   showDexDocument(dex: unknown, source?: string): Promise<DataExchange | null> {
1071     return this.runEventsLoad('dex', source === undefined ? undefined : {url: source}, async request => {
1072       const start = performance.now();
1073       const data = this.dataService.parseDex(dex, source);
1074       return this.showLoaded(data, request, performance.now() - start);
1075     });
1076   }
1077 
1078   /**
1079    * Runs one events load as a request. From this call on the load counts for
1080    * readiness (`window.firebird`), shows its footer spinner, and supersedes
1081    * every earlier events request: a superseded load is aborted, does not
1082    * replace what is on screen, no longer holds readiness back, and its end
1083    * does not clear the newer load's spinner.
1084    *
1085    * @param kind Which footer spinner the load shows.
1086    * @param source What is loaded, when it has a name; a display remount does
1087    *   not load the same configured source again.
1088    * @param work The load. It receives the request (for showLoaded) and the
1089    *   signal that aborts when a newer request starts.
1090    * @returns What `work` returns, or null when a newer request superseded it.
1091    */
1092   private async runEventsLoad<T>(
1093     kind: 'dex' | 'root',
1094     source: EventsSourceId | undefined,
1095     work: (request: EventsLoadRequest, signal: AbortSignal) => Promise<T>,
1096   ): Promise<T | null> {
1097     const generation = ++this.eventsGeneration;
1098     this.eventsAbort?.abort();
1099     const abort = new AbortController();
1100     this.eventsAbort = abort;
1101     const target: LoadTarget = {
1102       key: source === undefined ? null : eventsSourceKey(source),
1103       configKey: this.configuredEventsKey(),
1104     };
1105     this.eventsTarget = target;
1106     this.eventsLoading.set(kind);
1107 
1108     // Readiness waits for the latest request only
1109     this.releaseEventsReadiness?.();
1110     this.batchStatus.beginEventLoad();
1111     let released = false;
1112     const release = () => {
1113       if (released) return;
1114       released = true;
1115       this.batchStatus.endEventLoad();
1116     };
1117     this.releaseEventsReadiness = release;
1118 
1119     const service = this;
1120     const request: EventsLoadRequest = {
1121       get isCurrent() { return generation === service.eventsGeneration; },
1122     };
1123     try {
1124       const result = await work(request, abort.signal);
1125       return request.isCurrent ? result : null;
1126     } catch (error) {
1127       // A replaced load ends with its loader's abort: not a failure
1128       if (!request.isCurrent) return null;
1129       if (this.eventsTarget === target) this.eventsTarget = null;
1130       throw error;
1131     } finally {
1132       release();
1133       if (this.releaseEventsReadiness === release) this.releaseEventsReadiness = null;
1134       if (generation === this.eventsGeneration) {
1135         this.eventsLoading.set(null);
1136         this.eventsAbort = null;
1137       }
1138     }
1139   }
1140 
1141   /**
1142    * The shared tail of every events load: waits for the lazy painters, then
1143    * adopts the events and shows the first one, unless a newer load was
1144    * requested meanwhile.
1145    *
1146    * @throws Error when the document holds no events.
1147    */
1148   private async showLoaded(data: DataExchange, request: EventsLoadRequest, modelMs: number): Promise<DataExchange> {
1149     if ((data.events?.length ?? 0) === 0) {
1150       throw new Error('The document holds no events');
1151     }
1152     await this.paintersReady;
1153     if (!request.isCurrent) {
1154       console.log('[eventDisplay]: A newer events load was requested; this result is not shown.');
1155       return data;
1156     }
1157     this.dataService.adoptEvents(data);
1158     this.showEntry(data.events[0], modelMs);
1159     return data;
1160   }
1161 
1162   /**
1163    * Puts the first entry of a load on screen through presentEntry(), with
1164    * [load-timing] stage accounting: model adoption (measured by the caller),
1165    * painter construction, paint, layers. The finer grain inside these stages
1166    * is logged by DataModelPainter and the painters.
1167    */
1168   private showEntry(entry: Event, modelMs: number): void {
1169     const start = performance.now();
1170     this.eventTime.set(null);
1171     this.presentEntry(entry);
1172     const end = performance.now();
1173     const totalMs = modelMs + end - start;
1174     if (totalMs > 100) {
1175       console.log(`[load-timing] show entry total ${totalMs.toFixed(1)} ms: ` +
1176         `model ${modelMs.toFixed(1)}, painters + paint + layers ${(end - start).toFixed(1)}`);
1177       // First frames after showing: the initial render compiles pipelines and
1178       // uploads buffers for every new object, a cost no stage above sees.
1179       // Two chained rAFs bracket one full frame of the render loop.
1180       requestAnimationFrame(() => requestAnimationFrame(() => {
1181         console.log(`[load-timing] first rendered frame after show: +${(performance.now() - end).toFixed(1)} ms`);
1182       }));
1183     }
1184   }
1185 
1186   /**
1187    * Offline frame-by-frame render. Steps the tween manually,
1188    * captures each frame as PNG, returns array of blobs.
1189    *
1190    * `views` selects what is recorded: one view renders FULL-FRAME at the
1191    * target resolution (its camera, per-view cut and tracks-on-top applied —
1192    * not cropped from the on-screen grid); several views render a 2-column
1193    * grid composite the way the quad page draws them. Default is the main
1194    * view — the original single-camera behavior.
1195    */
1196   async captureFramesOffline(options: {
1197     overrideResolution?: boolean;
1198     width: number;
1199     height: number;
1200     eventTimeStep: number;       // event-time units per frame, e.g. 0.1
1201     /** Record the collision intro first, when one is registered (`withCollisionIntro`). */
1202     includeCollision?: boolean;
1203     onProgress?: (current: number, total: number) => void;
1204     signal?: AbortSignal;
1205     views?: RenderViewImpl[];
1206   }): Promise<Blob[]> {
1207     const { width, height, eventTimeStep, onProgress } = options;
1208     const renderer = this.three.renderer;
1209     const views = options.views?.length ? options.views : [this.three.mainView];
1210 
1211     // ── Save original state ──
1212     // The LOGICAL size (CSS pixels): setSize takes logical sizes and
1213     // multiplies by the pixel ratio; the canvas width/height are already
1214     // multiplied and would come back DPR-squared.
1215     const origSize = renderer.getSize(new Vector2());
1216     const origPixelRatio = renderer.getPixelRatio();
1217     const origCameraPos = this.three.camera.position.clone();
1218     const origTarget = this.three.controls.target.clone();
1219 
1220     // ── Force render resolution (opt-in) ──
1221     if (options.overrideResolution) {
1222       renderer.setSize(width, height, false);
1223       renderer.setPixelRatio(1);
1224     }
1225 
1226     // Aim each recorded view's cameras at its capture rectangle's aspect
1227     // (full frame for one view, a quadrant for the composite — same aspect
1228     // either way). updateViewport() restores on-screen aspects afterwards.
1229     const captureWidth = options.overrideResolution ? width : renderer.domElement.width / origPixelRatio;
1230     const captureHeight = options.overrideResolution ? height : renderer.domElement.height / origPixelRatio;
1231     for (const view of views) {
1232       view.setCaptureAspect(captureWidth, captureHeight);
1233     }
1234 
1235     const frames: Blob[] = [];
1236 
1237     const renderCaptureFrame = (): void => {
1238       if (views.length === 1) {
1239         renderer.setScissorTest(false);
1240         renderer.setViewport(0, 0, captureWidth, captureHeight);
1241         views[0].renderFullFrame(renderer, this.three.scene);
1242         return;
1243       }
1244       // Grid composite: 2 columns, views in reading order (the quad page
1245       // passes [top, side, front, main] to reproduce its layout).
1246       const columns = 2;
1247       const rows = Math.ceil(views.length / columns);
1248       const cellWidth = captureWidth / columns;
1249       const cellHeight = captureHeight / rows;
1250       renderer.setScissorTest(true);
1251       views.forEach((view, index) => {
1252         const column = index % columns;
1253         const row = Math.floor(index / columns);
1254         // This renderer's viewport origin is top-left (WebGPURenderer
1255         // convention on both backends — same math as RenderView.updateViewport).
1256         view.renderTo(renderer, this.three.scene, {
1257           x: column * cellWidth,
1258           y: row * cellHeight,
1259           width: cellWidth,
1260           height: cellHeight,
1261         });
1262       });
1263       renderer.setScissorTest(false);
1264     };
1265 
1266     const captureFrame = (): Promise<Blob> => {
1267       renderCaptureFrame();
1268       return new Promise((resolve, reject) => {
1269         renderer.domElement.toBlob(
1270           blob => blob ? resolve(blob) : reject(new Error('toBlob failed')),
1271           'image/png'
1272         );
1273       });
1274     };
1275 
1276     // ── Yield to browser so UI updates (progress bar etc.) ──
1277     const yieldFrame = () => new Promise(resolve => setTimeout(resolve, 0));
1278 
1279     try {
1280       // ── Phase 1: the collision intro (optional, when one is registered) ──
1281       // The same intro the live path plays, stepped at 60 fps of intro time
1282       const IntroClass = options.includeCollision ? await this.loadCollisionIntro() : null;
1283       if (IntroClass) {
1284         const intro = new IntroClass();
1285         const msPerFrame = 1000 / 60;
1286         const introFrames = Math.ceil(intro.durationMs / msPerFrame);
1287 
1288         // Reset state
1289         this.rewindTime();
1290         this.three.camera.position.copy(origCameraPos);
1291         this.three.controls.target.copy(origTarget);
1292 
1293         const group = this.beginCollisionIntro(intro);
1294         try {
1295           for (let i = 0; i <= introFrames; i++) {
1296             if (options.signal?.aborted) break;
1297             intro.update(Math.min(i * msPerFrame, intro.durationMs));
1298             if (this.animateCameraMovement) this.dollyCamera(3);
1299             frames.push(await captureFrame());
1300             onProgress?.(frames.length, -1); // indeterminate total during the intro
1301             await yieldFrame();
1302           }
1303         } finally {
1304           this.endCollisionIntro(intro, group);
1305         }
1306       }
1307 
1308       // ── Phase 2: Time animation ──
1309       // Speed scales event-time per frame: speed=2 → 2x event-time per frame → half as many frames
1310       const totalEventTime = this.maxTime() - this.minTime();
1311       const effectiveStep = eventTimeStep * this.animationSpeed();
1312       const totalFrames = Math.ceil(totalEventTime / effectiveStep);
1313 
1314       for (let i = 0; i <= totalFrames; i++) {
1315         if (options.signal?.aborted) break;
1316 
1317         const currentTime = Math.min(this.minTime() + i * effectiveStep, this.maxTime());
1318         this.eventTime.set(currentTime);
1319         // Paint NOW: signal effects flush asynchronously, and the capture
1320         // renders synchronously below — without the direct paint every frame
1321         // would show the PREVIOUS step's state (and the first frame whatever
1322         // was on screen). The later effect flush repaints the same time.
1323         this.painter.paint(currentTime);
1324         this.stampEventLayers();
1325 
1326         // Camera movement (matches the live time animation)
1327         if (this.animateCameraMovement) {
1328           this.followTimeWithCamera(currentTime);
1329         }
1330 
1331         frames.push(await captureFrame());
1332         onProgress?.(frames.length, totalFrames);
1333         await yieldFrame();
1334       }
1335 
1336     } finally {
1337       // ── Restore everything ──
1338       if (options.overrideResolution) {
1339         // Pixel ratio first: setPixelRatio re-applies the CURRENT logical
1340         // size, so the logical size must come last.
1341         renderer.setPixelRatio(origPixelRatio);
1342         renderer.setSize(origSize.x, origSize.y, false);
1343       }
1344       this.three.camera.position.copy(origCameraPos);
1345       this.three.controls.target.copy(origTarget);
1346       this.three.camera.updateMatrix();
1347       // On-screen aspects come back from the view containers.
1348       for (const view of views) {
1349         view.updateViewport();
1350       }
1351       this.three.invalidate();
1352     }
1353 
1354     return frames;
1355   }
1356 }