Warning, /firebird/packages/firebird-ng/display/src/lib/services/geometry.service.ts is written in an unsupported language. File is not indexed.
0001 import {Injectable, inject, signal, WritableSignal} from '@angular/core';
0002 import {ConfigProperty, ConfigService, resolveRegistry} from '@dexvis/app-features';
0003 import {
0004 FIREBIRD_WORKERS,
0005 GEOMETRY_CUT_LIST_CONFIG,
0006 GEOMETRY_FAST_MATERIAL_CONFIG,
0007 GEOMETRY_ROOT_FILTER_CONFIG,
0008 GEOMETRY_THEME_CONFIG,
0009 GEOMETRY_THEMES,
0010 resolveRootGeometryRules,
0011 ROOT_GEOMETRY_RULES,
0012 RootGeometryLoadRules,
0013 RootGeometryRules,
0014 selectRootGeometryLoadRules,
0015 UrlService,
0016 } from '@dexvis/firebird-ng/api';
0017 import {Subdetector, DetectorThreeRuleSet, ThreeGeometryProcessor, getColorOrDefault} from '@dexvis/threejs-tree-editor';
0018 import {Color, DoubleSide, MeshLambertMaterial, NormalBlending, Object3D, ObjectLoader, Plane} from "three";
0019 import * as THREE from "three";
0020
0021
0022 import type {
0023 WorkerResponse,
0024 GeometryLoadRequest,
0025 GeometryCancelRequest,
0026 SubdetectorInfo
0027 } from "@dexvis/firebird-ng/workers/geometry";
0028
0029
0030
0031 /** Result returned by loadGeometry */
0032 interface GeometryLoadResult {
0033 threeGeometry: Object3D | null;
0034 cancelled: boolean;
0035 }
0036
0037 /** Progress callback type */
0038 export type GeometryProgressCallback = (stage: string, progress: number) => void;
0039
0040 /** The rejection of a load that a newer load replaced. */
0041 function replacedError(): DOMException {
0042 return new DOMException('A newer geometry load replaced this one', 'AbortError');
0043 }
0044
0045 @Injectable({
0046 providedIn: 'root'
0047 })
0048 export class GeometryService {
0049
0050 // Pipeline options from the config page (keys and defaults: config-keys.ts)
0051 geometryFastAndUgly: ConfigProperty<boolean>;
0052 geometryCutListName: ConfigProperty<string>;
0053 geometryThemeName: ConfigProperty<string>;
0054 geometryRootFilterName: ConfigProperty<string>;
0055
0056 /** Collection of subdetectors */
0057 public subdetectors: Subdetector[] = [];
0058
0059 /** TGeoManager - no longer available when using worker (kept for API compatibility) */
0060 public rootGeometry: any | null = null;
0061
0062 /** Pre-build TGeo rules (withRootGeometryRules), merged once on the first load. */
0063 private readonly rootRuleSources = inject(ROOT_GEOMETRY_RULES, {optional: true}) ?? [];
0064 private rootRules: Promise<RootGeometryRules> | null = null;
0065
0066 /** Geometry themes (withGeometryTheme), one per id: a later registration replaces an earlier one. */
0067 private readonly themes = resolveRegistry(inject(GEOMETRY_THEMES, {optional: true}) ?? [], theme => theme.id);
0068
0069 /** for geometry post-processing */
0070 private threeGeometryProcessor = new ThreeGeometryProcessor();
0071
0072 private defaultColor: Color = new Color(0x68698D);
0073
0074 /** The geometry on screen: EventDisplayService sets it when it shows a load. */
0075 public geometry: WritableSignal<Object3D | null> = signal(null);
0076
0077 /** Loading progress signal (0-100) */
0078 public loadingProgress: WritableSignal<number> = signal(0);
0079
0080 /** Current loading stage description */
0081 public loadingStage: WritableSignal<string> = signal('');
0082
0083 /** The worker factories of the application (withWorkers). */
0084 private readonly workers = inject(FIREBIRD_WORKERS, {optional: true});
0085 /** The geometry worker, created on the first load; dropped when it fails. */
0086 private worker: Worker | null = null;
0087
0088 /** Current active request ID */
0089 private currentRequestId: string | null = null;
0090
0091 /** Pending promise resolvers for geometry loading */
0092 private pendingResolvers: Map<string, {
0093 resolve: (result: GeometryLoadResult) => void;
0094 reject: (error: Error) => void;
0095 onProgress?: GeometryProgressCallback;
0096 }> = new Map();
0097
0098 /** ObjectLoader for deserializing geometry from worker */
0099 private objectLoader = new ObjectLoader();
0100
0101 constructor(
0102 private urlService: UrlService,
0103 private config: ConfigService,
0104 ) {
0105 // declare returns the canonical instance for the key (the config page or
0106 // the data selector may have declared it first) — keep the returned reference.
0107 this.geometryFastAndUgly = this.config.declare(GEOMETRY_FAST_MATERIAL_CONFIG);
0108 this.geometryCutListName = this.config.declare(GEOMETRY_CUT_LIST_CONFIG);
0109 this.geometryThemeName = this.config.declare(GEOMETRY_THEME_CONFIG);
0110 this.geometryRootFilterName = this.config.declare(GEOMETRY_ROOT_FILTER_CONFIG);
0111 }
0112
0113 /**
0114 * The geometry worker, started on first use. A worker that fails (its
0115 * script did not load, or it crashed) rejects every pending load and is
0116 * dropped, so the next load starts a fresh one instead of waiting forever
0117 * on a dead worker.
0118 */
0119 private ensureWorker(): Worker {
0120 if (this.worker) return this.worker;
0121 if (typeof Worker === 'undefined') {
0122 throw new Error('Web Workers are not available, cannot load ROOT geometry in this browser');
0123 }
0124 if (!this.workers) {
0125 throw new Error('No geometry worker: add withWorkers({ geometry, rootFile }) to provideFirebird()');
0126 }
0127 const worker = this.workers.geometry();
0128 worker.onmessage = ({data}: MessageEvent<WorkerResponse>) => {
0129 this.handleWorkerMessage(data);
0130 };
0131 worker.onerror = (error) => {
0132 const message = `Geometry worker error: ${error.message ?? 'the worker script failed to load'}`;
0133 console.error(`[GeometryService]: ${message}`);
0134 for (const resolvers of this.pendingResolvers.values()) {
0135 resolvers.reject(new Error(message));
0136 }
0137 this.pendingResolvers.clear();
0138 this.currentRequestId = null;
0139 worker.terminate();
0140 if (this.worker === worker) this.worker = null;
0141 };
0142 this.worker = worker;
0143 return worker;
0144 }
0145
0146 /**
0147 * Handle messages from the worker
0148 */
0149 private handleWorkerMessage(data: WorkerResponse): void {
0150 const resolvers = this.pendingResolvers.get(data.requestId);
0151
0152 // Check if this response is for an old/stale request (not the current one)
0153 const isStaleRequest = data.requestId !== this.currentRequestId;
0154
0155 if (data.type === 'progress') {
0156 // Only update progress for current request
0157 if (!isStaleRequest) {
0158 this.loadingProgress.set(data.progress);
0159 this.loadingStage.set(data.stage);
0160 if (resolvers?.onProgress) {
0161 resolvers.onProgress(data.stage, data.progress);
0162 }
0163 }
0164 return;
0165 }
0166
0167 if (!resolvers) {
0168 // This can happen for stale requests that were already resolved
0169 if (isStaleRequest) {
0170 console.log(`[GeometryService]: Ignoring stale response for ${data.requestId} (current: ${this.currentRequestId})`);
0171 } else {
0172 console.warn(`[GeometryService]: No pending request for ${data.requestId}`);
0173 }
0174 return;
0175 }
0176
0177 this.pendingResolvers.delete(data.requestId);
0178
0179 // If this is a stale request, resolve as cancelled without processing
0180 if (isStaleRequest && data.type === 'success') {
0181 console.log(`[GeometryService]: Discarding stale geometry for ${data.requestId} (current: ${this.currentRequestId})`);
0182 resolvers.resolve({threeGeometry: null, cancelled: true});
0183 return;
0184 }
0185
0186 if (data.requestId === this.currentRequestId) {
0187 this.currentRequestId = null;
0188 }
0189
0190 if (data.type === 'success') {
0191 try {
0192 // Deserialize the geometry using ObjectLoader
0193 console.time('[GeometryService]: Parse geometry from JSON');
0194 const geometry = this.objectLoader.parse(data.geometryJson) as Object3D;
0195 console.timeEnd('[GeometryService]: Parse geometry from JSON');
0196
0197 // jsroot creates objects with matrixAutoUpdate=false and sets matrices directly.
0198 // After deserialization, we need to ensure all matrices are properly applied.
0199 console.time('[GeometryService]: Update matrix world');
0200 this.restoreMatrixState(geometry);
0201 console.timeEnd('[GeometryService]: Update matrix world');
0202
0203 // Build subdetectors from the worker's metadata
0204 this.buildSubdetectors(geometry, data.subdetectorInfos);
0205
0206 this.loadingProgress.set(100);
0207 this.loadingStage.set('Complete');
0208
0209 resolvers.resolve({threeGeometry: geometry, cancelled: false});
0210 } catch (error: any) {
0211 resolvers.reject(new Error(`Failed to parse geometry: ${error.message}`));
0212 }
0213 } else if (data.type === 'cancelled') {
0214 console.log(`[GeometryService]: Load cancelled for ${data.requestId}`);
0215 // Only reset progress if this is the current request
0216 if (!isStaleRequest) {
0217 this.loadingProgress.set(0);
0218 this.loadingStage.set('Cancelled');
0219 }
0220 resolvers.resolve({threeGeometry: null, cancelled: true});
0221 } else if (data.type === 'error') {
0222 // Only reset progress if this is the current request
0223 if (!isStaleRequest) {
0224 this.loadingProgress.set(0);
0225 this.loadingStage.set('Error');
0226 }
0227 resolvers.reject(new Error(data.error));
0228 }
0229 }
0230
0231 /**
0232 * Build subdetector objects from worker metadata and deserialized geometry
0233 */
0234 private buildSubdetectors(geometry: Object3D, infos: SubdetectorInfo[]): void {
0235 this.subdetectors = [];
0236
0237 if (!geometry.children.length || !geometry.children[0].children.length) {
0238 return;
0239 }
0240
0241 const topDetectorNodes = geometry.children[0].children;
0242
0243 for (let i = 0; i < topDetectorNodes.length && i < infos.length; i++) {
0244 const topNode = topDetectorNodes[i];
0245 const info = infos[i];
0246
0247 const subdetector: Subdetector = {
0248 sourceGeometry: null, // Not available when using worker
0249 sourceGeometryName: info.originalName,
0250 geometry: topNode,
0251 name: info.name,
0252 groupName: ''
0253 };
0254
0255 this.subdetectors.push(subdetector);
0256 }
0257 }
0258
0259 /**
0260 * Generate a unique request ID
0261 */
0262 private generateRequestId(): string {
0263 return `geo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
0264 }
0265
0266 /**
0267 * Restore matrix state after deserialization.
0268 * jsroot creates objects with matrixAutoUpdate=false and sets matrices directly.
0269 * Three.js ObjectLoader restores the matrix, but we need to ensure it's properly applied.
0270 */
0271 private restoreMatrixState(object: Object3D): void {
0272 // Traverse all objects and ensure matrices are properly set up
0273 object.traverse((child) => {
0274 // jsroot geometry uses matrixAutoUpdate = false
0275 child.matrixAutoUpdate = false;
0276 // Decompose the matrix to position/rotation/scale for proper rendering
0277 child.matrix.decompose(child.position, child.quaternion, child.scale);
0278 });
0279
0280 // Update the world matrices for the entire hierarchy
0281 object.updateMatrixWorld(true);
0282 }
0283
0284 /**
0285 * Cancel the current geometry loading operation.
0286 * The loading promise will resolve with cancelled: true.
0287 */
0288 cancelLoading(): void {
0289 if (!this.currentRequestId || !this.worker) {
0290 return;
0291 }
0292
0293 console.log(`[GeometryService]: Cancelling load request ${this.currentRequestId}`);
0294
0295 const cancelRequest: GeometryCancelRequest = {
0296 type: 'cancel',
0297 requestId: this.currentRequestId
0298 };
0299
0300 this.worker.postMessage(cancelRequest);
0301 }
0302
0303 /**
0304 * Check if geometry is currently being loaded
0305 */
0306 isLoading(): boolean {
0307 return this.currentRequestId !== null;
0308 }
0309
0310 /**
0311 * Loads ROOT TGeo geometry in the geometry worker, which keeps the UI
0312 * responsive while jsroot reads and builds it. Lengths stay in centimeters.
0313 * A newer load cancels this one.
0314 *
0315 * @param url The URL or path to load (aliases and server paths are
0316 * resolved here), or a picked file, read in place.
0317 * @param options.signal Cancels the load: the worker stops and the
0318 * promise rejects with the signal's reason.
0319 * @returns The geometry root.
0320 * @throws Error with the reason; an AbortError when the signal fired or a
0321 * newer load replaced this one.
0322 */
0323 async loadGeometry(
0324 url: string | File,
0325 options: { signal?: AbortSignal; onProgress?: GeometryProgressCallback } = {},
0326 ): Promise<Object3D> {
0327 const {signal, onProgress} = options;
0328 signal?.throwIfAborted();
0329 const worker = this.ensureWorker();
0330 this.subdetectors = [];
0331 this.rootGeometry = null;
0332
0333 // A picked/dropped file is read where it lies - there is no URL to resolve
0334 const finalUrl = typeof url === 'string' ? this.urlService.resolveDownloadUrl(url) : url;
0335 const label = typeof finalUrl === 'string' ? finalUrl : finalUrl.name;
0336
0337 console.log(`[GeometryService]: Loading geometry from ${label}`);
0338 console.time('[GeometryService]: Total load geometry time');
0339
0340 // Cancel any existing load operation and immediately resolve old promise
0341 if (this.currentRequestId) {
0342 const oldRequestId = this.currentRequestId;
0343 const oldResolvers = this.pendingResolvers.get(oldRequestId);
0344 if (oldResolvers) {
0345 console.log(`[GeometryService]: Immediately resolving old request ${oldRequestId} as cancelled`);
0346 this.pendingResolvers.delete(oldRequestId);
0347 oldResolvers.resolve({threeGeometry: null, cancelled: true});
0348 }
0349 this.cancelLoading();
0350 }
0351
0352 const requestId = this.generateRequestId();
0353 this.currentRequestId = requestId;
0354
0355 this.loadingProgress.set(0);
0356 this.loadingStage.set('Starting');
0357
0358 // The rules this load applies, chosen by the config names. Merging may
0359 // wait for a rule chunk to load, and a newer load can start meanwhile.
0360 let rules: RootGeometryLoadRules;
0361 try {
0362 this.rootRules ??= resolveRootGeometryRules(this.rootRuleSources);
0363 rules = selectRootGeometryLoadRules(
0364 await this.rootRules, this.geometryRootFilterName.value, this.geometryCutListName.value);
0365 } catch (error) {
0366 // The next load tries the rule chunks again
0367 this.rootRules = null;
0368 if (this.currentRequestId === requestId) this.currentRequestId = null;
0369 console.timeEnd('[GeometryService]: Total load geometry time');
0370 throw new Error(`Geometry rules failed to load: ${error instanceof Error ? error.message : error}`);
0371 }
0372 if (this.currentRequestId !== requestId || signal?.aborted) {
0373 if (this.currentRequestId === requestId) this.currentRequestId = null;
0374 console.timeEnd('[GeometryService]: Total load geometry time');
0375 throw signal?.reason ?? replacedError();
0376 }
0377
0378 const request: GeometryLoadRequest = {
0379 type: 'load',
0380 requestId,
0381 url: finalUrl,
0382 rules,
0383 };
0384
0385 // Resolved by the worker message handler; the signal cancels the request
0386 const abort = () => {
0387 if (this.currentRequestId === requestId) this.cancelLoading();
0388 };
0389 signal?.addEventListener('abort', abort, {once: true});
0390 let result: GeometryLoadResult;
0391 try {
0392 result = await new Promise<GeometryLoadResult>((resolve, reject) => {
0393 this.pendingResolvers.set(requestId, {resolve, reject, onProgress});
0394 worker.postMessage(request);
0395 });
0396 } finally {
0397 signal?.removeEventListener('abort', abort);
0398 console.timeEnd('[GeometryService]: Total load geometry time');
0399 }
0400
0401 if (result.cancelled || !result.threeGeometry) {
0402 throw signal?.reason ?? replacedError();
0403 }
0404 return result.threeGeometry;
0405 }
0406
0407 /**
0408 * The main-thread material pass of a loaded geometry: default materials,
0409 * then the theme selected by `geometry.themeName`, then the clipping
0410 * planes on every material.
0411 */
0412 public async postProcessing(threeGeometry: Object3D, clippingPlanes: Plane[]): Promise<void> {
0413
0414 // Now we want to set default materials
0415 threeGeometry.traverse((child: any) => {
0416 if (child.type !== 'Mesh' || !child?.material?.isMaterial) {
0417 return;
0418 }
0419
0420 // Handle the material of the child
0421 const color = getColorOrDefault(child.material, this.defaultColor);
0422
0423 if(this.geometryFastAndUgly.value) {
0424 child.material = new MeshLambertMaterial({
0425 color: color,
0426 side: DoubleSide,
0427 transparent: false,
0428 opacity: 1,
0429 blending: THREE.NoBlending,
0430 depthTest: true,
0431 depthWrite: true,
0432 clippingPlanes,
0433 clipIntersection: true,
0434 clipShadows: false,
0435 fog: false,
0436 vertexColors: false,
0437 flatShading: true,
0438 toneMapped: false
0439 });
0440 } else {
0441 child.material = new MeshLambertMaterial({
0442 color: color,
0443 side: DoubleSide,
0444 transparent: true,
0445 opacity: 0.7,
0446 blending: NormalBlending,
0447 depthTest: true,
0448 depthWrite: true,
0449 clippingPlanes: clippingPlanes,
0450 clipIntersection: true,
0451 clipShadows: false,
0452 });
0453 }
0454 });
0455
0456 const ruleSets = await this.themeRuleSets(this.geometryThemeName.value);
0457 if (ruleSets) {
0458 await this.threeGeometryProcessor.processRuleSets([...ruleSets], this.subdetectors);
0459 }
0460
0461 threeGeometry.traverse((child: any) => {
0462 if (!child?.material?.isMaterial) {
0463 return;
0464 }
0465
0466 if (child.material.type === 'LineMaterial' ||
0467 child.material.isLineMaterial ||
0468 child.type === 'Line2' ||
0469 child.type === 'LineSegments2') {
0470 return;
0471 }
0472
0473 if (child.material?.clippingPlanes !== undefined) {
0474 child.material.clippingPlanes = clippingPlanes;
0475 }
0476 if (child.material?.clipIntersection !== undefined) {
0477 child.material.clipIntersection = true;
0478 }
0479 if (child.material?.clipShadows !== undefined) {
0480 child.material.clipShadows = false;
0481 }
0482 });
0483 }
0484
0485 /**
0486 * The rule sets of the theme `id`; undefined for 'off', and for an id no
0487 * feature registered (a saved choice from an installation with another
0488 * pack): the geometry then keeps its default materials.
0489 */
0490 private async themeRuleSets(id: string): Promise<readonly DetectorThreeRuleSet[] | undefined> {
0491 console.log(`[GeometryService]: Geometry theme name is set to '${id}'`);
0492 if (id === 'off') return undefined;
0493 const theme = this.themes.find(candidate => candidate.id === id);
0494 if (!theme) {
0495 console.warn(`[GeometryService]: No geometry theme '${id}' is registered; known: ${this.themes.map(known => known.id).join(', ') || 'none'}`);
0496 return undefined;
0497 }
0498 return theme.load();
0499 }
0500
0501 toggleVisibility(object: Object3D) {
0502 if (object) {
0503 object.visible = !object.visible;
0504 console.log(`Visibility toggled for object: ${object.name}. Now visible: ${object.visible}`);
0505 }
0506 }
0507 }
0508