Back to home page

EIC code displayed by LXR

 
 

    


Warning, /firebird/firebird-ng/src/app/pages/main-display/main-display.component.ts is written in an unsupported language. File is not indexed.

0001 import {
0002   Component,
0003   OnInit,
0004   AfterViewInit,
0005   Input,
0006   ViewChild, OnDestroy, TemplateRef, ElementRef, signal
0007 } from '@angular/core';
0008 
0009 import {ALL_GROUPS, GeometryService} from '../../services/geometry.service';
0010 import {GameControllerService} from '../../services/game-controller.service';
0011 import {ConfigService} from '../../services/config.service';
0012 
0013 import {SceneTreeComponent} from '../../components/scene-tree/scene-tree.component';
0014 import {ShellComponent} from '../../components/shell/shell.component';
0015 import {ToolPanelComponent} from '../../components/tool-panel/tool-panel.component';
0016 import {EventSelectorComponent} from '../../components/event-selector/event-selector.component';
0017 import {GeometryClippingComponent} from '../../components/geometry-clipping/geometry-clipping.component';
0018 import {PhoenixThreeFacade} from "../../utils/phoenix-three-facade";
0019 
0020 import {MatSnackBar} from '@angular/material/snack-bar';
0021 import {MatIcon} from '@angular/material/icon';
0022 import { MatIconButton} from '@angular/material/button';
0023 import {MatTooltip} from '@angular/material/tooltip';
0024 import {EventDisplay} from "phoenix-event-display";
0025 
0026 import {PerfStatsComponent} from "../../components/perf-stats/perf-stats.component";
0027 import {EventDisplayService} from "../../services/event-display.service";
0028 import {EventTimeControlComponent} from "../../components/event-time-control/event-time-control.component";
0029 import {ServerConfigService} from "../../services/server-config.service";
0030 import {CubeViewportControlComponent} from "../../components/cube-viewport-control/cube-viewport-control.component";
0031 import {LegendWindowComponent} from "../../components/legend-window/legend-window.component";
0032 import {PainterConfigPageComponent} from "../../services/configurator/painter-config-page.component";
0033 import {NgIf} from "@angular/common";
0034 import {TrackPainterConfig} from "../../services/track-painter-config";
0035 import {ObjectRaycastComponent} from "../../components/object-raycast/object-raycast.component";
0036 import {MatProgressSpinner} from "@angular/material/progress-spinner";
0037 import {SceneExportComponent} from "../../components/scene-export/scene-export";
0038 import {AnimationSettingsComponent} from "../../components/animation-settings/animation-settings.component";
0039 import GUI from 'lil-gui';
0040 import {ConfigProperty} from "../../utils/config-property";
0041 import JSZip from 'jszip';
0042 
0043 
0044 
0045 /**
0046  * This MainDisplayComponent:
0047  *  - Initializes and uses ThreeService (which sets up scene, camera, controls, etc.).
0048  *  - Loads geometry via GeometryService, attaches it to scene.
0049  *  - Loads event data (Dex or custom) via DataModelService, builds objects in "EventData" group.
0050  *  - Uses EicAnimationsManager for collisions/expansions.
0051  *  - Has leftover UI logic for sliders, time stepping, left/right pane toggling, etc.
0052  *  - Has *no* references to phoenix-event-display or eventDisplay.
0053  */
0054 @Component({
0055   selector: 'app-main-display',
0056   templateUrl: './main-display.component.html',
0057   styleUrls: ['./main-display.component.scss'],
0058   imports: [
0059     MatIcon,
0060     MatTooltip,
0061     MatIconButton,
0062     SceneTreeComponent,
0063     ShellComponent,
0064     ToolPanelComponent,
0065     EventSelectorComponent,
0066     GeometryClippingComponent,
0067     PerfStatsComponent,
0068     EventTimeControlComponent,
0069     CubeViewportControlComponent,
0070     LegendWindowComponent,
0071     PainterConfigPageComponent,
0072     NgIf,
0073     ObjectRaycastComponent,
0074     MatProgressSpinner,
0075     SceneExportComponent,
0076     AnimationSettingsComponent,
0077   ]
0078 })
0079 export class MainDisplayComponent implements OnInit, AfterViewInit, OnDestroy {
0080   /** Automatically load geometry and event data on init (set to false for tests) */
0081   @Input() isAutoLoadOnInit = true;
0082 
0083   @Input()
0084   eventDataImportOptions: string[] = []; // example, if you used them in UI
0085 
0086   @ViewChild('displayHeaderControls', {static: true})
0087   displayHeaderControls!: TemplateRef<any>;
0088 
0089   @ViewChild('eventDisplay')
0090   eventDisplayDiv!: ElementRef;
0091 
0092   // For referencing child components
0093   @ViewChild(ShellComponent)
0094   displayShellComponent!: ShellComponent;
0095 
0096   @ViewChild(SceneTreeComponent)
0097   geometryTreeComponent: SceneTreeComponent | null | undefined;
0098 
0099   @ViewChild(CubeViewportControlComponent)
0100   private cubeControl!: CubeViewportControlComponent;
0101 
0102   geometryUrl = new ConfigProperty('geometry.selectedGeometry', 'https://eic.github.io/epic/artifacts/tgeo/epic_craterlake.root');
0103   geometryFastAndUgly = new ConfigProperty('geometry.FastDefaultMaterial', false);
0104   geometryCutListName = new ConfigProperty('geometry.cutListName', "off");
0105   dexJsonEventSource = new ConfigProperty('events.dexEventsSource', '');
0106   rootEventSource = new ConfigProperty('events.rootEventSource', '');
0107   rootEventRange = new ConfigProperty('events.rootEventRange', '0-5');
0108 
0109   message = "";
0110 
0111   loaded: boolean = false;
0112 
0113   // The geometry group switching index, used in cycleGeometry()
0114   private geometryGroupSwitchingIndex = ALL_GROUPS.length;
0115   currentGeometry: string = 'All';
0116 
0117   // UI toggles
0118   isLeftPaneOpen: boolean = false;
0119   isRightPaneOpen: boolean = false;
0120 
0121   // Loading indicators
0122   loadingDex     = signal(false);
0123   loadingEdm     = signal(false);
0124   loadingGeometry = signal(false);
0125 
0126   // lil GUI for right panel
0127   lilGui = new GUI();
0128   showGui = false;
0129 
0130   // video recording
0131   offlineRecording = signal(false);
0132   offlineProgress = signal('');
0133   private offlineAbort: AbortController | null = null;
0134   capturedFrames: Blob[] = [];
0135   captureOverrideResolution = true;
0136   captureWidth = 3840;
0137   captureHeight = 2160;
0138 
0139   // Phoenix API
0140   private facade: PhoenixThreeFacade = new PhoenixThreeFacade(new EventDisplay());
0141 
0142   constructor(
0143     private controller: GameControllerService,
0144     private snackBar: MatSnackBar,
0145     public  eventDisplay: EventDisplayService,
0146     private config: ConfigService,
0147     private serverConfig: ServerConfigService,
0148     public  geomService: GeometryService,
0149   ) {
0150     config.addConfig(this.geometryUrl);
0151     config.addConfig(this.geometryFastAndUgly);
0152     config.addConfig(this.geometryCutListName);
0153     config.addConfig(this.dexJsonEventSource);
0154     config.addConfig(this.rootEventSource);
0155     config.addConfig(this.rootEventRange);
0156   }
0157 
0158 
0159   async ngOnInit() {
0160     // Initialize the ThreeService scene/camera/renderer/controls
0161     this.eventDisplay.initThree('eventDisplay');
0162 
0163     // The facade will be initialized in three.service
0164     this.facade.initializeScene()
0165 
0166 
0167     this.controller.buttonY.onPress.subscribe((value) => {
0168       if (value) {
0169         // TODO this.cycleGeometry();
0170       }
0171     });
0172   }
0173 
0174 
0175   // 2) AFTER VIEW INIT => handle resizing with DisplayShell or window
0176   ngAfterViewInit(): void {
0177 
0178     if (this.isAutoLoadOnInit) {
0179       // Load JSON based data files
0180       this.initDexEventSource();
0181 
0182       // Load Root file based data files
0183       this.initRootData();
0184     }
0185 
0186     if (this.displayShellComponent) {
0187       const resizeInvoker = () => {
0188         setTimeout(() => {
0189           this.onRendererElementResize();
0190         }, 100);
0191       };
0192       this.displayShellComponent.onVisibilityChangeLeft.subscribe(resizeInvoker);
0193       this.displayShellComponent.onVisibilityChangeRight.subscribe(resizeInvoker);
0194       this.displayShellComponent.onEndResizeLeft.subscribe(() => this.onRendererElementResize());
0195       this.displayShellComponent.onEndResizeRight.subscribe(() => this.onRendererElementResize());
0196     }
0197 
0198     this.initCubeViewportControl();
0199 
0200     window.addEventListener('resize', () => {
0201       this.onRendererElementResize();
0202     });
0203 
0204     // When sidebar is collapsed/opened, the main container, i.e. #eventDisplay offsetWidth is not yet updated.
0205     // This leads to a not proper resize  processing. We add 100ms delay before calling a function
0206     const this_obj = this;
0207     const resizeInvoker = function () {
0208       setTimeout(() => {
0209         this_obj.onRendererElementResize();
0210       }, 100);  // 100 milliseconds = 0.1 seconds
0211     };
0212     resizeInvoker();
0213 
0214     // Loads the geometry (do it last as it might be long)
0215     if (this.isAutoLoadOnInit) {
0216       this.initGeometry();
0217     }
0218 
0219     // Init gui
0220     this.lilGui.add(this, 'cameraToCenter').name('Camera to center');
0221     this.lilGui.add(this, 'cameraToFarForward').name('Camera to Far Forward');
0222     this.lilGui.add(this, 'makeScreenshot').name('Make Screenshot');
0223 
0224     this.lilGui.add(this.eventDisplay.three.perspectiveCamera.position, 'x').name('Camera x[mm]').decimals(2).listen();
0225     this.lilGui.add(this.eventDisplay.three.perspectiveCamera.position, 'y').name('Camera y[mm]').decimals(2).listen();
0226     this.lilGui.add(this.eventDisplay.three.perspectiveCamera.position, 'z').name('Camera z[mm]').decimals(2).listen();
0227 
0228     this.lilGui.add(this.eventDisplay.three.controls.target, 'x').name("Pivot x[mm]").decimals(1).listen();
0229     this.lilGui.add(this.eventDisplay.three.controls.target, 'y').name("Pivot y[mm]").decimals(1).listen();
0230     this.lilGui.add(this.eventDisplay.three.controls.target, 'z').name("Pivot z[mm]").decimals(1).listen();
0231 
0232     this.lilGui.add(this.eventDisplay.three, "showBVHDebug");
0233 
0234     // GUI settings
0235     this.lilGui.domElement.style.top = '64px';
0236     this.lilGui.domElement.style.right = '120px';
0237     this.lilGui.domElement.style.display = 'none';
0238 
0239     this.mediaSource.addEventListener('sourceopen', this.handleSourceOpen, false);
0240 
0241     // Video Capture controls
0242     const videoFolder = this.lilGui.addFolder('Video Capture');
0243     videoFolder.close();
0244     videoFolder.add(this, 'startRecording').name('Start recording');
0245     videoFolder.add(this, 'stopRecording').name('Stop recording');
0246     videoFolder.add(this, 'download').name('Download recording');
0247 
0248     // High Resolution Capture controls
0249     const captureFolder = this.lilGui.addFolder('High Resolution Capture');
0250     captureFolder.close();
0251     captureFolder.add(this, 'captureOverrideResolution').name('Override resolution');
0252     captureFolder.add(this, 'captureWidth', 640, 7680, 1).name('Width');
0253     captureFolder.add(this, 'captureHeight', 360, 4320, 1).name('Height');
0254     captureFolder.add(this, 'startOfflineRecording').name('▶ Start Capture');
0255     captureFolder.add(this, 'stopOfflineRecording').name('⏹ Stop');
0256     captureFolder.add(this, 'downloadFrames').name('💾 Download Frames');
0257 
0258   }
0259 
0260   // 3) UI - Toggling panes
0261   toggleLeftPane() {
0262     this.displayShellComponent?.toggleLeftPane();
0263     this.isLeftPaneOpen = !this.isLeftPaneOpen;
0264   }
0265 
0266   toggleRightPane() {
0267     this.displayShellComponent?.toggleRightPane();
0268     this.isRightPaneOpen = !this.isRightPaneOpen;
0269   }
0270 
0271   // 4) Method to initialize CubeViewportControl with the existing Three.js objects
0272   private initCubeViewportControl(): void {
0273     const {scene, camera, renderer} = this.eventDisplay.three;
0274     if (this.cubeControl && scene && camera && renderer) {
0275       // Pass the external scene, camera, and renderer to the cube control
0276       this.cubeControl.initWithExternalScene(scene, camera, renderer);
0277       this.cubeControl.gizmo.attachControls(this.eventDisplay.three.controls);
0278       this.cubeControl.gizmo.camera
0279     }
0280 
0281     const thisPointer = this;
0282     this.eventDisplay.three.addFrameCallback(() => {
0283       if (thisPointer.cubeControl?.gizmo) {
0284         thisPointer.cubeControl.gizmo.render();
0285       }
0286     });
0287   }
0288 
0289 
0290   showError(message: string) {
0291     this.snackBar.open(message, 'Dismiss', {
0292       duration: 7000, // Auto-dismiss after X ms
0293       // verticalPosition: 'top', // Place at the top of the screen
0294       panelClass: ['error-snackbar']
0295     });
0296   }
0297 
0298 
0299   ngOnDestroy(): void {
0300     // Clear the custom controls when leaving the page
0301   }
0302 
0303 
0304   // Called when we want to recalculate the size of the canvas
0305   private onRendererElementResize() {
0306     let {width, height} = this.displayShellComponent.getMainAreaVisibleDimensions();
0307     console.log(`[RendererResize] New size: ${width}x${height} px`);
0308 
0309     // Delegate resizing to ThreeService
0310     this.eventDisplay.three.setSize(width, height);
0311     if (this.cubeControl?.gizmo) {
0312       this.cubeControl.gizmo.update();
0313     }
0314   }
0315 
0316   // 10) SCENE TREE / UI
0317   private updateSceneTreeComponent() {
0318     // Example: rename lights
0319     const scene = this.eventDisplay.three.scene;
0320     if (this.geometryTreeComponent) {
0321       this.geometryTreeComponent.refreshSceneTree();
0322     }
0323   }
0324 
0325   toggleCameraControls() {
0326     this.showGui = !this.showGui;
0327 
0328     // Toggle GUI visibility
0329     const guiElement = this.lilGui.domElement;
0330     if (this.showGui) {
0331       guiElement.style.display = 'block';
0332     } else {
0333       guiElement.style.display = 'none';
0334     }
0335   }
0336 
0337 
0338   selectedConfigItem: any = null;
0339 
0340   onConfigureItemClicked(type: string) {
0341     if (type === 'track') {
0342       this.selectedConfigItem = {
0343         name: 'Track A',
0344         type: 'track',
0345         config: new TrackPainterConfig()
0346       };
0347     }
0348 
0349     this.toggleRightPane();
0350   }
0351 
0352   private initDexEventSource() {
0353 
0354     // We set loadingDex=false to be safe
0355     this.loadingDex.set(false);
0356 
0357     let dexUrl = this.config.getConfig<string>('events.dexEventsSource')?.value;
0358 
0359     if (!dexUrl || dexUrl.trim().length === 0) {
0360       console.log("[main-display]: No event data source specified. Skip loadDexData.");
0361     }
0362     // Check if we have the same data
0363     else if (this.eventDisplay.lastLoadedDexUrl === dexUrl) {
0364       console.log(`[main-display]: Event data (DEX) url is the same as before: '${dexUrl}', skip loading.`);
0365     }
0366     // Try to load
0367     else {
0368       this.loadingDex.set(true);
0369       this.eventDisplay.loadDexData(dexUrl).catch(error => {
0370         const msg = `Error loading events: ${error}`;
0371         console.error(`[main-display]: ${msg}`);
0372         this.showError(msg);
0373       }).then(() => {
0374         console.log("[main-display]: Event data loaded.");
0375         this.updateSceneTreeComponent();
0376       }).finally(()=>{
0377         this.loadingDex.set(false);   // switch off loading indicator
0378       });
0379     }
0380   }
0381 
0382 
0383   private initRootData() {
0384     let url = (
0385       this.config.getConfig<string>('events.rootEventSource')
0386       ?? this.config.createConfig('events.rootEventSource', '')
0387     ).subject.getValue();
0388 
0389     let eventRange = (
0390       this.config.getConfig<string>('events.rootEventRange')
0391       ?? this.config.createConfig('events.rootEventRange', '')
0392     ).subject.getValue();
0393 
0394 
0395     // Do we have url?
0396     if (!url || url.trim().length === 0) {
0397       console.log("[main-display]: No Edm4Eic source specified. Nothing to load");
0398       return;
0399     }
0400 
0401     // Do we have event Range?
0402     if (!eventRange || eventRange.trim().length === 0) {
0403       console.log("[main-display]: Event Range specified. Trying '0', to load the first event");
0404       eventRange = "0";
0405     }
0406 
0407     // Check if we have the same data
0408     if (this.eventDisplay.lastLoadedRootUrl === url && this.eventDisplay.lastLoadedRootEventRange === eventRange) {
0409       console.log(`[main-display]: Edm url is the same as before: '${url}', eventRange: '${eventRange}' - skip loading.`);
0410       return;
0411     }
0412 
0413     // Try to load
0414     else {
0415       this.loadingEdm.set(true);
0416       this.eventDisplay.loadRootData(url, eventRange).catch(error => {
0417         const msg = `Error loading events: ${error}`;
0418         console.error(`[main-display]: ${msg}`);
0419         this.showError(msg);
0420       }).then(() => {
0421         console.log("[main-display]: Event data loaded.");
0422         this.updateSceneTreeComponent();
0423       }).finally(()=>{
0424         this.loadingEdm.set(false);   // switch off loading indicator
0425       });
0426     }
0427   }
0428 
0429 
0430   /**
0431    * Cancel any ongoing geometry loading operation
0432    */
0433   cancelGeometryLoading(): void {
0434     if (this.geomService.isLoading()) {
0435       console.log("[main-display]: Cancelling geometry loading...");
0436       this.geomService.cancelLoading();
0437     }
0438   }
0439 
0440   private initGeometry() {
0441     const url = (this.config.getConfigOrCreate<string>('geometry.selectedGeometry', '')).value;
0442 
0443     if (!url || url.trim().length === 0) {
0444       console.log("[main-display]: No geometry specified. Skip loadGeometry ");
0445       return;
0446     }
0447 
0448     if (this.eventDisplay.lastLoadedGeometryUrl === url) {
0449       console.log(`[main-display]: Geometry url is the same as before: '${url}', skip loading`);
0450       return;
0451     }
0452 
0453     // Cancel any existing geometry load before starting a new one
0454     this.cancelGeometryLoading();
0455 
0456     this.loadingGeometry.set(true);
0457     this.eventDisplay.loadGeometry(url)
0458       .then((result) => {
0459         // Only update UI if geometry was actually loaded (not cancelled)
0460         if (result !== undefined) {
0461           this.updateSceneTreeComponent();
0462           console.log("[main-display]: Geometry loaded");
0463         } else {
0464           console.log("[main-display]: Geometry loading was cancelled");
0465         }
0466       })
0467       .catch(error => {
0468         const msg = `Error loading geometry: ${error}`;
0469         console.error(`[main-display]: ${msg}`);
0470         this.showError("Error loading Geometry. Open 'Configure' to change. Press F12->Console for logs");
0471       })
0472       .finally(() => this.loadingGeometry.set(false));
0473   }
0474 
0475 
0476   animateWithCollision() {
0477     this.eventDisplay.animateWithCollision();
0478   }
0479 
0480   cameraToCenter() {
0481     this.eventDisplay.three.camera.position.setX(-3600);
0482     this.eventDisplay.three.camera.position.setY(2900);
0483     this.eventDisplay.three.camera.position.setZ(-4700);
0484     this.eventDisplay.three.controls.target.setX(0);
0485     this.eventDisplay.three.controls.target.setY(0);
0486     this.eventDisplay.three.controls.target.setZ(0);
0487   }
0488 
0489   cameraToFarForward() {
0490     this.eventDisplay.three.camera.position.setX(8000);
0491     this.eventDisplay.three.camera.position.setY(7500);
0492     this.eventDisplay.three.camera.position.setZ(40000);
0493     this.eventDisplay.three.controls.target.setX(0);
0494     this.eventDisplay.three.controls.target.setY(0);
0495     this.eventDisplay.three.controls.target.setZ(30000);
0496   }
0497 
0498   makeScreenshot() {
0499     const renderer = this.eventDisplay.three.renderer;
0500     // Render one frame to ensure the canvas is up to date
0501     renderer.render(this.eventDisplay.three.scene, this.eventDisplay.three.camera);
0502     // Use toDataURL for broad browser compatibility (works in Firefox)
0503     const dataUrl = renderer.domElement.toDataURL('image/png');
0504     const link = document.createElement('a');
0505     link.href = dataUrl;
0506     link.download = `firebird-screenshot-${Date.now()}.png`;
0507     document.body.appendChild(link);
0508     link.click();
0509     document.body.removeChild(link);
0510   }
0511 
0512   mediaSource = new MediaSource();
0513 
0514   mediaRecorder?: MediaRecorder;
0515   recordedBlobs = [];
0516   sourceBuffer!:SourceBuffer;
0517   originalSize?:{width:number, height:number}|null;
0518 
0519 
0520   handleSourceOpen(event: any) {
0521     console.log('MediaSource opened');
0522     this.sourceBuffer = this.mediaSource.addSourceBuffer('video/webm; codecs="vp8"');
0523     console.log('Source buffer: ', this.sourceBuffer);
0524   }
0525 
0526   handleDataAvailable(event:any) {
0527     if (event.data && event.data.size > 0) {
0528       // @ts-ignore
0529       this.recordedBlobs.push(event.data);
0530     }
0531   }
0532 
0533   handleStop(event: any) {
0534     console.log('Recorder stopped: ', event);
0535     const superBuffer = new Blob(this.recordedBlobs, {type: 'video/webm'});
0536     const url = window.URL.createObjectURL(superBuffer);
0537     const a = document.createElement('a');
0538     a.style.display = 'none';
0539     a.href = url;
0540     a.download = 'recording.webm';
0541     document.body.appendChild(a);
0542     a.click();
0543     window.URL.revokeObjectURL(url);
0544     document.body.removeChild(a);
0545   }
0546 
0547   startRecording() {
0548     // Save current size so we can restore later
0549 
0550     const stream = this.eventDisplay.three.renderer.domElement.captureStream(60);
0551     this.recordedBlobs = [];
0552 
0553     const optionsList = [
0554       { mimeType: 'video/webm;codecs=vp9', videoBitsPerSecond: 200_000_000 },  // 200 Mbps for 4K
0555       { mimeType: 'video/webm;codecs=vp8', videoBitsPerSecond: 200_000_000 },
0556       { mimeType: 'video/webm', videoBitsPerSecond: 200_000_000 },
0557     ];
0558 
0559     let recorder: MediaRecorder | null = null;
0560     for (const options of optionsList) {
0561       if (MediaRecorder.isTypeSupported(options.mimeType)) {
0562         try {
0563           recorder = new MediaRecorder(stream, options);
0564           console.log('Created MediaRecorder with', options);
0565           break;
0566         } catch (e) {
0567           console.warn('Failed with options', options, e);
0568         }
0569       }
0570     }
0571 
0572     if (!recorder) {
0573       alert('MediaRecorder is not supported by this browser.');
0574       return;
0575     }
0576 
0577     this.mediaRecorder = recorder;
0578     this.mediaRecorder.onstop = (event) => this.handleStop(event);
0579     this.mediaRecorder.ondataavailable = (event) => this.handleDataAvailable(event);
0580     this.mediaRecorder.start(100);
0581   }
0582 
0583   stopRecording() {
0584     this.mediaRecorder?.stop();
0585     console.log('Recorded Blobs: ', this.recordedBlobs);
0586   }
0587 
0588 
0589 
0590 
0591   download() {
0592     const blob = new Blob(this.recordedBlobs, {type: 'video/webm'});
0593     const url = window.URL.createObjectURL(blob);
0594     const a = document.createElement('a');
0595     a.style.display = 'none';
0596     a.href = url;
0597     a.download = 'test.webm';
0598     document.body.appendChild(a);
0599     a.click();
0600     setTimeout(() => {
0601       document.body.removeChild(a);
0602       window.URL.revokeObjectURL(url);
0603     }, 100);
0604   }
0605 
0606   async startOfflineRecording() {
0607     if (this.offlineRecording()) return;
0608     this.snackBar.open('Capture may distort video in your browser. Resulting captures will be fine.', 'OK', { duration: 5000 });
0609     this.offlineRecording.set(true);
0610     this.offlineProgress.set('Preparing...');
0611 
0612     this.offlineAbort = new AbortController();
0613     let frames: Blob[] = [];
0614 
0615     try {
0616       frames = await this.eventDisplay.captureFramesOffline({
0617         overrideResolution: this.captureOverrideResolution,
0618         width: this.captureWidth,
0619         height: this.captureHeight,
0620         eventTimeStep: 0.1,
0621         includeCollision: true,
0622         signal: this.offlineAbort.signal,
0623         onProgress: (current, total) => {
0624           if (total > 0) {
0625             this.offlineProgress.set(`Frame ${current} / ${total}`);
0626           } else {
0627             this.offlineProgress.set(`Frame ${current} (collision phase)`);
0628           }
0629         },
0630       });
0631 
0632       if (this.offlineAbort.signal.aborted) {
0633         this.offlineProgress.set(`Stopped. Captured ${frames.length} frames.`);
0634       }
0635     } catch (err) {
0636       console.error('Offline recording failed:', err);
0637       this.showError(`Offline recording failed: ${err}`);
0638       this.offlineRecording.set(false);
0639       return;
0640     }
0641 
0642     // Store frames so we can download later even after stopping
0643     this.capturedFrames = frames;
0644     this.offlineRecording.set(false);
0645 
0646     if (frames.length > 0) {
0647       this.offlineProgress.set(`${frames.length} frames ready. Use "Download frames" button.`);
0648     }
0649   }
0650 
0651   stopOfflineRecording() {
0652     this.offlineAbort?.abort();
0653   }
0654 
0655   async downloadFrames() {
0656     if (this.capturedFrames.length === 0) {
0657       this.showError('No frames captured yet.');
0658       return;
0659     }
0660 
0661     const total = this.capturedFrames.length;
0662 
0663     // Prefer File System Access API (Chromium) — writes each PNG directly,
0664     // no memory spike at all. Falls back to chunked ZIPs for Firefox/Safari.
0665     if ('showDirectoryPicker' in window) {
0666       try {
0667         await this.downloadFramesToDirectory(total);
0668         return;
0669       } catch (err: any) {
0670         if (err.name === 'AbortError') return; // user cancelled picker
0671         console.warn('Directory picker failed, falling back to chunked ZIPs:', err);
0672       }
0673     }
0674 
0675     await this.downloadFramesAsChunkedZips(total);
0676   }
0677 
0678   /** Write each frame as an individual PNG into a user-chosen folder. */
0679   private async downloadFramesToDirectory(total: number) {
0680     this.offlineProgress.set('Choose a folder to save frames...');
0681     const dirHandle = await (window as any).showDirectoryPicker({ mode: 'readwrite' });
0682 
0683     const yieldToUI = () => new Promise(resolve => setTimeout(resolve, 0));
0684     this.snackBar.open(`Saving ${total} frames to folder...`, undefined, { duration: 0 });
0685 
0686     for (let i = 0; i < total; i++) {
0687       const name = `frame_${String(i).padStart(6, '0')}.png`;
0688       const fileHandle = await dirHandle.getFileHandle(name, { create: true });
0689       const writable = await fileHandle.createWritable();
0690       await writable.write(this.capturedFrames[i]);
0691       await writable.close();
0692 
0693       if (i % 10 === 0) {
0694         this.offlineProgress.set(`Saving frame ${i + 1} / ${total}...`);
0695         await yieldToUI();
0696       }
0697     }
0698 
0699     this.offlineProgress.set(`Done! Saved ${total} frames to folder.`);
0700     this.snackBar.open(`Saved ${total} frames`, 'OK', { duration: 5000 });
0701   }
0702 
0703   /**
0704    * Fallback: split frames into small ZIPs (~200 frames each) so no single
0705    * ZIP blob exceeds browser ArrayBuffer limits.
0706    */
0707   private async downloadFramesAsChunkedZips(total: number) {
0708     const CHUNK_SIZE = 200;
0709     const numChunks = Math.ceil(total / CHUNK_SIZE);
0710 
0711     this.snackBar.open(
0712       `Saving ${total} frames in ${numChunks} ZIP file(s)...`, undefined, { duration: 0 }
0713     );
0714 
0715     const yieldToUI = () => new Promise(resolve => setTimeout(resolve, 0));
0716 
0717     for (let chunk = 0; chunk < numChunks; chunk++) {
0718       const start = chunk * CHUNK_SIZE;
0719       const end = Math.min(start + CHUNK_SIZE, total);
0720 
0721       this.offlineProgress.set(
0722         `ZIP ${chunk + 1}/${numChunks}: packing frames ${start}–${end - 1}...`
0723       );
0724       await yieldToUI();
0725 
0726       const zip = new JSZip();
0727       const folder = zip.folder('frames')!;
0728       for (let i = start; i < end; i++) {
0729         folder.file(`frame_${String(i).padStart(6, '0')}.png`, this.capturedFrames[i]);
0730       }
0731 
0732       const blob = await zip.generateAsync(
0733         { type: 'blob', compression: 'STORE' },
0734         (meta) => this.offlineProgress.set(
0735           `ZIP ${chunk + 1}/${numChunks}: ${meta.percent.toFixed(0)}%`
0736         )
0737       );
0738 
0739       const url = URL.createObjectURL(blob);
0740       const a = document.createElement('a');
0741       a.href = url;
0742       a.download = numChunks === 1
0743         ? 'frames_4k.zip'
0744         : `frames_4k_part${String(chunk + 1).padStart(2, '0')}.zip`;
0745       a.click();
0746 
0747       // Give browser time to start the download before revoking
0748       await new Promise(resolve => setTimeout(resolve, 1000));
0749       URL.revokeObjectURL(url);
0750       await yieldToUI();
0751     }
0752 
0753     this.offlineProgress.set(`Done! Downloaded ${total} frames in ${numChunks} ZIP(s).`);
0754     this.snackBar.open(`Downloaded ${total} frames`, 'OK', { duration: 5000 });
0755   }
0756 }