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 }