diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml index abecfd9..1ab5b09 100644 --- a/.github/workflows/frontend.yaml +++ b/.github/workflows/frontend.yaml @@ -80,6 +80,12 @@ jobs: echo "--- ls ./dist/firebird" ls -latrh ./dist/firebird + # Make dynamic routing work after reload or going directly to page + # See issue #6 or https://angular.io/guide/deployment#deploy-to-github-pages + - name: Fix dynamic routing + run: | + cp ./dist/firebird/index.html ./dist/firebird/404.html + - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: diff --git a/.gitignore b/.gitignore index ef586e2..3bb1f83 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,5 @@ dist # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 .idea/* + +.vscode/ diff --git a/firebird-ng/angular.json b/firebird-ng/angular.json index 3ed16fd..3d141b7 100644 --- a/firebird-ng/angular.json +++ b/firebird-ng/angular.json @@ -107,5 +107,8 @@ } } } + }, + "cli": { + "analytics": "aa6f00cf-04e4-4e98-9d0e-1423cb1a0d51" } } diff --git a/firebird-ng/package-lock.json b/firebird-ng/package-lock.json index 14d7ad9..bd0e730 100644 --- a/firebird-ng/package-lock.json +++ b/firebird-ng/package-lock.json @@ -1,12 +1,12 @@ { "name": "firebird", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firebird", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@angular/animations": "^17.3.0", "@angular/common": "^17.3.0", @@ -19,6 +19,8 @@ "@types/picomatch": "^2.3.3", "jsdom": "^24.0.0", "jsrootdi": "^7.6.101", + "lil-gui": "^0.19.2", + "outmatch": "^1.0.0", "phoenix-event-display": "^2.16.0", "phoenix-ui-components": "^2.16.0", "picomatch": "^4.0.2", @@ -9802,6 +9804,11 @@ "immediate": "~3.0.5" } }, + "node_modules/lil-gui": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.19.2.tgz", + "integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -11104,6 +11111,11 @@ "node": ">=0.10.0" } }, + "node_modules/outmatch": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/outmatch/-/outmatch-1.0.0.tgz", + "integrity": "sha512-Dro+1hlvosA7DUKFqeHUA2g+xdWGoGTGZ+19go5vwxqS7iSTXQtajrEdhTdBXdahzGBEV2NH89vouFWpLyOuPg==" + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", diff --git a/firebird-ng/package.json b/firebird-ng/package.json index 54b4bfc..6dcdeee 100644 --- a/firebird-ng/package.json +++ b/firebird-ng/package.json @@ -1,6 +1,6 @@ { "name": "firebird", - "version": "0.0.1", + "version": "0.0.3", "scripts": { "ng": "ng", "start": "ng serve", @@ -24,6 +24,7 @@ "@types/picomatch": "^2.3.3", "jsdom": "^24.0.0", "jsrootdi": "^7.6.101", + "outmatch": "^1.0.0", "phoenix-event-display": "^2.16.0", "phoenix-ui-components": "^2.16.0", "picomatch": "^4.0.2", @@ -31,7 +32,8 @@ "three": "^0.164.1", "tslib": "^2.3.0", "vm": "^0.1.0", - "zone.js": "~0.14.3" + "zone.js": "~0.14.3", + "lil-gui": "^0.19.2" }, "devDependencies": { "@angular-builders/custom-webpack": "^17.0.2", diff --git a/firebird-ng/src/app/app.component.html b/firebird-ng/src/app/app.component.html index 11f67b0..a386dbf 100644 --- a/firebird-ng/src/app/app.component.html +++ b/firebird-ng/src/app/app.component.html @@ -1,5 +1,5 @@ - Worflow: + @@ -9,7 +9,8 @@ - + Configure + Display @@ -30,18 +31,21 @@ - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/firebird-ng/src/app/app.component.ts b/firebird-ng/src/app/app.component.ts index 876b5ac..7c590e1 100644 --- a/firebird-ng/src/app/app.component.ts +++ b/firebird-ng/src/app/app.component.ts @@ -1,6 +1,7 @@ import { Component } from '@angular/core'; import { RouterOutlet, RouterModule, Router} from '@angular/router'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import {BehaviorSubject, Subject} from "rxjs"; @Component({ selector: 'app-root', standalone: true, diff --git a/firebird-ng/src/app/app.routes.ts b/firebird-ng/src/app/app.routes.ts index da96e7d..ea4aab0 100644 --- a/firebird-ng/src/app/app.routes.ts +++ b/firebird-ng/src/app/app.routes.ts @@ -4,15 +4,14 @@ import {FileBrowserComponent} from "./file-browser/file-browser.component"; import {InputConfigComponent} from "./input-config/input-config.component"; export const routes: Routes = [ - // { path: '', redirectTo: '/display', pathMatch: 'full' }, + { path: '', redirectTo: '/display', pathMatch: 'full' }, { path: 'config', component: InputConfigComponent }, - { path: 'files', loadComponent: () => import('./file-browser/file-browser.component').then(m => m.FileBrowserComponent) }, { - path: '', + path: 'display', loadComponent: () => import('./main-display/main-display.component').then(m => m.MainDisplayComponent) }, ]; diff --git a/firebird-ng/src/app/eic-edm4hep-json-loader.ts b/firebird-ng/src/app/eic-edm4hep-json-loader.ts new file mode 100644 index 0000000..0f9b8aa --- /dev/null +++ b/firebird-ng/src/app/eic-edm4hep-json-loader.ts @@ -0,0 +1,694 @@ +import {PhoenixLoader} from "phoenix-event-display"; + + +/** + * Edm4hepJsonLoader for loading EDM4hep json dumps + */ +export class EicEdm4hepJsonLoader extends PhoenixLoader { + /** Event data loaded from EDM4hep JSON file */ + private rawEventData: any; + + /** Create Edm4hepJsonLoader */ + constructor() { + super(); + this.eventData = {}; + } + + /** Put raw EDM4hep JSON event data into the loader */ + setRawEventData(rawEventData: any) { + this.rawEventData = rawEventData; + } + + /** Process raw EDM4hep JSON event data into the Phoenix format */ + processEventData(): boolean { + Object.entries(this.rawEventData).forEach(([eventName, event]) => { + const oneEventData = { + Vertices: {}, + Tracks: {}, + Hits: {}, + CaloCells: {}, + CaloClusters: {}, + Jets: {}, + MissingEnergy: {}, + 'event number': this.getEventNumber(event), + 'run number': this.getRunNumber(event), + }; + + this.colorTracks(event); + + oneEventData.Vertices = this.getVertices(event); + oneEventData.Tracks = this.getTracks(event); + oneEventData.Hits = this.getHits(event); + oneEventData.CaloCells = this.getCells(event); + oneEventData.CaloClusters = this.getCaloClusters(event); + oneEventData.Jets = this.getJets(event); + oneEventData.MissingEnergy = this.getMissingEnergy(event); + + this.eventData[eventName] = oneEventData; + }); + + return true; + } + + /** Output event data in Phoenix compatible format */ + getEventData(): any { + return this.eventData; + } + + /** Return number of events */ + private getNumEvents(): number { + return Object.keys(this.rawEventData).length; + } + + /** Return run number (or 0, if not defined) */ + private getRunNumber(event: any): number { + if (!('EventHeader' in event)) { + return 0; + } + + const eventHeader = event['EventHeader']['collection']; + + if (!('runNumber' in eventHeader)) { + return eventHeader[0]['runNumber']; + } + + return 0; + } + + /** Return event number (or 0, if not defined) */ + private getEventNumber(event: any): number { + if (!('EventHeader' in event)) { + return 0; + } + + const eventHeader = event['EventHeader']['collection']; + + if (!('eventNumber' in eventHeader)) { + return eventHeader[0]['eventNumber']; + } + + return 0; + } + + /** Assign default color to Tracks*/ + private colorTracks(event: any) { + let recoParticles: any[]; + if ('ReconstructedParticles' in event) { + recoParticles = event['ReconstructedParticles']['collection']; + } else { + return; + } + + let mcParticles: any[]; + if ('Particle' in event) { + mcParticles = event['Particle']['collection']; + } else { + return; + } + + let mcRecoAssocs: any[]; + if ('MCRecoAssociations' in event) { + mcRecoAssocs = event['MCRecoAssociations']['collection']; + } else { + return; + } + + let tracks: any[]; + if ('EFlowTrack' in event) { + tracks = event['EFlowTrack']['collection']; + } else { + return; + } + + mcRecoAssocs.forEach((mcRecoAssoc: any) => { + const recoIndex = mcRecoAssoc['rec']['index']; + const mcIndex = mcRecoAssoc['sim']['index']; + + const pdgid = mcParticles[mcIndex]['PDG']; + const trackRefs = recoParticles[recoIndex]['tracks']; + + trackRefs.forEach((trackRef: any) => { + const track = tracks[trackRef['index']]; + if (Math.abs(pdgid) === 11) { + track['color'] = '00ff00'; + track['pid'] = 'electron'; + } else if (Math.abs(pdgid) === 22) { + track['color'] = 'ff0000'; + track['pid'] = 'photon'; + } else if (Math.abs(pdgid) === 211 || Math.abs(pdgid) === 111) { + track['color'] = 'a52a2a'; + track['pid'] = 'pion'; + } else if (Math.abs(pdgid) === 2212) { + track['color'] = '778899'; + track['pid'] = 'proton'; + } else if (Math.abs(pdgid) === 321) { + track['color'] = '5f9ea0'; + track['pid'] = 'kaon'; + } else { + track['color'] = '0000cd'; + track['pid'] = 'other'; + } + track['pdgid'] = pdgid; + }); + }); + } + + /** Return the vertices */ + private getVertices(event: any) { + const allVertices: { [key: string]: any[] } = {}; + + for (const collName in event) { + if (event[collName].constructor != Object) { + continue; + } + + const collDict = event[collName]; + + if (!('collType' in collDict)) { + continue; + } + + if (!('collection' in collDict)) { + continue; + } + + if (!(collDict['collType'] === 'edm4hep::VertexCollection')) { + continue; + } + + const vertices: any[] = []; + const rawVertices = collDict['collection']; + const vertexColor = this.randomColor(); + + rawVertices.forEach((rawVertex: any) => { + const position: any[] = []; + if ('position' in rawVertex) { + position.push(rawVertex['position']['x']); + position.push(rawVertex['position']['y']); + position.push(rawVertex['position']['z']); + } + + const vertex = { + pos: position, + size: 3, + color: '#' + vertexColor, + }; + vertices.push(vertex); + }); + + allVertices[collName] = vertices; + } + + return allVertices; + } + + /** Return tracks */ + private getTracks(event: any) { + const allTracks: { [key: string]: any[] } = {}; + + for (const collName in event) { + if (event[collName].constructor != Object) { + continue; + } + + const collDict = event[collName]; + + if (!('collType' in collDict)) { + continue; + } + + if (!(collDict['collType'] === 'edm4hep::TrackCollection')) { + continue; + } + + if (!('collection' in collDict)) { + continue; + } + + const rawTracks = collDict['collection']; + const electrons: any[] = []; + const photons: any[] = []; + const pions: any[] = []; + const protons: any[] = []; + const kaons: any[] = []; + const other: any[] = []; + + rawTracks.forEach((rawTrack: any) => { + const positions: any[] = []; + if ('trackerHits' in rawTrack) { + const trackerHitRefs = rawTrack['trackerHits']; + trackerHitRefs.forEach((trackerHitRef: any) => { + const trackerHits = this.getCollByID( + event, + trackerHitRef['collectionID'], + ); + const trackerHit = trackerHits[trackerHitRef['index']]; + positions.push([ + trackerHit['position']['x'], + trackerHit['position']['y'], + trackerHit['position']['z'], + ]); + }); + } + if ('trackStates' in rawTrack && positions.length === 0) { + const trackStates = rawTrack['trackStates']; + trackStates.forEach((trackState: any) => { + if ('referencePoint' in trackState) { + positions.push([ + trackState['referencePoint']['x'], + trackState['referencePoint']['y'], + trackState['referencePoint']['z'], + ]); + } + }); + } + + let trackColor = '0000cd'; + if ('color' in rawTrack) { + trackColor = rawTrack['color']; + } + + const track = { + pos: positions, + color: trackColor, + }; + + if ('pid' in rawTrack) { + if (rawTrack['pid'] == 'electron') { + electrons.push(track); + } else if (rawTrack['pid'] == 'photon') { + photons.push(track); + } else if (rawTrack['pid'] == 'pion') { + pions.push(track); + } else if (rawTrack['pid'] == 'proton') { + protons.push(track); + } else if (rawTrack['pid'] == 'kaon') { + kaons.push(track); + } else { + other.push(track); + } + } else { + other.push(track); + } + }); + + allTracks[collName + ' | Electrons'] = electrons; + allTracks[`${collName} | Photons`] = photons; + allTracks[collName + ' | Pions'] = pions; + allTracks[collName + ' | Protons'] = protons; + allTracks[collName + ' | Kaons'] = kaons; + allTracks[collName + ' | Other'] = other; + } + + return allTracks; + } + + /** Not implemented */ + private getHits(event: any) { + const allHits: { [key: string]: any[] } = {}; + + for (const collName in event) { + if (event[collName].constructor != Object) { + continue; + } + + const collDict = event[collName]; + + if (!('collType' in collDict)) { + continue; + } + + if (!collDict['collType'].includes('edm4hep::')) { + continue; + } + + if (!collDict['collType'].includes('TrackerHitCollection')) { + continue; + } + + if (!('collection' in collDict)) { + continue; + } + + const rawHits = collDict['collection']; + const hits: any[] = []; + const hitColor = this.randomColor(); + + rawHits.forEach((rawHit: any) => { + const position: any[] = []; + if ('position' in rawHit) { + position.push(rawHit['position']['x']); + position.push(rawHit['position']['y']); + position.push(rawHit['position']['z']); + } + + const hit = { + type: 'CircularPoint', + pos: position, + color: '#' + hitColor, + size: 20, + }; + hits.push(hit); + }); + + allHits[collName] = hits; + } + + return allHits; + } + + /** Returns the cells */ + private getCells(event: any) { + const allCells: { [key: string]: any[] } = {}; + + for (const collName in event) { + if (event[collName].constructor != Object) { + continue; + } + + const collDict = event[collName]; + + if (!('collType' in collDict)) { + continue; + } + + if (!collDict['collType'].includes('edm4hep::')) { + continue; + } + + if (!collDict['collType'].includes('CalorimeterHitCollection')) { + continue; + } + + if (!('collection' in collDict)) { + continue; + } + + const rawCells = collDict['collection']; + const cells: any[] = []; + + // Find smallest distance between cell centers and use it as cell size + let drmin = 1e9; + for (let i = 0; i < 1e4; ++i) { + const j = Math.floor(Math.random() * rawCells.length); + const k = Math.floor(Math.random() * rawCells.length); + if (j === k) { + continue; + } + + const dx2 = Math.pow( + rawCells[j].position.x - rawCells[k].position.x, + 2, + ); + const dy2 = Math.pow( + rawCells[j].position.y - rawCells[k].position.y, + 2, + ); + const dz2 = Math.pow( + rawCells[j].position.z - rawCells[k].position.z, + 2, + ); + const dr = Math.sqrt(dx2 + dy2 + dz2); + + if (dr < drmin) { + drmin = dr; + } + } + const cellSide = + Math.floor(drmin) * 0.1 > 1 ? Math.floor(drmin) * 0.1 : 1; + const cellsHue = Math.floor(Math.random() * 358); + + rawCells.forEach((rawCell: any) => { + const x = rawCell.position.x; + const y = rawCell.position.y; + const z = rawCell.position.z; + + const r = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2) + Math.pow(z, 2)); + const rho = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); + const eta = Math.asinh(z / rho); + const phi = Math.acos(x / rho) * Math.sign(y); + const cellLightness = this.valToLightness(rawCell.energy, 1e-3, 1); + const cellOpacity = this.valToOpacity(rawCell.energy, 1e-3, 1); + + const cell = { + eta: eta, + phi: phi, + energy: rawCell.energy, + radius: r, + side: cellSide, + length: cellSide, // expecting cells in multiple layers + color: '#' + this.convHSLtoHEX(cellsHue, 90, cellLightness), + opacity: cellOpacity, + }; + cells.push(cell); + }); + + allCells[collName] = cells; + } + + return allCells; + } + + /** Return Calo clusters */ + private getCaloClusters(event: any) { + const allClusters: { [key: string]: any[] } = {}; + + for (const collName in event) { + if (event[collName].constructor != Object) { + continue; + } + + const collDict = event[collName]; + + if (!('collType' in collDict)) { + continue; + } + + if (!(collDict['collType'] === 'edm4hep::ClusterCollection')) { + continue; + } + + if (!('collection' in collDict)) { + continue; + } + + const rawClusters = collDict['collection']; + const clusters: any[] = []; + + rawClusters.forEach((rawCluster: any) => { + const x = rawCluster.position.x; + const y = rawCluster.position.y; + const z = rawCluster.position.z; + + const r = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2) + Math.pow(z, 2)); + const rho = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); + const eta = Math.asinh(z / rho); + const phi = Math.acos(x / rho) * Math.sign(y); + + const cluster = { + eta: eta, + phi: phi, + energy: rawCluster.energy * 100, + radius: r, + side: 40, + }; + clusters.push(cluster); + }); + + allClusters[collName] = clusters; + } + + return allClusters; + } + + /** Return jets */ + private getJets(event: any) { + const allJets: { [key: string]: any[] } = {}; + + for (const collName in event) { + if (event[collName].constructor != Object) { + continue; + } + + const collDict = event[collName]; + + if (!('collType' in collDict)) { + continue; + } + + if ( + !(collDict['collType'] === 'edm4hep::ReconstructedParticleCollection') + ) { + continue; + } + + if (!(collName.includes('Jet') || collName.includes('jet'))) { + continue; + } + + if (!('collection' in collDict)) { + continue; + } + + const jets: any[] = []; + const rawJets = collDict['collection']; + + rawJets.forEach((rawJet: any) => { + if (!('momentum' in rawJet)) { + return; + } + if (!('energy' in rawJet)) { + return; + } + const px = rawJet['momentum']['x']; + const py = rawJet['momentum']['y']; + const pz = rawJet['momentum']['z']; + + const pt = Math.sqrt(Math.pow(px, 2) + Math.pow(py, 2)); + const eta = Math.asinh(pz / pt); + const phi = Math.acos(px / pt) * Math.sign(py); + + const jet = { + eta: eta, + phi: phi, + energy: 1000 * rawJet.energy, + }; + jets.push(jet); + }); + allJets[collName] = jets; + } + + return allJets; + } + + /** Return missing energy */ + private getMissingEnergy(event: any) { + const allMETs: { [key: string]: any[] } = {}; + + for (const collName in event) { + if (event[collName].constructor != Object) { + continue; + } + + const collDict = event[collName]; + + if (!('collType' in collDict)) { + continue; + } + + if ( + !(collDict['collType'] === 'edm4hep::ReconstructedParticleCollection') + ) { + continue; + } + + if (!(collName.includes('Missing') || collName.includes('missing'))) { + continue; + } + + if (!('collection' in collDict)) { + continue; + } + + const METs: any[] = []; + const rawMETs = collDict['collection']; + const METColor = '#ff69b4'; + + rawMETs.forEach((rawMET: any) => { + if (!('momentum' in rawMET)) { + return; + } + if (!('energy' in rawMET)) { + return; + } + const px = rawMET['momentum']['x']; + const py = rawMET['momentum']['y']; + const pz = rawMET['momentum']['z']; + + const p = Math.sqrt( + Math.pow(px, 2) + Math.pow(py, 2) + Math.pow(pz, 2), + ); + const etx = (rawMET['energy'] * px) / p; + const ety = (rawMET['energy'] * py) / p; + + const MET = { + etx: etx * 100, + ety: ety * 100, + color: '#ff69b4', + }; + METs.push(MET); + }); + allMETs[collName] = METs; + } + + return allMETs; + } + + /** Return a random colour */ + private randomColor() { + return Math.floor(Math.random() * 16777215) + .toString(16) + .padStart(6, '0') + .toUpperCase(); + } + + /** Helper conversion of HSL to hexadecimal */ + private convHSLtoHEX(h: number, s: number, l: number): string { + l /= 100; + const a = (s * Math.min(l, 1 - l)) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); + }; + + return `${f(0)}${f(8)}${f(4)}`; + } + + /** Return a lightness value from the passed number and range */ + private valToLightness(v: number, min: number, max: number): number { + let lightness = 80 - ((v - min) * 65) / (max - min); + if (lightness < 20) { + lightness = 20; + } + if (lightness > 85) { + lightness = 85; + } + + return lightness; + } + + /** Return a opacity value from the passed number and range */ + private valToOpacity(v: number, min: number, max: number): number { + let opacity = 0.5 + ((v - min) * 0.65) / (max - min); + if (opacity < 0.5) { + opacity = 0.5; + } + if (opacity > 1) { + opacity = 1; + } + + return opacity; + } + + /** Get the required collection */ + private getCollByID(event: any, id: number) { + for (const collName in event) { + if (event[collName].constructor != Object) { + continue; + } + + const collDict = event[collName]; + + if (!('collID' in collDict)) { + continue; + } + + if (collDict['collID'] === id) { + return collDict['collection']; + } + } + } +} diff --git a/firebird-ng/src/app/game-controller.service.spec.ts b/firebird-ng/src/app/game-controller.service.spec.ts new file mode 100644 index 0000000..f59ab7e --- /dev/null +++ b/firebird-ng/src/app/game-controller.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { GameControllerService } from './game-controller.service'; + +describe('GameControllerService', () => { + let service: GameControllerService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(GameControllerService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/firebird-ng/src/app/game-controller.service.ts b/firebird-ng/src/app/game-controller.service.ts new file mode 100644 index 0000000..71c921d --- /dev/null +++ b/firebird-ng/src/app/game-controller.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@angular/core'; +import * as THREE from "three"; +import {BehaviorSubject, Observable, Subject} from "rxjs"; + + +export enum ControllerButtonIndexes { + ButtonA = 0, + ButtonB = 1, + ButtonX = 2, + ButtonY = 3, + ButtonLB = 4, + ButtonRB = 5, + ButtonLT = 6, + ButtonRT = 7, + Select = 8, + Start = 9, +} + + +@Injectable({ + providedIn: 'root' +}) +export class GameControllerService { + + private xAxisSubject = new BehaviorSubject(0); + public xAxisChanged = this.xAxisSubject.asObservable(); + public xAxis = 0; + + private yAxisSubject = new BehaviorSubject(0); + private yAxisChanged = this.yAxisSubject.asObservable(); + public yAxis: number = 0; + public buttons: GamepadButton[] = []; + public prevButtons: GamepadButton[] = []; + + private buttonASubject = new Subject(); + public buttonAPressed = this.buttonASubject.asObservable(); + public buttonA: GamepadButton = {pressed: false, touched: false, value: 0}; + private buttonBSubject = new Subject(); + public buttonBPressed = this.buttonBSubject.asObservable(); + public buttonB: GamepadButton = {pressed: false, touched: false, value: 0}; + private buttonXSubject = new Subject(); + public buttonXPressed = this.buttonXSubject.asObservable(); + public buttonX: GamepadButton = {pressed: false, touched: false, value: 0}; + private buttonYSubject = new Subject(); + public buttonYPressed = this.buttonYSubject.asObservable(); + public buttonY: GamepadButton = {pressed: false, touched: false, value: 0}; + private buttonLBSubject = new Subject(); + public buttonLBPressed = this.buttonLBSubject.asObservable(); + public buttonLB: GamepadButton = {pressed: false, touched: false, value: 0}; + private buttonRBSubject = new Subject(); + public buttonRBPressed = this.buttonRBSubject.asObservable(); + public buttonRB: GamepadButton = {pressed: false, touched: false, value: 0}; + private buttonLTSubject = new Subject(); + public buttonLTPressed = this.buttonLTSubject.asObservable(); + public buttonLT: GamepadButton = {pressed: false, touched: false, value: 0}; + private buttonRTSubject = new Subject(); + public buttonRTPressed = this.buttonRTSubject.asObservable(); + public buttonRT: GamepadButton = {pressed: false, touched: false, value: 0}; + private buttonSelectSubject = new Subject(); + public buttonSelectPressed = this.buttonSelectSubject.asObservable(); + public buttonSelect: GamepadButton = {pressed: false, touched: false, value: 0}; + private buttonStartSubject = new Subject(); + public buttonStartPressed = this.buttonStartSubject.asObservable(); + public buttonStart: GamepadButton = {pressed: false, touched: false, value: 0}; + + public activeGamepad: Gamepad|null = null; + + animationLoopHandler () { + + const epsilon = 0.01; + const gamepads = navigator.getGamepads(); + for (const gamepad of gamepads) { + if (gamepad) { + + this.activeGamepad = gamepad; + // Example: Using left joystick to control OrbitControls + // Axis 0: Left joystick horizontal (left/right) + // Axis 1: Left joystick vertical (up/down) + this.xAxis = gamepad.axes[0]; + this.yAxis = gamepad.axes[1]; + + if(Math.abs(this.xAxis - this.xAxisSubject.value) > epsilon) { + this.xAxisSubject.next(this.xAxis); + } + + if(Math.abs(this.yAxis - this.yAxisSubject.value) > epsilon) { + this.yAxisSubject.next(this.yAxis); + } + + this.buttonA = gamepad.buttons[ControllerButtonIndexes.ButtonA]; + this.buttonB = gamepad.buttons[ControllerButtonIndexes.ButtonB]; + this.buttonX = gamepad.buttons[ControllerButtonIndexes.ButtonX]; + this.buttonY = gamepad.buttons[ControllerButtonIndexes.ButtonY]; + this.buttonLB = gamepad.buttons[ControllerButtonIndexes.ButtonLB]; + this.buttonRB = gamepad.buttons[ControllerButtonIndexes.ButtonRB]; + this.buttonLT = gamepad.buttons[ControllerButtonIndexes.ButtonLT]; + this.buttonRT = gamepad.buttons[ControllerButtonIndexes.ButtonRT]; + this.buttonSelect = gamepad.buttons[ControllerButtonIndexes.Select]; + this.buttonStart = gamepad.buttons[ControllerButtonIndexes.Start]; + + if (this.buttonA.pressed !== this.buttonASubject.observed) this.buttonASubject.next(this.buttonA.pressed); + if (this.buttonB.pressed !== this.buttonBSubject.observed) this.buttonASubject.next(this.buttonB.pressed); + if (this.buttonX.pressed !== this.buttonXSubject.observed) this.buttonASubject.next(this.buttonX.pressed); + if (this.buttonY.pressed !== this.buttonYSubject.observed) this.buttonASubject.next(this.buttonY.pressed); + if (this.buttonLB.pressed !== this.buttonLBSubject.observed) this.buttonASubject.next(this.buttonLB.pressed); + if (this.buttonRB.pressed !== this.buttonRBSubject.observed) this.buttonASubject.next(this.buttonRB.pressed); + if (this.buttonLT.pressed !== this.buttonLTSubject.observed) this.buttonASubject.next(this.buttonLT.pressed); + if (this.buttonRT.pressed !== this.buttonRTSubject.observed) this.buttonASubject.next(this.buttonRT.pressed); + if (this.buttonSelect.pressed !== this.buttonSelectSubject.observed) this.buttonASubject.next(this.buttonSelect.pressed); + if (this.buttonStart.pressed !== this.buttonStartSubject.observed) this.buttonASubject.next(this.buttonStart.pressed); + + break; // Only use the first connected gamepad + } + } + }; + + constructor() { + // Run it on contruction so if we have an active controller we set up values + this.animationLoopHandler(); + } +} diff --git a/firebird-ng/src/app/geometry-prettifiers/calorimetry.prettifier.ts b/firebird-ng/src/app/geometry-prettifiers/calorimetry.prettifier.ts new file mode 100644 index 0000000..3191349 --- /dev/null +++ b/firebird-ng/src/app/geometry-prettifiers/calorimetry.prettifier.ts @@ -0,0 +1,134 @@ +import * as THREE from "three"; +import { + createOutline, + disposeNode, + disposeOriginalMeshesAfterMerge, + findObject3DNodes, + pruneEmptyNodes +} from "../utils/three.utils"; +import {mergeMeshList, MergeResult} from "../utils/three-geometry-merge"; +import {ColorRepresentation} from "three/src/math/Color"; + + +export class CalorimetryGeometryPrettifier { + + doEndcapEcalN(node: THREE.Mesh) { + let crystals = findObject3DNodes(node, "**/crystal_vol_0", "Mesh").nodes; + //console.log(crystals); + + // Merge crystals together + let mergeResult: MergeResult = mergeMeshList(crystals, node, "crystals"); + disposeOriginalMeshesAfterMerge(mergeResult) + + // outline crystals + createOutline(mergeResult.mergedMesh); + + // Support + let innerSupport = findObject3DNodes(node, "**/inner_support*", "Mesh").nodes[0]; + let ring = findObject3DNodes(node, "**/ring*", "Mesh").nodes[0]; + const supportMaterial = new THREE.MeshStandardMaterial({ + color: 0x19a5f5, + roughness: 0.7, + metalness: 0.869, + transparent: true, + opacity: 1, + side: THREE.DoubleSide + }); + + mergeResult = mergeMeshList([innerSupport, ring], node, "support", supportMaterial); + disposeOriginalMeshesAfterMerge(mergeResult); + + + // Cleanup. Removing useless nodes that were left without geometries speeds up overall rendering + pruneEmptyNodes(node); + } + + /** + * DRICH + * + * ---------------------------------------------------- + * */ + doDRICH(node: THREE.Mesh) { + + let sensors = findObject3DNodes(node, "**/*cooling*", "Mesh").nodes; + let aeroGel = findObject3DNodes(node, "**/*aerogel*", "Mesh").nodes[0]; + sensors.push(aeroGel); + let mergeResult = mergeMeshList(sensors, node, "sensors"); + disposeOriginalMeshesAfterMerge(mergeResult) + + let filter = findObject3DNodes(node, "**/*filter*", "Mesh").nodes[0]; + filter.visible = false; + let airGap = findObject3DNodes(node, "**/*airgap*", "Mesh").nodes[0]; + airGap.visible = false; + + + let mirrors = findObject3DNodes(node, "**/*mirror*", "Mesh").nodes; + const mirrorsMaterial = new THREE.MeshPhysicalMaterial({ + color: 0xfafafa, + roughness: 0.1, + metalness: 0.2, + reflectivity: 1.5, + clearcoat: 1, + depthTest: true, + depthWrite: true, + transparent: true, + envMapIntensity: 0.8, + opacity: 1, + side: THREE.DoubleSide + }); + + // Merge crystals together + mergeResult = mergeMeshList(mirrors, node, "mirrors", mirrorsMaterial); + disposeOriginalMeshesAfterMerge(mergeResult) + + // Cleanup. Removing useless nodes that were left without geometries speeds up overall rendering + pruneEmptyNodes(node); + } + + /** + * DIRC + * + * ---------------------------------------------------- + * */ + doDIRC(node: THREE.Mesh) { + + let bars = findObject3DNodes(node, "**/*box*", "Mesh").nodes; + let prisms = findObject3DNodes(node, "**/*prism*", "Mesh").nodes; + const barsPrisms = bars.concat(prisms); + + const barMat = new THREE.MeshPhysicalMaterial({ + color: 0xe5ba5d, + metalness: .9, + roughness: .05, + envMapIntensity: 0.9, + clearcoat: 1, + transparent: true, + //transmission: .60, + opacity: .6, + reflectivity: 0.2, + //refr: 0.985, + ior: 0.9, + side: THREE.DoubleSide, + }); + + + let mergeResult = mergeMeshList(barsPrisms, node, "barsPrisms", barMat); + disposeOriginalMeshesAfterMerge(mergeResult); + + createOutline(mergeResult.mergedMesh); + + // Rails + let rails = findObject3DNodes(node, "**/*rail*", "Mesh").nodes; + mergeResult = mergeMeshList(rails, node, "rails"); + disposeOriginalMeshesAfterMerge(mergeResult); + createOutline(mergeResult.mergedMesh); + + // MCPs + let mcps = findObject3DNodes(node, "**/*mcp*", "Mesh").nodes; + mergeResult = mergeMeshList(mcps, node, "mcps"); + disposeOriginalMeshesAfterMerge(mergeResult); + + // Cleanup. Removing useless nodes that were left without geometries speeds up overall rendering + pruneEmptyNodes(node); + } +} diff --git a/firebird-ng/src/app/geometry.service.ts b/firebird-ng/src/app/geometry.service.ts index 0c21944..698781c 100644 --- a/firebird-ng/src/app/geometry.service.ts +++ b/firebird-ng/src/app/geometry.service.ts @@ -1,3 +1,4 @@ + import {Injectable} from '@angular/core'; //import { openFile } from '../../../jsroot/core.mjs'; //import * as ROOT from '../../../jsroot/build; @@ -5,185 +6,69 @@ import {openFile} from 'jsrootdi'; import { analyzeGeoNodes, editGeoNodes, - findGeoManager, findGeoNodes, findSingleGeoNode, geoBITS, + findGeoManager, findGeoNodes, findSingleGeoNode, GeoAttBits, GeoNodeEditRule, printAllGeoBitsStatus, - PruneRuleActions, removeGeoNode, testGeoBit + EditActions, removeGeoNode, testGeoBit } from './utils/cern-root.utils'; import {build} from 'jsrootdi/geom'; +import {BehaviorSubject} from "rxjs"; +import {RootGeometryProcessor} from "./root-geometry.processor"; +import {UserConfigService} from "./user-config.service"; - -export class DetectorGeometryFineTuning { - namePattern: string = ""; - editRules: GeoNodeEditRule[] = []; -} - - -function pruneTopLevelDetectors(geoManager: any, removeNames: string[]): any { - const volume = geoManager.fMasterVolume === undefined ? geoManager.fVolume : geoManager.fMasterVolume; - const nodes: any[] = volume?.fNodes?.arr ?? []; - let removedNodes: any[] = []; - - // Don't have nodes? Have problems? - if(!nodes.length) { - return {nodes, removedNodes}; - } - - // Collect nodes to remove - for(let node of nodes) { - let isRemoving = removeNames.some(substr => node.fName.startsWith(substr)) - if(isRemoving) { - removedNodes.push(node); - } - } - - // Now remove nodes - for(let node of removedNodes) { - removeGeoNode(node); - } - - return {nodes, removedNodes} -} - - +// constants.ts +export const DEFAULT_GEOMETRY = 'epic-central-optimized'; @Injectable({ providedIn: 'root' }) export class GeometryService { - /** - * Detectors (top level TGeo nodes) to be removed. - * (!) startsWith function is used for filtering (aka: detector.fName.startsWith(removeDetectorNames[i]) ... ) - */ - removeDetectorNames: string[] = [ - "Lumi", - "Magnet", - "B0", - "B1", - "B2", - "Q0", - "Q1", - "Q2", - "BeamPipe", - "Pipe", - "ForwardOffM", - "Forward", - "Backward", - "Vacuum", - "SweeperMag", - "AnalyzerMag", - "ZDC", - "LFHCAL", - "HcalFarForward" - ]; - - subDetectorsRules: DetectorGeometryFineTuning[] = [ - { - namePattern: "*/EcalBarrelScFi*", - editRules: [ - {pattern: "*/fiber_grid*", prune:PruneRuleActions.Remove}, - ] - }, - { - namePattern: "*/EcalBarrelImaging*", - editRules: [ - {pattern: "*/stav*", prune:PruneRuleActions.RemoveChildren}, - ] - }, - { - namePattern: "*/DRICH*", - editRules: [ - {pattern: "*/DRICH_cooling*", prune:PruneRuleActions.RemoveSiblings}, - ] - }, - { - namePattern: "*/EcalEndcapN*", - editRules: [ - {pattern: "*/crystal*", prune:PruneRuleActions.RemoveSiblings}, - ] - }, - { - namePattern: "*/HcalEndcapPInsert_23*", - editRules: [ - {pattern: "*/*layer*slice1_*", prune:PruneRuleActions.RemoveSiblings}, - ] - }, - { - namePattern: "*/HcalBarrel*", - editRules: [ - {pattern: "*/Tile*", prune:PruneRuleActions.Remove}, - {pattern: "*/ChimneyTile*", prune:PruneRuleActions.Remove}, - ] - }, - { - namePattern: "*/EndcapTOF*", - editRules: [ - {pattern: "*/suppbar*", prune:PruneRuleActions.Remove}, - {pattern: "*/component*3", prune:PruneRuleActions.RemoveSiblings}, - ] - } - - ] - - constructor() { + rootGeometryProcessor = new RootGeometryProcessor(); + + constructor(private settings: UserConfigService) { + } - async loadEicGeometry() { + async loadGeometry() { //let url: string = 'assets/epic_pid_only.root'; //let url: string = 'https://eic.github.io/epic/artifacts/tgeo/epic_dirc_only.root'; - let url: string = 'https://eic.github.io/epic/artifacts/tgeo/epic_full.root'; + // let url: string = 'https://eic.github.io/epic/artifacts/tgeo/epic_full.root'; // >oO let objectName = 'default'; - console.log(`Loading file ${url}`) + const url = this.settings.selectedGeometry.value !== DEFAULT_GEOMETRY? + this.settings.selectedGeometry.value: + 'https://eic.github.io/epic/artifacts/tgeo/epic_full.root'; - console.time('Open root file'); + console.time('[GeoSrv]: Total load geometry time'); + console.log(`[GeoSrv]: Loading file ${url}`) + + console.time('[GeoSrv]: Open root file'); const file = await openFile(url); // >oO debug console.log(file); - console.timeEnd('Open root file'); + console.timeEnd('[GeoSrv]: Open root file'); - console.time('Reading geometry from file'); + console.time('[GeoSrv]: Reading geometry from file'); const rootGeoManager = await findGeoManager(file) // await file.readObject(objectName); - // >oO console.log(geoManager); - console.timeEnd('Reading geometry from file'); - - // Getting main detector nodes - let result = pruneTopLevelDetectors(rootGeoManager, this.removeDetectorNames); - console.log("Filtered top level detectors: ", result); - + // >oO + console.log(rootGeoManager); + console.timeEnd('[GeoSrv]: Reading geometry from file'); - // >oO analyzeGeoNodes(rootGeoManager, 1); - // Now we go with the fine tuning each detector - for(let detector of this.subDetectorsRules) { - let topDetNode = findSingleGeoNode(rootGeoManager, detector.namePattern, 1); - console.log(`Processing ${topDetNode}`); - if(!topDetNode) { - continue; - } - console.time(`Process sub-detector: ${detector.namePattern}`); - for(let rule of detector.editRules) { - editGeoNodes(topDetNode, [rule]) - } - console.timeEnd(`Process sub-detector: ${detector.namePattern}`); - } + console.time('[GeoSrv]: Root geometry pre-processing'); + this.rootGeometryProcessor.process(rootGeoManager); + console.time('[GeoSrv]: Root geometry pre-processing'); - console.log(`Done processing ${this.subDetectorsRules.length} detectors`); - - console.log(`---- DETECTOR ANALYSIS ----`); analyzeGeoNodes(rootGeoManager, 1); - console.log(`---- END DETECTOR ANALYSIS ----`); - - //analyzeGeoNodes(geoManager, 1); - // return {rootGeoManager: null, rootObject3d: null}; // - console.time('Build geometry'); - let rootObject3d = build(rootGeoManager, { numfaces: 500000000, numnodes: 50000000, instancing:1, dflt_colors: false, vislevel: 100, doubleside:true, transparency:true}); - console.timeEnd('Build geometry'); + console.time('[GeoSrv]: Build geometry'); + let rootObject3d = build(rootGeoManager, { numfaces: 500000000, numnodes: 50000000, instancing:-1, dflt_colors: false, vislevel: 100, doubleside:true, transparency:true}); + console.timeEnd('[GeoSrv]: Build geometry'); // >oO console.log(geo); + console.timeEnd('[GeoSrv]: Total load geometry time'); return {rootGeoManager, rootObject3d}; } } diff --git a/firebird-ng/src/app/input-config/input-config.component.html b/firebird-ng/src/app/input-config/input-config.component.html index f11c577..a6f1654 100644 --- a/firebird-ng/src/app/input-config/input-config.component.html +++ b/firebird-ng/src/app/input-config/input-config.component.html @@ -1,55 +1,123 @@ - Event display source control - - - - Geometry source - - EIC ePIC Central Detector optimized - epic_bhcal.root - epic_calorimeters.root - epic_craterlake.root - epic_craterlake_10x100.root - epic_craterlake_10x275.root - epic_craterlake_18x110_Au.root - epic_craterlake_18x275.root - epic_craterlake_5x41.root - epic_craterlake_material_map.root - epic_craterlake_no_bhcal.root - epic_craterlake_tracking_only.root - epic_dirc_only.root - epic_drich_only.root - epic_forward_detectors.root - epic_forward_detectors_with_inserts.root - epic_full.root - epic_imaging_only.root - epic_inner_detector.root - epic_ip6.root - epic_ip6_extended.root - epic_lfhcal_only.root - epic_lfhcal_with_insert.root - epic_mrich_only.root - epic_pfrich_only.root - epic_pid_only.root - epic_tof_endcap_only.root - epic_tof_only.root - epic_vertex_only.root - epic_zdc_lyso_sipm.root - epic_zdc_sipm_on_tile_only.root - + Configure geometry pipeline + + + + + + + Geometry Source + + Geometry file sources are taken from https://eic.github.io/epic/ + + EIC ePIC Central Detector optimized + epic_bhcal.root + epic_calorimeters.root + epic_craterlake.root + epic_craterlake_10x100.root + epic_craterlake_10x275.root + epic_craterlake_18x110_Au.root + epic_craterlake_18x275.root + epic_craterlake_5x41.root + epic_craterlake_material_map.root + epic_craterlake_no_bhcal.root + epic_craterlake_tracking_only.root + epic_dirc_only.root + epic_drich_only.root + epic_forward_detectors.root + epic_forward_detectors_with_inserts.root + epic_full.root + epic_imaging_only.root + epic_inner_detector.root + epic_ip6.root + epic_ip6_extended.root + epic_lfhcal_only.root + epic_lfhcal_with_insert.root + epic_mrich_only.root + epic_pfrich_only.root + epic_pid_only.root + epic_tof_endcap_only.root + epic_tof_only.root + epic_vertex_only.root + epic_zdc_lyso_sipm.root + epic_zdc_sipm_on_tile_only.root + + + + Events Source + + + No events (may upload later) + Test events + DIRC optical (load DIRC only geometry) + Pyth8 All(300MeV+) DIC-CC 5x41 minQ2-100 5 events + Pyth8 All(200MeV+) DIC-NC 18x275 minQ2-1000 5 events + Pyth8 All(200MeV+) DIC-NC 18x275 minQ2-100 5 events + Pyth8 All(200MeV+) DIC-NC 18x275 minQ2-1 5 events + Pyth8 All(200MeV+) DIC-NC 10x100 minQ2-1000 5 events + Pyth8 All(200MeV+) DIC-NC 10x100 minQ2-100 5 events + Pyth8 All(200MeV+) DIC-NC 10x100 minQ2-1 5 events + Pyth8 All(200MeV+) DIC-NC 5x41 minQ2-100 5 events + + + + + - - Selected events - - Central detector - Far forward - + + + ROOT Geometry optimization + + Leave only central detector (Remove Beamline, Far Froward and Backward) + + + Enabled + + + + Optimize detectors (remove parts such as glue, pixels, some layers, etc) + + + Enabled + + + + + + WebGL Geometry optimization + + Merge geometries if possible + Merging geometries allows to significantly improve the performance by reducing the number of + drawcalls. + + + Enabled + + + + + + + + + + + + + + + + + + + DISPLAY + diff --git a/firebird-ng/src/app/input-config/input-config.component.scss b/firebird-ng/src/app/input-config/input-config.component.scss index e69de29..b42012e 100644 --- a/firebird-ng/src/app/input-config/input-config.component.scss +++ b/firebird-ng/src/app/input-config/input-config.component.scss @@ -0,0 +1,4 @@ +.card { + background-color: var(--phoenix-background-color-secondary); /* Using theme variables */ + color: var(--phoenix-text-color); +} diff --git a/firebird-ng/src/app/input-config/input-config.component.ts b/firebird-ng/src/app/input-config/input-config.component.ts index 16e3f1f..9871e1c 100644 --- a/firebird-ng/src/app/input-config/input-config.component.ts +++ b/firebird-ng/src/app/input-config/input-config.component.ts @@ -1,9 +1,10 @@ // event-display-source.component.ts import { Component, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup } from '@angular/forms'; -import { GeometryService } from '../geometry.service'; +import { FormBuilder, FormGroup, FormControl } from '@angular/forms'; +import { UserConfigService } from "../user-config.service"; import { ReactiveFormsModule } from '@angular/forms'; import {RouterLink} from '@angular/router'; +import {ConfigProperty} from "../utils/config-property"; @Component({ selector: 'app-input-config', @@ -14,27 +15,34 @@ import {RouterLink} from '@angular/router'; }) export class InputConfigComponent implements OnInit { - geoForm: FormGroup; + selectedGeometry = new FormControl(''); + selectedEventSource = new FormControl(''); + onlyCentralDetector: FormControl = new FormControl(true); + + constructor(private configService: UserConfigService) { + } - constructor(private fb: FormBuilder, private geometryService: GeometryService) { - this.geoForm = this.fb.group({ - selectedGeometry: ['eic geometry'], - geoOptEnabled: [false], - selectedGeoCutoff: ['Central detector'], - geoPostEnabled: [false] - }); - this.geoForm.valueChanges.subscribe(value => { - //this.geometryService.save(value); - console.log(value); - }); + bindConfigToControl(control: FormControl, config: ConfigProperty ) { + control.setValue(config.value, { emitEvent: false }) + control.valueChanges.subscribe( + value => { + if(value !== null) { + config.value=value; + } + } + ); + config.changes$.subscribe( + value => { + control.setValue(value, { emitEvent: false }) + } + ); } ngOnInit(): void { - // const savedSettings = this.geometryService.load(); - // if (savedSettings) { - // this.geoForm.setValue(savedSettings); - // } + //this.selectedGeometry.setValue(this.configService.selectedGeometry.value, { emitEvent: false }) + this.bindConfigToControl(this.selectedGeometry, this.configService.selectedGeometry); + this.bindConfigToControl(this.selectedEventSource, this.configService.eventSource); + this.bindConfigToControl(this.onlyCentralDetector, this.configService.onlyCentralDetector); } - } diff --git a/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.html b/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.html new file mode 100644 index 0000000..dcc864b --- /dev/null +++ b/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.html @@ -0,0 +1,208 @@ + + Import and export + + + Event data + + + + + + + Load .json + + + + + + + + Load .edm4hep.json + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Geometries + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Scene + + + + Save scene + + + + + + + + + + + + + + + + + + + + + Save OBJ + + + + + + + Close + + + diff --git a/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.scss b/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.scss new file mode 100644 index 0000000..2c3118e --- /dev/null +++ b/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.scss @@ -0,0 +1,68 @@ +.row { + justify-content: center; + margin: 1rem 0; +} + +.file-input { + width: 0.1px; + height: 0.1px; + opacity: 0; + overflow: hidden; + position: absolute; + z-index: -1; +} + +.file-input-button { + font-size: 1em; + font-weight: 700; + color: white; + background-color: #6eaece; + padding: 1em 0.5em; + border: none; + border-radius: 10px; + width: 8rem; + margin: 0 0.4rem; + + &.load-export { + padding: 0.8em 0.2em; + width: 7rem; + + img { + max-height: 1.2em; + } + } + + &.export-button { + background-color: #cb7133; + + &:hover { + background-color: #ad5b2d; + } + } + + img { + max-height: 2em; + display: block; + width: 100%; + } + + &:hover { + background-color: #118ab2; + } +} + +#exportScene { + background-color: #5bd99e; + + &:hover { + background-color: #05c292; + } +} + +#importScene { + background-color: #ecc25e; + + &:hover { + background-color: #d6b44d; + } +} diff --git a/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.test.ts b/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.test.ts new file mode 100644 index 0000000..3c18aaa --- /dev/null +++ b/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.test.ts @@ -0,0 +1,189 @@ +import JSZip from 'jszip'; +import fetch from 'node-fetch'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IOOptionsDialogComponent } from './io-options-dialog.component'; +import { MatDialogRef } from '@angular/material/dialog'; +import { EventDisplayService } from '../../../../services/event-display.service'; +import { PhoenixUIModule } from '../../../phoenix-ui.module'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +const mockFileList = (files: File[]): FileList => { + const fileList: FileList = { + length: files.length, + item: (index) => files[index], + [Symbol.iterator]: files[Symbol.iterator], + }; + Object.assign(fileList, files); + + return fileList; +}; + +describe('IoOptionsDialogComponent', () => { + let component: IOOptionsDialogComponent; + let fixture: ComponentFixture; + + const mockDialogRef = { + close: jest.fn(), + }; + + const mockEventDisplayService = { + buildEventDataFromJSON: jest.fn(), + parsePhoenixEvents: jest.fn(), + parseOBJGeometry: jest.fn(), + parsePhoenixDisplay: jest.fn(), + parseGLTFGeometry: jest.fn(), + exportPhoenixDisplay: jest.fn(), + exportToOBJ: jest.fn(), + getInfoLogger: () => ({ + add: jest.fn(), + }), + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [BrowserAnimationsModule, PhoenixUIModule], + providers: [ + { + provide: EventDisplayService, + useValue: mockEventDisplayService, + }, + { + provide: MatDialogRef, + useValue: mockDialogRef, + }, + ], + declarations: [IOOptionsDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(IOOptionsDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close the IOOptionsDialog', () => { + component.onClose(); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + describe('handleFileInput', () => { + beforeEach(() => { + jest.spyOn(component, 'handleFileInput').mockImplementation(() => {}); + }); + + it('should handle JiveXML event data input', async () => { + await fetch( + 'https://raw.githubusercontent.com/HSF/phoenix/main/packages/phoenix-ng/projects/phoenix-app/src/assets/files/JiveXML/JiveXML_336567_2327102923.xml', + ) + .then((res) => res.text()) + .then((res) => { + const files = mockFileList([ + new File([res], 'testfile.xml', { type: 'text/xml' }), + ]); + component.handleJiveXMLDataInput(files); + expect(component.readTextFile).toHaveBeenCalled(); + }); + }, 30000); + + describe('handleFileInput sync', () => { + afterEach(() => { + expect(component.readTextFile).toHaveBeenCalled(); + }); + + it('should log error for wrong file', () => { + const filesWrong = mockFileList([ + new File(['test data'], 'testfile.xml', { + type: 'text/xml', + }), + ]); + component.handleJSONEventDataInput(filesWrong); + }); + + it('should handle JSON event data input', () => { + const files = mockFileList([ + new File(['{}'], 'testfile.json', { + type: 'application/json', + }), + ]); + component.handleJSONEventDataInput(files); + }); + + it('should handle OBJ file input', () => { + const files = mockFileList([ + new File(['test data'], 'testfile.obj', { + type: 'text/plain', + }), + ]); + component.handleOBJInput(files); + }); + + it('should handle scene file input', () => { + const files = mockFileList([ + new File(['test data'], 'testfile.phnx', { + type: 'text/plain', + }), + ]); + component.handleSceneInput(files); + }); + + it('should handle glTF file input', () => { + const files = mockFileList([ + new File(['{}'], 'testfile.gltf', { + type: 'application/json', + }), + ]); + component.handleGLTFInput(files); + }); + + it('should handle phoenix file input', () => { + const files = mockFileList([ + new File(['{}'], 'testfile.phnx', { + type: 'application/json', + }), + ]); + component.handlePhoenixInput(files); + }); + }); + }); + + it('should handle zipped event data', async () => { + const zip = new JSZip(); + zip.file('test_data.json', '{ "event": null }'); + const jivexmlData = await fetch( + 'https://raw.githubusercontent.com/HSF/phoenix/main/packages/phoenix-ng/projects/phoenix-app/src/assets/files/JiveXML/JiveXML_336567_2327102923.xml', + ); + zip.file('test_data.xml', jivexmlData.text()); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const files = mockFileList([ + new File([zipBlob], 'test_data.zip', { type: 'application/zip' }), + ]); + component.handleZipEventDataInput(files); + }, 30000); + + it('should handle ig event data', async () => { + const ig = new JSZip(); + ig.file('test_data', '{}'); + const igBlob = await ig.generateAsync({ type: 'blob' }); + const files = mockFileList([new File([igBlob], 'test_data.ig')]); + component.handleZipEventDataInput(files); + }); + + it('should save scene', () => { + component.saveScene(); + expect(mockEventDisplayService.exportPhoenixDisplay).toHaveBeenCalled(); + }); + + it('should export to OBJ', () => { + component.exportOBJ(); + expect(mockEventDisplayService.exportToOBJ).toHaveBeenCalled(); + }); +}); diff --git a/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.ts b/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.ts new file mode 100644 index 0000000..07f2bc2 --- /dev/null +++ b/firebird-ng/src/app/main-display/io-options/io-options-dialog/io-options-dialog.component.ts @@ -0,0 +1,214 @@ +import { OnInit, Component, Input } from '@angular/core'; +import { + CMSLoader, + JiveXMLLoader, + readZipFile, + Edm4hepJsonLoader, +} from 'phoenix-event-display'; + +import { MatDialogRef } from '@angular/material/dialog'; +import {EventDataFormat, EventDataImportOption, EventDisplayService, ImportOption} from "phoenix-ui-components"; +import {Cache} from "three"; +import files = Cache.files; +import {EicEdm4hepJsonLoader} from "../../../eic-edm4hep-json-loader"; + +@Component({ + selector: 'app-io-options-dialog', + templateUrl: './io-options-dialog.component.html', + styleUrls: ['./io-options-dialog.component.scss'], + standalone: true, +}) +export class IOOptionsDialogComponent implements OnInit { + + constructor( + private eventDisplay: EventDisplayService, + public dialogRef: MatDialogRef, + ) {} + + ngOnInit() { + + } + + + onClose(): void { + this.dialogRef.close(); + } + + getFirstFileFromEvent(event: Event): File|null { + + // Check if the target is actually an HTMLInputElement + if (!(event.target instanceof HTMLInputElement)) { + console.error("Event target is not an HTML input element."); + return null; + } + + const files = event.target.files; + if (!files || files.length === 0) { + console.error("No files selected."); + return null; + } + + return files[0]; + + } + + handleJSONEventDataInput(event: Event) { + let file = this.getFirstFileFromEvent(event); + if (!file) return; // If not file it is already reported + + this.readTextFile(file, (content: string) => { + this.eventDisplay.parsePhoenixEvents(JSON.parse(content)); + }); + } + + handleEdm4HepJsonEventDataInput(event: Event) { + let file = this.getFirstFileFromEvent(event); + if (!file) return; // If not file it is already reported + const callback = (content: any) => { + const json = typeof content === 'string' ? JSON.parse(content) : content; + const edm4hepJsonLoader = new EicEdm4hepJsonLoader(); + edm4hepJsonLoader.setRawEventData(json); + edm4hepJsonLoader.processEventData(); + this.eventDisplay.parsePhoenixEvents(edm4hepJsonLoader.getEventData()); + }; + this.readTextFile(file, callback); + } + + handleJiveXMLDataInput(element: HTMLInputElement| null) { + let files = element?.files; + if(!files) return; + + if(!files) return; + const callback = (content: any) => { + const jiveloader = new JiveXMLLoader(); + jiveloader.process(content); + const eventData = jiveloader.getEventData(); + this.eventDisplay.buildEventDataFromJSON(eventData); + }; + this.readTextFile(files[0], callback); + } + + handleOBJInput(element: HTMLInputElement| null) { + let files = element?.files; + if(!files) return; + + const callback = (content: any) => { + this.eventDisplay.parseOBJGeometry(content, files[0].name); + }; + if(files) { + this.readTextFile(files[0], callback); + } + } + + handleSceneInput(files: FileList) { + const callback = (content: any) => { + this.eventDisplay.parsePhoenixDisplay(content); + }; + this.readTextFile(files[0], callback); + } + + handleGLTFInput(files: FileList) { + const callback = (content: any) => { + this.eventDisplay.parseGLTFGeometry(content, files[0].name); + }; + this.readTextFile(files[0], callback); + } + + handlePhoenixInput(files: FileList| null) { + if(!files) return; + const callback = (content: any) => { + this.eventDisplay.parsePhoenixDisplay(content); + }; + this.readTextFile(files[0], callback); + } + + async handleROOTInput(files: FileList) { + const rootObjectName = prompt('Enter object name in ROOT file'); + + await this.eventDisplay.loadRootGeometry( + URL.createObjectURL(files[0]), + rootObjectName ?? "", + files[0].name.split('.')[0], + ); + + this.onClose(); + } + + async handleRootJSONInput(files: FileList) { + + const name = files[0].name.split('.')[0]; + await this.eventDisplay.loadRootJSONGeometry( + URL.createObjectURL(files[0]), + name, + ); + + this.onClose(); + } + + handleIgEventDataInput(files: FileList) { + const cmsLoader = new CMSLoader(); + cmsLoader.readIgArchive(files[0], (allEvents: any[]) => { + const allEventsData = cmsLoader.getAllEventsData(allEvents); + this.eventDisplay.parsePhoenixEvents(allEventsData); + this.onClose(); + }); + } + + async handleZipEventDataInput(files: FileList) { + + const allEventsObject = {}; + let filesWithData: { [fileName: string]: string }; + + // Using a try catch block to catch any errors in Promises + try { + filesWithData = await readZipFile(files[0]); + } catch (error) { + console.error('Error while reading zip', error); + this.eventDisplay.getInfoLogger().add('Could not read zip file', 'Error'); + return; + } + + // JSON event data + Object.keys(filesWithData) + .filter((fileName) => fileName.endsWith('.json')) + .forEach((fileName) => { + Object.assign(allEventsObject, JSON.parse(filesWithData[fileName])); + }); + + // JiveXML event data + const jiveloader = new JiveXMLLoader(); + Object.keys(filesWithData) + .filter((fileName) => { + return fileName.endsWith('.xml') || fileName.startsWith('JiveXML'); + }) + .forEach((fileName) => { + jiveloader.process(filesWithData[fileName]); + const eventData = jiveloader.getEventData(); + Object.assign(allEventsObject, { [fileName]: eventData }); + }); + // For some reason the above doesn't pick up JiveXML_XXX_YYY.zip + + this.eventDisplay.parsePhoenixEvents(allEventsObject); + + this.onClose(); + } + + readTextFile(file: File, callback: (result: string) => void) { + const reader = new FileReader(); + reader.onload = () => { + callback(reader?.result?.toString() ?? ""); + }; + reader.readAsText(file); + this.onClose(); + } + + saveScene() { + this.eventDisplay.exportPhoenixDisplay(); + } + + exportOBJ() { + this.eventDisplay.exportToOBJ(); + } + + protected readonly HTMLInputElement = HTMLInputElement; +} diff --git a/firebird-ng/src/app/main-display/io-options/io-options.component.html b/firebird-ng/src/app/main-display/io-options/io-options.component.html new file mode 100644 index 0000000..a7e80d3 --- /dev/null +++ b/firebird-ng/src/app/main-display/io-options/io-options.component.html @@ -0,0 +1,8 @@ + + + diff --git a/firebird-ng/src/app/main-display/io-options/io-options.component.scss b/firebird-ng/src/app/main-display/io-options/io-options.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/firebird-ng/src/app/main-display/io-options/io-options.component.test.ts b/firebird-ng/src/app/main-display/io-options/io-options.component.test.ts new file mode 100644 index 0000000..1afce0e --- /dev/null +++ b/firebird-ng/src/app/main-display/io-options/io-options.component.test.ts @@ -0,0 +1,52 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IoOptionsComponent } from './io-options.component'; +import { MatDialog } from '@angular/material/dialog'; +import { PhoenixUIModule } from '../../phoenix-ui.module'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +describe('IoOptionsComponent', () => { + let component: IoOptionsComponent; + let fixture: ComponentFixture; + + const dialog = { + open: jest.fn().mockImplementation(() => { + return { + componentInstance: { + eventDataImportOptions: [], + }, + }; + }), + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [BrowserAnimationsModule, PhoenixUIModule], + providers: [ + { + provide: MatDialog, + useValue: dialog, + }, + ], + declarations: [IoOptionsComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(IoOptionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should open IO dialog', () => { + jest.spyOn(dialog, 'open'); + + component.openIODialog(); + + expect(dialog.open).toHaveBeenCalled(); + }); +}); diff --git a/firebird-ng/src/app/main-display/io-options/io-options.component.ts b/firebird-ng/src/app/main-display/io-options/io-options.component.ts new file mode 100644 index 0000000..f1379a5 --- /dev/null +++ b/firebird-ng/src/app/main-display/io-options/io-options.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; + +import { IOOptionsDialogComponent } from './io-options-dialog/io-options-dialog.component'; +import {PhoenixUIModule} from "phoenix-ui-components"; + +@Component({ + selector: 'eic-io-options', + standalone: true, + templateUrl: './io-options.component.html', + styleUrls: ['./io-options.component.scss'], + imports: [ + PhoenixUIModule + ] +}) +export class IoOptionsComponent { + + constructor(private dialog: MatDialog) {} + + openIODialog() { + this.dialog.open(IOOptionsDialogComponent, { panelClass: 'dialog', }); + } +} diff --git a/firebird-ng/src/app/main-display/main-display.component.html b/firebird-ng/src/app/main-display/main-display.component.html index e9b451a..07ce96e 100644 --- a/firebird-ng/src/app/main-display/main-display.component.html +++ b/firebird-ng/src/app/main-display/main-display.component.html @@ -1,8 +1,59 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firebird-ng/src/app/main-display/main-display.component.ts b/firebird-ng/src/app/main-display/main-display.component.ts index 8f72e54..b9053ba 100644 --- a/firebird-ng/src/app/main-display/main-display.component.ts +++ b/firebird-ng/src/app/main-display/main-display.component.ts @@ -1,64 +1,58 @@ -import { Component, OnInit } from '@angular/core'; -import { EventDisplayService } from 'phoenix-ui-components'; -import { Configuration, PhoenixLoader, PresetView, ClippingSetting, PhoenixMenuNode } from 'phoenix-event-display'; +import {Component, Input, OnInit} from '@angular/core'; +import {HttpClient, HttpClientModule} from '@angular/common/http'; import { - Color, - DoubleSide, - Mesh, - LineSegments, - LineBasicMaterial, - MeshPhongMaterial, - Material, - ObjectLoader, - FrontSide, - Vector3, - Matrix4, - REVISION, - MeshPhysicalMaterial, -} from "three"; -import { PhoenixUIModule } from 'phoenix-ui-components'; -import { GeometryService} from '../geometry.service'; -import { Edm4hepRootEventLoader } from '../edm4hep-root-event-loader'; -import { ActivatedRoute } from '@angular/router'; -import {color} from "three/examples/jsm/nodes/shadernode/ShaderNode"; -import {getGeoNodesByLevel} from "../utils/cern-root.utils"; + EventDataFormat, + EventDataImportOption, + EventDisplayService, + PhoenixUIModule +} from 'phoenix-ui-components'; +import {ClippingSetting, Configuration, PhoenixLoader, PhoenixMenuNode, PresetView} from 'phoenix-event-display'; +import * as THREE from 'three'; +import {Color, DoubleSide, Line, MeshPhongMaterial,} from "three"; +import {GeometryService} from '../geometry.service'; +import {ActivatedRoute} from '@angular/router'; +import {ThreeGeometryProcessor} from "../three-geometry.processor"; + +import GUI from "lil-gui"; import {produceRenderOrder} from "jsrootdi/geom"; -import {wildCardCheck} from "../utils/wildcard"; - -interface Colorable { - color: Color; -} - -function isColorable(material: any): material is Colorable { - return 'color' in material; -} - -function getColorOrDefault(material:any, defaultColor: Color): Color { - if (isColorable(material)) { - return material.color; - } else { - return defaultColor; - } - -} - -function ensureColor(material: any) { - -} +import { + disposeHierarchy, + disposeNode, + findObject3DNodes, + getColorOrDefault, + pruneEmptyNodes +} from "../utils/three.utils"; +import {mergeMeshList, MergeResult} from "../utils/three-geometry-merge"; +import {PhoenixThreeFacade} from "../utils/phoenix-three-facade"; +import {BehaviorSubject, Subject} from "rxjs"; +import {GameControllerService} from "../game-controller.service"; +import {LineMaterial} from "three/examples/jsm/lines/LineMaterial"; +import {Line2} from "three/examples/jsm/lines/Line2"; +import {LineGeometry} from "three/examples/jsm/lines/LineGeometry"; +import {IoOptionsComponent} from "./io-options/io-options.component"; +import {ThreeEventProcessor} from "../three-event.processor"; +import {UserConfigService} from "../user-config.service"; +// import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; @Component({ selector: 'app-test-experiment', templateUrl: './main-display.component.html', - imports: [PhoenixUIModule], + imports: [PhoenixUIModule, IoOptionsComponent], standalone: true, styleUrls: ['./main-display.component.scss'] }) export class MainDisplayComponent implements OnInit { + @Input() + eventDataImportOptions: EventDataImportOption[] = Object.values(EventDataFormat); + /** The root Phoenix menu node. */ phoenixMenuRoot = new PhoenixMenuNode("Phoenix Menu"); + threeGeometryProcessor = new ThreeGeometryProcessor(); + threeEventProcessor = new ThreeEventProcessor(); + /** is geometry loaded */ loaded: boolean = false; @@ -68,14 +62,27 @@ export class MainDisplayComponent implements OnInit { /** The Default color of elements if not set */ defaultColor: Color = new Color(0x2fd691); + + private renderer: THREE.Renderer|null = null; + private camera: THREE.Camera|null = null; + private scene: THREE.Scene|null = null; + private stats: any|null = null; // Stats JS display from UI manager + + private threeFacade: PhoenixThreeFacade; + + constructor( private geomService: GeometryService, private eventDisplay: EventDisplayService, - private route: ActivatedRoute) { } + private controller: GameControllerService, + private route: ActivatedRoute, + private settings: UserConfigService) { + this.threeFacade = new PhoenixThreeFacade(this.eventDisplay); + } async loadGeometry(initiallyVisible=true, scale=10) { - let {rootGeoManager, rootObject3d} = await this.geomService.loadEicGeometry(); + let {rootGeoManager, rootObject3d} = await this.geomService.loadGeometry(); let threeManager = this.eventDisplay.getThreeManager(); let uiManager = this.eventDisplay.getUIManager(); let openThreeManager: any = threeManager; @@ -84,136 +91,235 @@ export class MainDisplayComponent implements OnInit { const sceneGeometry = threeManager.getSceneManager().getGeometries(); + // Set geometry scale if (scale) { rootObject3d.scale.setScalar(scale); } + + // Add root geometry to scene + // console.log("CERN ROOT converted to Object3d: ", rootObject3d); sceneGeometry.add(rootObject3d); - console.log("CERN ROOT converted to Object3d: ", rootObject3d); - //rootGeometry.visible = initiallyVisible; - //sceneGeometry.add(rootGeometry); - let topLevelRootItems = getGeoNodesByLevel(rootGeoManager); - let topLevelObj3dNodes = rootObject3d.children[0].children; - if(topLevelRootItems.length != topLevelObj3dNodes.length) { - console.warn(`topLevelRootItems.length != topLevelObj3dNodes.length`); - console.log("Can't create Menu Items"); - } - else { - for(let i=0; i < topLevelRootItems.length; i++) { - let rootGeoNode = topLevelRootItems[i].geoNode; - let obj3dNode = topLevelObj3dNodes[i]; - obj3dNode.name = obj3dNode.userData["name"] = rootGeoNode.name; - - // Add geometry - uiManager.addGeometry(obj3dNode, obj3dNode.name); - } - } - let renderer = openThreeManager.rendererManager; + // Now we want to change the materials + sceneGeometry.traverse( (child: any) => { - const glassMaterial = new MeshPhysicalMaterial({ - color: 0xffff00, // Yellow color - metalness: 0, - roughness: 0, - transmission: 0.7, // High transparency - opacity: 1, - transparent: true, - reflectivity: 0.5 - }); + if(child.type!=="Mesh" || !child?.material?.isMaterial) { + return; + } + // Assuming `getObjectSize` is correctly typed and available + child.userData["size"] = importManager.getObjectSize(child); - // Now we want to change the materials - sceneGeometry.traverse( (child: any) => { + // Handle the material of the child - if(child.type!=="Mesh") { - return; - } + const color = getColorOrDefault(child.material, this.defaultColor); + const side = doubleSided ? DoubleSide : child.material.side; - if(!child?.material?.isMaterial) { - return; - } + child.material.dispose(); // Dispose the old material if it's a heavy object - // Assuming `getObjectSize` is correctly typed and available - child.userData["size"] = importManager.getObjectSize(child); + let opacity = rootObject3d.userData.opacity ?? 1; + let transparent = opacity < 1; - // Handle the material of the child + child.material = new MeshPhongMaterial({ + color: color, + shininess: 0, + side: side, + transparent: true, + opacity: 0.5, + depthTest: true, + depthWrite: true, + clippingPlanes: openThreeManager.clipPlanes, + clipIntersection: true, + clipShadows: false + }); - const color = getColorOrDefault(child.material, this.defaultColor); - const side = doubleSided ? DoubleSide : child.material.side; + // Material + let name:string = child.name; - child.material.dispose(); // Dispose the old material if it's a heavy object + if(! child.material?.clippingPlanes !== undefined) { + child.material.clippingPlanes = openThreeManager.clipPlanes; + } - let opacity = rootObject3d.userData.opacity ?? 1; - let transparent = opacity < 1; + if(! child.material?.clipIntersection !== undefined) { + child.material.clipIntersection = true; + } - child.material = new MeshPhongMaterial({ - color: color, - shininess: 0, - side: side, - transparent: transparent, - opacity: opacity, - clippingPlanes: openThreeManager.clipPlanes, - clipIntersection: true, - clipShadows: false - }); + if(! child.material?.clipShadows !== undefined) { + child.material.clipShadows = false; + } + }); - // Material - let name:string = child.name; + // HERE WE DO POSTPROCESSING STEP + this.threeGeometryProcessor.process(rootObject3d); + // Now we want to change the materials + sceneGeometry.traverse( (child: any) => { - if(name.startsWith("bar_") || name.startsWith("prism_")) { - child.material = glassMaterial; + if(!child?.material?.isMaterial) { + return; } - if(! child.material?.clippingPlanes !== undefined) { + if(child.material?.clippingPlanes !== undefined) { child.material.clippingPlanes = openThreeManager.clipPlanes; } - if(! child.material?.clipIntersection !== undefined) { + if(child.material?.clipIntersection !== undefined) { child.material.clipIntersection = true; } - if(! child.material?.clipShadows !== undefined) { + if(child.material?.clipShadows !== undefined) { child.material.clipShadows = false; } - - // if (!(child instanceof Mesh)) { - // return; - // } - // child.userData["size"] = importManager.getObjectSize(child); - // if (!(child.material instanceof Material)) { - // return; - // } - // const color = child.material['color'] - // ? child.material['color'] - // : 0x2fd691; - // const side = doubleSided ? DoubleSide : child.material['side']; - // child.material.dispose(); - // let isTransparent = false; - // if (rootGeometry.userData.opacity) { - // isTransparent = true; - // } - // child.material = new MeshPhongMaterial({ - // color, - // shininess: 0, - // side: side, - // transparent: isTransparent, - // opacity: (_a = rootGeometry.userData.opacity) !== null && _a !== void 0 ? _a : 1, - // }); - // child.material.clippingPlanes = openThreeManager.clipPlanes; - // child.material.clipIntersection = true; - // child.material.clipShadows = false; }); - + let renderer = openThreeManager.rendererManager; + // Set render priority let scene = threeManager.getSceneManager().getScene(); + scene.background = new THREE.Color( 0x3F3F3F ); + renderer.getMainRenderer().sortObjects = false; + let camera = openThreeManager.controlsManager.getMainCamera(); - produceRenderOrder(scene, camera.position, 'dflt'); + // camera.far = 5000; + produceRenderOrder(scene, camera.position, 'ray'); + + var planeA = new THREE.Plane(); + + planeA.set(new THREE.Vector3(0,-1,0), 0); + + // renderer.getMainRenderer().clippingPlanes = [planeA]; } + produceRenderOrder() { + + console.log("produceRenderOrder. scene: ", this.scene, " camera ", this.camera); + produceRenderOrder(this.scene, this.camera?.position, 'ray'); + } + + logGamepadStates () { + const gamepads = navigator.getGamepads(); + + for (const gamepad of gamepads) { + if (gamepad) { + console.log(`Gamepad connected at index ${gamepad.index}: ${gamepad.id}.`); + console.log(`Timestamp: ${gamepad.timestamp}`); + console.log('Axes states:'); + gamepad.axes.forEach((axis, index) => { + console.log(`Axis ${index}: ${axis.toFixed(4)}`); + }); + console.log('Button states:'); + gamepad.buttons.forEach((button, index) => { + console.log(`Button ${index}: ${button.pressed ? 'pressed' : 'released'}, value: ${button.value}`); + }); + } + } + }; + + rotateCamera(xAxisChange: number, yAxisChange: number) { + let orbitControls = this.threeFacade.activeOrbitControls; + let camera = this.threeFacade.mainCamera; + + const offset = new THREE.Vector3(); // Offset of the camera from the target + const quat = new THREE.Quaternion().setFromUnitVectors(camera.up, new THREE.Vector3(0, 1, 0)); + const quatInverse = quat.clone().invert(); + + const currentPosition = camera.position.clone().sub(orbitControls.target); + currentPosition.applyQuaternion(quat); // Apply the quaternion + + // Spherical coordinates + const spherical = new THREE.Spherical().setFromVector3(currentPosition); + + // Adjusting spherical coordinates + spherical.theta -= xAxisChange * 0.01; // Azimuth angle change + spherical.phi += yAxisChange * 0.01; // Polar angle change, for rotating up/down + + // Ensure phi is within bounds to avoid flipping + spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); + + // Convert back to Cartesian coordinates + const newPostion = new THREE.Vector3().setFromSpherical(spherical); + newPostion.applyQuaternion(quatInverse); + + camera.position.copy(newPostion.add(orbitControls.target)); + camera.lookAt(orbitControls.target); + orbitControls.update(); + } + + zoom(factor: number) { + let orbitControls = this.threeFacade.activeOrbitControls; + let camera = this.threeFacade.mainCamera; + orbitControls.object.position.subVectors(camera.position, orbitControls.target).multiplyScalar(factor).add(orbitControls.target); + orbitControls.update(); + } + + handleGamepadInputV2 () { + this.controller.animationLoopHandler(); + } + + logCamera() { + console.log(this.threeFacade.mainCamera); + } + + + handleGamepadInputV1 () { + + // Update stats display that showing FPS, etc. + if (this.stats) { + this.stats.update(); + } + + const gamepads = navigator.getGamepads(); + for (const gamepad of gamepads) { + if (gamepad) { + // Example: Using left joystick to control OrbitControls + // Axis 0: Left joystick horizontal (left/right) + // Axis 1: Left joystick vertical (up/down) + const xAxis = gamepad.axes[0]; + const yAxis = gamepad.axes[1]; + + let controls = this.threeFacade.activeOrbitControls; + let camera = this.threeFacade.mainCamera; + + if (Math.abs(xAxis) > 0.1 || Math.abs(yAxis) > 0.1) { + this.rotateCamera(xAxis, yAxis); + + } + + // Zooming using buttons + const zoomInButton = gamepad.buttons[2]; + const zoomOutButton = gamepad.buttons[0]; + + if (zoomInButton.pressed) { + this.zoom(0.99); + } + + if (zoomOutButton.pressed) { + this.zoom(1.01); + } + + break; // Only use the first connected gamepad + } + } + }; + + updateProjectionMatrix() { + let camera = this.threeFacade.mainCamera; + camera.updateProjectionMatrix(); + } + + ngOnInit() { + + let eventSource = this.settings.eventSource.value; + let eventConfig = {eventFile: "https://firebird-eic.org/py8_all_dis-cc_beam-5x41_minq2-100_nevt-5.evt.json.zip", eventType: "zip"}; + if( eventSource != "no-events" && !eventSource.endsWith("edm4hep.json")) { + let eventType = eventSource.endsWith("zip") ? "zip" : "json"; + let eventFile = eventSource; + eventConfig = {eventFile, eventType}; + } + // Create the event display configuration const configuration: Configuration = { eventDataLoader: new PhoenixLoader(), @@ -226,23 +332,80 @@ export class MainDisplayComponent implements OnInit { new PresetView('Perspective2 + clip', [-4500, 8000, -6000], [0, 0, -5000], 'right-cube', ClippingSetting.On, 90, 90) ], // default view with x, y, z of the camera and then x, y, z of the point it looks at - defaultView: [-4500, 12000, 0, 0, 0 ,0], + defaultView: [-2500, 0, -8000, 0, 0 ,0], phoenixMenuRoot: this.phoenixMenuRoot, // Event data to load by default - defaultEventFile: { - // (Assuming the file exists in the `src/assets` directory of the app) - //eventFile: 'assets/herwig_18x275_5evt.json', - eventFile: 'assets/events/herwig_5x41_5evt_showers.json', - eventType: 'json' // or zip - }, + defaultEventFile: eventConfig + // defaultEventFile: { + // // (Assuming the file exists in the `src/assets` directory of the app) + // //eventFile: 'assets/herwig_18x275_5evt.json', + // //eventFile: 'assets/events/py8_all_dis-cc_beam-18x275_minq2-1000_nevt-20.evt.json', + // //eventFile: 'assets/events/py8_dis-cc_mixed.json.zip', + // eventFile: 'https://firebird-eic.org/py8_all_dis-cc_beam-5x41_minq2-100_nevt-5.evt.json.zip', + // eventType: 'zip' // or zip + // }, } // Initialize the event display this.eventDisplay.init(configuration); + + // let uiManager = this.eventDisplay.getUIManager(); + let openThreeManager: any = this.eventDisplay.getThreeManager(); + let threeManager = this.eventDisplay.getThreeManager(); + + this.renderer = openThreeManager.rendererManager.getMainRenderer(); + this.scene = threeManager.getSceneManager().getScene() as THREE.Scene; + this.camera = openThreeManager.controlsManager.getMainCamera() as THREE.Camera; + + + // GUI + const globalPlane = new THREE.Plane( new THREE.Vector3( - 1, 0, 0 ), 0.1 ); + + const gui = new GUI({ + // container: document.getElementById("lil-gui-place") ?? undefined, + + }); + + gui.title("Debug"); + gui.add(this, "produceRenderOrder"); + gui.add(this, "logGamepadStates").name( 'Log controls' ); + gui.add(this, "logCamera").name( 'Log camera' ); + gui.add(this, "updateProjectionMatrix").name( 'Try to screw up the camera =)' ); + gui.close(); + + // Set default clipping this.eventDisplay.getUIManager().setClipping(true); - this.eventDisplay.getUIManager().rotateOpeningAngleClipping(120); - this.eventDisplay.getUIManager().rotateStartAngleClipping(45); + this.eventDisplay.getUIManager().rotateOpeningAngleClipping(180); + this.eventDisplay.getUIManager().rotateStartAngleClipping(90); + + this.eventDisplay.listenToDisplayedEventChange(event => { + console.log("listenToDisplayedEventChange"); + console.log(event); + let mcTracksGroup = threeManager.getSceneManager().getObjectByName("mc_tracks"); + if(mcTracksGroup) { + this.threeEventProcessor.processMcTracks(mcTracksGroup); + } + }) + // Display event loader + this.eventDisplay.getLoadingManager().addLoadListenerWithCheck(() => { + console.log('Loading default configuration.'); + this.loaded = true; + }); + + this.eventDisplay + .getLoadingManager().toLoad.push("MyGeometry"); + + + this.eventDisplay + .getLoadingManager() + .addProgressListener((progress) => (this.loadingProgress = progress)); + + this.stats = (this.eventDisplay.getUIManager() as any).stats; + + + threeManager.setAnimationLoop(()=>{this.handleGamepadInputV1()}); + //const events_url = "https://eic.github.io/epic/artifacts/sim_dis_10x100_minQ2=1000_epic_craterlake.edm4hep.root/sim_dis_10x100_minQ2=1000_epic_craterlake.edm4hep.root" @@ -259,12 +422,12 @@ export class MainDisplayComponent implements OnInit { let jsonGeometry; this.loadGeometry().then(jsonGeom => { - + jsonGeometry = jsonGeom; + this.eventDisplay + .getLoadingManager().itemLoaded("MyGeometry"); }); - this.eventDisplay - .getLoadingManager() - .addProgressListener((progress) => (this.loadingProgress = progress)); + document.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') { @@ -278,14 +441,5 @@ export class MainDisplayComponent implements OnInit { } console.log((e as KeyboardEvent).key); }); - - // Load the default configuration - this.eventDisplay.getLoadingManager().addLoadListenerWithCheck(() => { - console.log('Loading default configuration.'); - this.loaded = true; - - }); - } - } diff --git a/firebird-ng/src/app/root-geometry.processor.ts b/firebird-ng/src/app/root-geometry.processor.ts new file mode 100644 index 0000000..b860880 --- /dev/null +++ b/firebird-ng/src/app/root-geometry.processor.ts @@ -0,0 +1,169 @@ +//import { openFile } from '../../../jsroot/core.mjs'; +//import * as ROOT from '../../../jsroot/build; +import { + EditActions, + editGeoNodes, + findSingleGeoNode, + GeoAttBits, + GeoNodeEditRule, + removeGeoNode +} from './utils/cern-root.utils'; + + +export class DetectorGeometryFineTuning { + namePattern: string = ""; + editRules: GeoNodeEditRule[] = []; +} + + +function pruneTopLevelDetectors(geoManager: any, removeNames: string[]): any { + const volume = geoManager.fMasterVolume === undefined ? geoManager.fVolume : geoManager.fMasterVolume; + const nodes: any[] = volume?.fNodes?.arr ?? []; + let removedNodes: any[] = []; + + // Don't have nodes? Have problems? + if(!nodes.length) { + return {nodes, removedNodes}; + } + + // Collect nodes to remove + for(let node of nodes) { + let isRemoving = removeNames.some(substr => node.fName.startsWith(substr)) + if(isRemoving) { + removedNodes.push(node); + } + } + + // Now remove nodes + for(let node of removedNodes) { + removeGeoNode(node); + } + + return {nodes, removedNodes} +} + +export class RootGeometryProcessor { + /** + * Detectors (top level TGeo nodes) to be removed. + * (!) startsWith function is used for filtering (aka: detector.fName.startsWith(removeDetectorNames[i]) ... ) + */ + removeDetectorNames: string[] = [ + "Lumi", + //"Magnet", + //"B0", + "B1", + "B2", + //"Q0", + //"Q1", + "Q2", + //"BeamPipe", + //"Pipe", + "ForwardOffM", + "Forward", + "Backward", + "Vacuum", + "SweeperMag", + "AnalyzerMag", + "ZDC", + //"LFHCAL", + "HcalFarForward", + "InnerTrackingSupport" + ]; + + subDetectorsRules: DetectorGeometryFineTuning[] = [ + { + namePattern: "*/EcalBarrelScFi*", + editRules: [ + {pattern: "*/fiber_grid*", action: EditActions.Remove}, + ] + }, + { + namePattern: "*/EcalBarrelImaging*", + editRules: [ + {pattern: "*/stav*", action: EditActions.RemoveChildren}, + ] + }, + { + namePattern: "*/DRICH*", + editRules: [ + {pattern: "*/DRICH_cooling*", action: EditActions.RemoveSiblings}, + ] + }, + { + namePattern: "*/DIRC*", + editRules: [ + {pattern: "*/Envelope_box*", action: EditActions.RemoveChildren}, + {pattern: "*/Envelope_box*", action: EditActions.SetGeoBit, geoBit: GeoAttBits.kVisThis}, + {pattern: "*/Envelope_box*", action: EditActions.UnsetGeoBit, geoBit: GeoAttBits.kVisNone}, + {pattern: "*/Envelope_box*", action: EditActions.UnsetGeoBit, geoBit: GeoAttBits.kVisDaughters}, + {pattern: "*/Envelope_lens_vol*", action: EditActions.Remove}, + ] + }, + { + namePattern: "*/EcalEndcapN*", + editRules: [ + {pattern: "*/crystal*", action: EditActions.RemoveSiblings}, + ] + }, + { + namePattern: "*/EcalEndcapP_*", + editRules: [ + {pattern: "*/EcalEndcapP_layer1_0*", action: EditActions.UnsetGeoBit, geoBit: GeoAttBits.kVisDaughters}, + {pattern: "*/EcalEndcapP_layer1_0*", action: EditActions.RemoveChildren}, + ] + }, + { + namePattern: "*/LFHCAL_*", + editRules: [ + {pattern: "*/LFHCAL_8M*", action: EditActions.SetGeoBit, geoBit: GeoAttBits.kVisThis}, + {pattern: "*/LFHCAL_8M*", action: EditActions.UnsetGeoBit, geoBit: GeoAttBits.kVisDaughters}, + {pattern: "*/LFHCAL_8M*", action: EditActions.UnsetGeoBit, geoBit: GeoAttBits.kVisNone}, + ] + }, + { + namePattern: "*/HcalEndcapPInsert_23*", + editRules: [ + {pattern: "*/*layer*slice1_*", action: EditActions.RemoveSiblings}, + ] + }, + { + namePattern: "*/HcalBarrel*", + editRules: [ + {pattern: "*/Tile*", action: EditActions.Remove}, + {pattern: "*/ChimneyTile*", action: EditActions.Remove}, + ] + }, + { + namePattern: "*/EndcapTOF*", + editRules: [ + {pattern: "*/suppbar*", action: EditActions.Remove}, + {pattern: "*/component*3", action: EditActions.RemoveSiblings}, + ] + } + ] + + public process(rootGeoManager:any):any { + // Getting main detector nodes + let result = pruneTopLevelDetectors(rootGeoManager, this.removeDetectorNames); + console.log("Filtered top level detectors: ", result); + + + // >oO analyzeGeoNodes(rootGeoManager, 1); + // Now we go with the fine-tuning of each detector + for(let detector of this.subDetectorsRules) { + let topDetNode = findSingleGeoNode(rootGeoManager, detector.namePattern, 1); + console.log(`Processing ${topDetNode}`); + if(!topDetNode) { + continue; + } + console.time(`Process sub-detector: ${detector.namePattern}`); + for(let rule of detector.editRules) { + + editGeoNodes(topDetNode, [rule]) + } + console.timeEnd(`Process sub-detector: ${detector.namePattern}`); + } + + console.log(`Done processing ${this.subDetectorsRules.length} detectors`); + } +} diff --git a/firebird-ng/src/app/three-event.processor.ts b/firebird-ng/src/app/three-event.processor.ts new file mode 100644 index 0000000..65379ff --- /dev/null +++ b/firebird-ng/src/app/three-event.processor.ts @@ -0,0 +1,177 @@ +import {Color, Object3D} from "three"; +import {LineMaterial} from "three/examples/jsm/lines/LineMaterial"; +import {LineGeometry} from "three/examples/jsm/lines/LineGeometry"; +import {Line2} from "three/examples/jsm/lines/Line2"; + + +export enum NeonTrackColors { + + Red = 0xFF0007, + Pink= 0xCF00FF, + Violet = 0x5400FF, + Blue = 0x0097FF, + DeepBlue = 0x003BFF, + Teal = 0x00FFD1, + Green = 0x13FF00, + Salad = 0x8CFF00, + Yellow = 0xFFEE00, + Orange = 0xFF3500, + Gray = 0xAAAAAA, +} + +/** + * "gamma": "yellow", + * "e-": "blue", + * "pi+": "pink", + * "pi-": "salad", + * "proton": "violet", + * "neutron": "green" + */ +export class ThreeEventProcessor { + + /** This is primer, all other DASHED line materials take this and clone and change color */ + dashedLineMaterial = new LineMaterial( { + color: 0xffff00, + linewidth: 10, // in world units with size attenuation, pixels otherwise + worldUnits: true, + dashed: true, + //dashScale: 100, // ???? Need this? What is it? + dashSize: 100, + gapSize: 100, + alphaToCoverage: true, + } ); + + /** This is primer, all other SOLID line materials take this and clone and change color */ + solidLineMaterial = new LineMaterial( { + color: 0xffff00, + linewidth: 10, // in world units with size attenuation, pixels otherwise + worldUnits: true, + dashed: false, + //dashScale: 100, // ???? Need this? What is it? + alphaToCoverage: true, + } ); + + gammaMaterial: LineMaterial; + electronMaterial: LineMaterial; + piPlusMaterial: LineMaterial; + piMinusMaterial: LineMaterial; + piZeroMaterial: LineMaterial; + protonMaterial: LineMaterial; + neutronMaterial: LineMaterial; + posChargeMaterial: LineMaterial; + negChargeMaterial: LineMaterial; + zeroChargeMaterial: LineMaterial; + scatteredElectronMaterial: LineMaterial; + + constructor() { + this.gammaMaterial = this.dashedLineMaterial.clone(); + this.gammaMaterial.color = new Color(NeonTrackColors.Yellow); + this.gammaMaterial.dashSize = 50; + this.gammaMaterial.gapSize = 50; + + this.electronMaterial = this.solidLineMaterial.clone(); + this.electronMaterial.color = new Color(NeonTrackColors.Blue); + + this.scatteredElectronMaterial = this.electronMaterial.clone(); + this.scatteredElectronMaterial.linewidth = 30; + + this.piPlusMaterial = this.solidLineMaterial.clone(); + this.piPlusMaterial.color = new Color(NeonTrackColors.Pink); + + this.piMinusMaterial = this.solidLineMaterial.clone(); + this.piMinusMaterial.color = new Color(NeonTrackColors.Teal); + + this.piZeroMaterial = this.dashedLineMaterial.clone(); + this.piZeroMaterial.color = new Color(NeonTrackColors.Salad); + + this.protonMaterial = this.solidLineMaterial.clone(); + this.protonMaterial.color = new Color(NeonTrackColors.Violet); + + this.neutronMaterial = this.dashedLineMaterial.clone(); + this.neutronMaterial.color = new Color(NeonTrackColors.Green); + + this.posChargeMaterial = this.solidLineMaterial.clone(); + this.posChargeMaterial.color = new Color(NeonTrackColors.Red); + + this.negChargeMaterial = this.solidLineMaterial.clone(); + this.negChargeMaterial.color = new Color(NeonTrackColors.DeepBlue); + + this.zeroChargeMaterial = this.dashedLineMaterial.clone(); + this.zeroChargeMaterial.color = new Color(NeonTrackColors.Gray); + + } + + getMaterial(pdgName: string, charge: number): LineMaterial { + switch (pdgName) { + case "gamma": + return this.gammaMaterial; + case "e-": + return this.electronMaterial; + case "pi+": + return this.piPlusMaterial; + case "pi-": + return this.piMinusMaterial; + case "pi0": + return this.piZeroMaterial; + case "proton": + return this.protonMaterial; + case "neutron": + return this.neutronMaterial; + default: + return charge < 0 ? this.negChargeMaterial: (charge > 0? this.posChargeMaterial: this.neutronMaterial); // Fallback function if material is not predefined + } + } + + + processMcTracks(mcTracksGroup: Object3D) { + + let isFoundScatteredElectron = false; + for(let trackGroup of mcTracksGroup.children) { + + let trackData = trackGroup.userData; + if(!('pdg_name' in trackData)) continue; + if(!('charge' in trackData)) continue; + const pdgName = trackData["pdg_name"] as string; + const charge = trackData["charge"] as number; + + for(let obj of trackGroup.children) { + if(obj.type == "Line") { + + let positions = (obj.userData as any).pos; + + let flat = []; + for(let position of positions) { + + flat.push(position[0], position[1], position[2]); + } + const geometry = new LineGeometry(); + geometry.setPositions( flat ); + // geometry.setColors( colors ); + + let material = this.getMaterial(pdgName, charge); + if(!isFoundScatteredElectron && pdgName=="e-") { + isFoundScatteredElectron = true; + material = this.scatteredElectronMaterial; + } + + let line = new Line2( geometry, material ); + + // line.scale.set( 1, 1, 1 ); + line.computeLineDistances(); + line.visible = true; + trackGroup.add( line ); + obj.visible = false; + } + + if(obj.type == "Mesh") { + obj.visible = false; + } + } + + } + + } + + + +} diff --git a/firebird-ng/src/app/three-geometry.processor.ts b/firebird-ng/src/app/three-geometry.processor.ts new file mode 100644 index 0000000..552eb1b --- /dev/null +++ b/firebird-ng/src/app/three-geometry.processor.ts @@ -0,0 +1,184 @@ +import { Component, OnInit } from '@angular/core'; +import { EventDisplayService } from 'phoenix-ui-components'; +import { Configuration, PhoenixLoader, PresetView, ClippingSetting, PhoenixMenuNode } from 'phoenix-event-display'; +import * as THREE from "three"; +import { PhoenixUIModule } from 'phoenix-ui-components'; +import { GeometryService} from './geometry.service'; +import { Edm4hepRootEventLoader } from './edm4hep-root-event-loader'; +import { ActivatedRoute } from '@angular/router'; +import {color} from "three/examples/jsm/nodes/shadernode/ShaderNode"; +import {getGeoNodesByLevel} from "./utils/cern-root.utils"; +import {produceRenderOrder} from "jsrootdi/geom"; +import {wildCardCheck} from "./utils/wildcard"; +import {createOutline, disposeHierarchy, findObject3DNodes, pruneEmptyNodes} from "./utils/three.utils"; +import {CalorimetryGeometryPrettifier} from "./geometry-prettifiers/calorimetry.prettifier"; +import {mergeBranchGeometries} from "./utils/three-geometry-merge"; + + +export class ThreeGeometryProcessor { + + calorimetry = new CalorimetryGeometryPrettifier(); + + glassMaterial = new THREE.LineBasicMaterial( { + color: 0xf1f1f1, + linewidth: 1, + linecap: 'round', //ignored by WebGLRenderer + linejoin: 'round' //ignored by WebGLRenderer + } ); + + params = { + alpha: 0.5, + alphaHash: true, + taa: true, + sampleLevel: 2, + }; + + vertexShader = ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); + } + `; + fragmentShader = ` + //#extension GL_OES_standard_derivatives : enable + + varying vec2 vUv; + uniform float thickness; + + float edgeFactor(vec2 p){ + vec2 grid = abs(fract(p - 0.5) - 0.5) / fwidth(p) / thickness; + return min(grid.x, grid.y); + } + + void main() { + + float a = edgeFactor(vUv); + + vec3 c = mix(vec3(1), vec3(0), a); + + gl_FragColor = vec4(c, 1.0); + } + `; + + shaderMaterial: THREE.ShaderMaterial; + + alphaMaterial = new THREE.MeshStandardMaterial( { + color: 0xffffff, + alphaHash: this.params.alphaHash, + opacity: this.params.alpha + } ); + + + + // new MeshPhysicalMaterial({ + // color: 0xffff00, // Yellow color + // metalness: 0, + // roughness: 0, + // transmission: 0.7, // High transparency + // opacity: 1, + // transparent: true, + // reflectivity: 0.5 + // }); + + constructor() { + this.shaderMaterial = new THREE.ShaderMaterial({ + uniforms: { + thickness: { + value: 1.5 + } + }, + vertexShader: this.vertexShader, + fragmentShader: this.fragmentShader + }); + + } + + public process(geometry: any) { + + // Add top nodes to menu + let topDetectorNodes = geometry.children[0].children; + + // for(let i= topLevelObj3dNodes.length - 1; i >= 0; i--) { + // console.log(`${i} : ${topLevelObj3dNodes[i].name}`); + // } + + console.log("DISPOSING"); + for(let i= topDetectorNodes.length - 1; i >= 0; i--){ + let detNode = topDetectorNodes[i]; + console.log(`${i} : ${topDetectorNodes[i].name}`); + detNode.name = detNode.userData["name"] = detNode.name; + // Add geometry + // uiManager.addGeometry(obj3dNode, obj3dNode.name); + + if(detNode.name == "EcalEndcapN_21") { + this.calorimetry.doEndcapEcalN(detNode); + } else if(detNode.name == "DRICH_16") { + this.calorimetry.doDRICH(detNode); + } else if(detNode.name.startsWith("DIRC")) { + this.calorimetry.doDIRC(detNode); + } else{ + + // try { + // detNode.removeFromParent(); + // } + // catch (e) { + // console.error(e); + // } + // + // try { + // // console.log("disposeHierarchy: ", detNode.name, detNode); + // disposeHierarchy(detNode); + // } catch (e) { + // console.error(e); + // } + + let result = mergeBranchGeometries(detNode, detNode.name + "_merged"); + createOutline(result.mergedMesh); + pruneEmptyNodes(detNode); + } + } + + // Now we want to change the materials + // geometry.traverse( (child: any) => { + // + // if(child.type!=="Mesh") { + // return; + // } + // + // child = child as THREE.Mesh; + // + // + // if(!child?.material?.isMaterial) { + // return; + // } + // + // // Material + // let name:string = child.name; + // child.updateMatrixWorld(true); + // + // //if(name.startsWith("bar_") || name.startsWith("prism_")) { + // //child.material = this.alphaMaterial; + // const edges = new THREE.EdgesGeometry(child.geometry, 30); + // //const lineMaterial = new MeshLambertMaterial({ + // const lineMaterial = new THREE.LineBasicMaterial({ + // color: 0x555555, + // fog: false, + // // Copy clipping planes from parent, using type assertion for TypeScript + // clippingPlanes: child.material.clippingPlanes ? child.material.clippingPlanes : [], + // clipIntersection: false, + // clipShadows: true, + // transparent: false + // + // }); + // + // // lineMaterial.clipping = true; + // const edgesLine = new THREE.LineSegments(edges, lineMaterial); + // //const edgesLine = new Mesh(edges, lineMaterial); + // + // child.add(edgesLine); + // + // //} + // }); + } +} diff --git a/firebird-ng/src/app/user-config.service.spec.ts b/firebird-ng/src/app/user-config.service.spec.ts new file mode 100644 index 0000000..31e4b6b --- /dev/null +++ b/firebird-ng/src/app/user-config.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { UserConfigService } from './user-config.service'; + +describe('UserConfigService', () => { + let service: UserConfigService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(UserConfigService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/firebird-ng/src/app/user-config.service.ts b/firebird-ng/src/app/user-config.service.ts new file mode 100644 index 0000000..001433c --- /dev/null +++ b/firebird-ng/src/app/user-config.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; +import {ConfigProperty} from "./utils/config-property"; + +@Injectable({ + providedIn: 'root' +}) +export class UserConfigService { + + public selectedGeometry: ConfigProperty; + public onlyCentralDetector: ConfigProperty; + public eventSource: ConfigProperty; + + constructor() { + this.selectedGeometry = new ConfigProperty("geometry.selectedGeometry", "epic-central-optimized"); + this.onlyCentralDetector = new ConfigProperty("geometry.onlyCentralDetector", true); + this.eventSource = new ConfigProperty("events.eventsSource", "recommended"); + } +} diff --git a/firebird-ng/src/app/utils/cern-root.utils.spec.ts b/firebird-ng/src/app/utils/cern-root.utils.spec.ts index 58101a7..14f0fdb 100644 --- a/firebird-ng/src/app/utils/cern-root.utils.spec.ts +++ b/firebird-ng/src/app/utils/cern-root.utils.spec.ts @@ -1,4 +1,4 @@ -import { walkGeoNodes, GeoNodeWalkCallback, findGeoNodes, geoBITS, testGeoBit, setGeoBit, toggleGeoBit} from './cern-root.utils'; +import { walkGeoNodes, GeoNodeWalkCallback, findGeoNodes, GeoAttBits, testGeoBit, setGeoBit, toggleGeoBit} from './cern-root.utils'; describe('walkGeoNodes', () => { let mockCallback: jasmine.Spy; @@ -96,54 +96,54 @@ describe('GeoBits Functions', () => { describe('testGeoBit', () => { it('should return false if fGeoAtt is undefined', () => { volume.fGeoAtt = undefined; - expect(testGeoBit(volume, geoBITS.kVisThis)).toBeFalse(); + expect(testGeoBit(volume, GeoAttBits.kVisThis)).toBeFalse(); }); it('should return false if the bit is not set', () => { volume.fGeoAtt = 0; - expect(testGeoBit(volume, geoBITS.kVisThis)).toBeFalse(); + expect(testGeoBit(volume, GeoAttBits.kVisThis)).toBeFalse(); }); it('should return true if the bit is set', () => { - volume.fGeoAtt = geoBITS.kVisThis; - expect(testGeoBit(volume, geoBITS.kVisThis)).toBeTrue(); + volume.fGeoAtt = GeoAttBits.kVisThis; + expect(testGeoBit(volume, GeoAttBits.kVisThis)).toBeTrue(); }); }); describe('setGeoBit', () => { it('should set the bit if value is 1', () => { - setGeoBit(volume, geoBITS.kVisThis, 1); - expect(testGeoBit(volume, geoBITS.kVisThis)).toBeTrue(); + setGeoBit(volume, GeoAttBits.kVisThis, 1); + expect(testGeoBit(volume, GeoAttBits.kVisThis)).toBeTrue(); }); it('should clear the bit if value is 0', () => { - volume.fGeoAtt = geoBITS.kVisThis; - setGeoBit(volume, geoBITS.kVisThis, 0); - expect(testGeoBit(volume, geoBITS.kVisThis)).toBeFalse(); + volume.fGeoAtt = GeoAttBits.kVisThis; + setGeoBit(volume, GeoAttBits.kVisThis, 0); + expect(testGeoBit(volume, GeoAttBits.kVisThis)).toBeFalse(); }); it('should not modify fGeoAtt if it is undefined', () => { volume.fGeoAtt = undefined; - setGeoBit(volume, geoBITS.kVisThis, 1); + setGeoBit(volume, GeoAttBits.kVisThis, 1); expect(volume.fGeoAtt).toBeUndefined(); }); }); describe('toggleGeoBit', () => { it('should toggle the bit from 0 to 1', () => { - toggleGeoBit(volume, geoBITS.kVisThis); - expect(testGeoBit(volume, geoBITS.kVisThis)).toBeTrue(); + toggleGeoBit(volume, GeoAttBits.kVisThis); + expect(testGeoBit(volume, GeoAttBits.kVisThis)).toBeTrue(); }); it('should toggle the bit from 1 to 0', () => { - volume.fGeoAtt = geoBITS.kVisThis; - toggleGeoBit(volume, geoBITS.kVisThis); - expect(testGeoBit(volume, geoBITS.kVisThis)).toBeFalse(); + volume.fGeoAtt = GeoAttBits.kVisThis; + toggleGeoBit(volume, GeoAttBits.kVisThis); + expect(testGeoBit(volume, GeoAttBits.kVisThis)).toBeFalse(); }); it('should not modify fGeoAtt if it is undefined', () => { volume.fGeoAtt = undefined; - toggleGeoBit(volume, geoBITS.kVisThis); + toggleGeoBit(volume, GeoAttBits.kVisThis); expect(volume.fGeoAtt).toBeUndefined(); }); }); diff --git a/firebird-ng/src/app/utils/cern-root.utils.ts b/firebird-ng/src/app/utils/cern-root.utils.ts index 46da754..d69d142 100644 --- a/firebird-ng/src/app/utils/cern-root.utils.ts +++ b/firebird-ng/src/app/utils/cern-root.utils.ts @@ -2,6 +2,7 @@ import { wildCardCheck } from './wildcard'; + export type GeoNodeWalkCallback = (node: any, nodeFullPath: string, level: number) => boolean; export function walkGeoNodes(node: any, callback: GeoNodeWalkCallback|null, maxLevel = 0, level = 0, path = "", pattern?: string) { @@ -64,12 +65,15 @@ export function findSingleGeoNode(topNode: any, pattern:string, maxLevel:number= return result[0].geoNode; } -export enum PruneRuleActions { - Nothing, /// Do not remove this or other nodes - Remove, /// Removes this node - RemoveSiblings, /// Remove all sibling nodes and leave only this node - RemoveChildren, /// Remove all doughter nodes - RemoveBySubLevel /// Remove all nodes below N levels +export enum EditActions { + Nothing, /** Do not remove this or other nodes */ + Remove, /** Removes this node from parent */ + RemoveSiblings, /** Remove all sibling nodes and leave only this node */ + RemoveChildren, /** Remove all child nodes */ + RemoveBySubLevel, /** Remove all nodes below N levels */ + SetGeoBit, /** Set certain Root GeoAtt bit */ + UnsetGeoBit, /** Unset certain ROOT GeoAtt bit */ + ToggleGeoBit /** Toggle certain ROOT GeoAtt bit */ } /** @@ -77,8 +81,11 @@ export enum PruneRuleActions { */ export class GeoNodeEditRule { public pattern: string = ''; - public prune: PruneRuleActions = PruneRuleActions.Nothing; - public pruneSubLevel?: number = Infinity + public action: EditActions = EditActions.Nothing; + public pruneSubLevel?: number = Infinity; + + /** Used only if action is one of SetGeoBit, UnsetGeoBit or ToggleGeoBit */ + public geoBit?:GeoAttBits; } /** @@ -111,19 +118,39 @@ export function editGeoNodes(topNode: any, rules: GeoNodeEditRule[], maxLevel:nu if(wildCardCheck(nodeFullPath, rule.pattern)) { // Remove this geo node - if(rule.prune == PruneRuleActions.Remove) { + if(rule.action === EditActions.Remove) { removeGeoNode(node); return false; // Don't go through children } // Remove all daughters - if(rule.prune == PruneRuleActions.RemoveChildren) { + if(rule.action === EditActions.RemoveChildren) { removeChildren(node); - return false; + return false; // don't process children + } + + // (!) All next actions may need to process children + + if(rule.action === EditActions.RemoveSiblings) { + // Add a node to matches to process them after + postponedProcessing.push({ node: node, fullPath: nodeFullPath, rule: rule }); } - // Add a node to matches - postponedProcessing.push({ node: node, fullPath: nodeFullPath, rule: rule }); + if(rule.action === EditActions.SetGeoBit){ + if(rule.geoBit !== undefined) { + setGeoBit(node.fVolume, rule.geoBit, 1); + } + } + if(rule.action === EditActions.UnsetGeoBit){ + if(rule.geoBit !== undefined) { + setGeoBit(node.fVolume, rule.geoBit, 0); + } + } + if(rule.action === EditActions.ToggleGeoBit){ + if(rule.geoBit !== undefined) { + toggleGeoBit(node.fVolume, rule.geoBit); + } + } } } return true; // Just continue @@ -141,7 +168,7 @@ export function editGeoNodes(topNode: any, rules: GeoNodeEditRule[], maxLevel:nu let siblings = motherVolume?.fNodes?.arr // Remove siblings but keep this one - if(rule.prune == PruneRuleActions.RemoveSiblings) { + if(rule.action == EditActions.RemoveSiblings) { if (siblings) { motherVolume.fNodes.arr = [node] } @@ -152,7 +179,7 @@ export function editGeoNodes(topNode: any, rules: GeoNodeEditRule[], maxLevel:nu } // Remove daughters by sublevels - if(rule.prune == PruneRuleActions.RemoveBySubLevel) { + if(rule.action == EditActions.RemoveBySubLevel) { let pruneSubNodes = getGeoNodesByLevel(node, rule.pruneSubLevel); for (const pruneSubNodeItem of pruneSubNodes) { removeGeoNode(pruneSubNodeItem.geoNode); @@ -250,7 +277,7 @@ function BIT(n:number) { return 1 << n; } /** @summary TGeo-related bits * @private */ -export enum geoBITS { +export enum GeoAttBits { kVisOverride = BIT(0), // volume's vis. attributes are overwritten kVisNone= BIT(1), // the volume/node is invisible, as well as daughters kVisThis= BIT(2), // this volume/node is visible @@ -267,7 +294,7 @@ export enum geoBITS { /** @summary Test fGeoAtt bits * @private */ -export function testGeoBit(volume:any , f: geoBITS) { +export function testGeoBit(volume:any , f: GeoAttBits) { const att = volume.fGeoAtt; return att === undefined ? false : ((att & f) !== 0); } @@ -275,14 +302,14 @@ export function testGeoBit(volume:any , f: geoBITS) { /** @summary Set fGeoAtt bit * @private */ -export function setGeoBit(volume:any, f: geoBITS, value: number) { +export function setGeoBit(volume:any, f: GeoAttBits, value: number) { if (volume.fGeoAtt === undefined) return; volume.fGeoAtt = value ? (volume.fGeoAtt | f) : (volume.fGeoAtt & ~f); } /** @summary Toggle fGeoAttBit * @private */ -export function toggleGeoBit(volume:any, f: geoBITS) { +export function toggleGeoBit(volume:any, f: GeoAttBits) { if (volume.fGeoAtt !== undefined) volume.fGeoAtt = volume.fGeoAtt ^ (f & 0xffffff); } @@ -292,18 +319,18 @@ export function toggleGeoBit(volume:any, f: geoBITS) { * @private */ export function printAllGeoBitsStatus(volume:any) { const bitDescriptions = [ - { name: 'kVisOverride ', bit: geoBITS.kVisOverride }, - { name: 'kVisNone ', bit: geoBITS.kVisNone }, - { name: 'kVisThis ', bit: geoBITS.kVisThis }, - { name: 'kVisDaughters ', bit: geoBITS.kVisDaughters }, - { name: 'kVisOneLevel ', bit: geoBITS.kVisOneLevel }, - { name: 'kVisStreamed ', bit: geoBITS.kVisStreamed }, - { name: 'kVisTouched ', bit: geoBITS.kVisTouched }, - { name: 'kVisOnScreen ', bit: geoBITS.kVisOnScreen }, - { name: 'kVisContainers', bit: geoBITS.kVisContainers }, - { name: 'kVisOnly ', bit: geoBITS.kVisOnly }, - { name: 'kVisBranch ', bit: geoBITS.kVisBranch }, - { name: 'kVisRaytrace ', bit: geoBITS.kVisRaytrace } + { name: 'kVisOverride ', bit: GeoAttBits.kVisOverride }, + { name: 'kVisNone ', bit: GeoAttBits.kVisNone }, + { name: 'kVisThis ', bit: GeoAttBits.kVisThis }, + { name: 'kVisDaughters ', bit: GeoAttBits.kVisDaughters }, + { name: 'kVisOneLevel ', bit: GeoAttBits.kVisOneLevel }, + { name: 'kVisStreamed ', bit: GeoAttBits.kVisStreamed }, + { name: 'kVisTouched ', bit: GeoAttBits.kVisTouched }, + { name: 'kVisOnScreen ', bit: GeoAttBits.kVisOnScreen }, + { name: 'kVisContainers', bit: GeoAttBits.kVisContainers }, + { name: 'kVisOnly ', bit: GeoAttBits.kVisOnly }, + { name: 'kVisBranch ', bit: GeoAttBits.kVisBranch }, + { name: 'kVisRaytrace ', bit: GeoAttBits.kVisRaytrace } ]; console.log(`fGeoAttr for ${volume._typename}: ${volume.fName}`); diff --git a/firebird-ng/src/app/utils/config-property.spec.ts b/firebird-ng/src/app/utils/config-property.spec.ts new file mode 100644 index 0000000..4bf50b7 --- /dev/null +++ b/firebird-ng/src/app/utils/config-property.spec.ts @@ -0,0 +1,47 @@ +import {ConfigProperty} from "./config-property" + +describe('ConfigProperty', () => { + let configProperty: ConfigProperty; + let mockSaveCallback: jasmine.Spy; + let defaultValue: string; + let key: string; + + beforeEach(() => { + key = 'testKey'; + defaultValue = 'default'; + mockSaveCallback = jasmine.createSpy('saveCallback'); + + configProperty = new ConfigProperty(key, defaultValue, mockSaveCallback); + }); + + it('should initialize with default value if no value in localStorage', () => { + localStorage.removeItem(key); + configProperty = new ConfigProperty(key, defaultValue); + expect(configProperty.value).toBe(defaultValue); + }); + + it('should use stored value if available', () => { + const storedValue = "stored"; + localStorage.setItem(key, storedValue); + configProperty = new ConfigProperty(key, defaultValue); + expect(configProperty.value).toBe(storedValue); + }); + + it('should call saveCallback when setting value', () => { + const newValue = 'new value'; + configProperty.value = newValue; + expect(configProperty.value).toBe(newValue); + expect(mockSaveCallback).toHaveBeenCalled(); + }); + + it('should not change value if validator fails', () => { + const badValue = 'bad'; + configProperty = new ConfigProperty(key, defaultValue, mockSaveCallback, (value) => false); + configProperty.value = badValue; + + expect(configProperty.value).toBe(defaultValue); // Still the default, not the bad value + expect(mockSaveCallback).not.toHaveBeenCalled(); + }); + + // Additional tests to cover other scenarios... +}); diff --git a/firebird-ng/src/app/utils/config-property.ts b/firebird-ng/src/app/utils/config-property.ts new file mode 100644 index 0000000..20f49de --- /dev/null +++ b/firebird-ng/src/app/utils/config-property.ts @@ -0,0 +1,108 @@ +import {BehaviorSubject, Observable} from 'rxjs'; + + +/** + * Storage general interface for storing ConfigProperty-ies. + * ConfigProperty uses the storage to save and load values + */ +interface ConfigPropertyStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +/** + * Use local storage to save load ConfigProperty + */ +class ConfigPropertyLocalStorage implements ConfigPropertyStorage { + getItem(key: string): string | null { + return localStorage.getItem(key); + } + + setItem(key: string, value: string): void { + localStorage.setItem(key, value); + } +} + +/** + * Manages an individual configuration property. Provides reactive updates to subscribers, + * persistence to localStorage, and optional value validation. + * + * @template T The type of the configuration value. + */ +export class ConfigProperty { + private subject: BehaviorSubject; + + /** Observable for subscribers to react to changes in the property value. */ + public changes$: Observable; + + /** + * Creates an instance of ConfigProperty. + * + * @param {string} key The localStorage key under which the property value is stored. + * @param {T} defaultValue The default value of the property if not previously stored. + * @param storage + * @param {() => void} saveCallback The callback to execute after setting a new value. + * @param {(value: T) => boolean} [validator] Optional validator function to validate the property value. + */ + constructor( + private key: string, + private defaultValue: T, + private saveCallback?: () => void, + private validator?: (value: T) => boolean, + private storage: ConfigPropertyStorage = new ConfigPropertyLocalStorage(), + ) { + const value = this.loadValue(); + this.subject = new BehaviorSubject(value); + this.changes$ = this.subject.asObservable(); + } + + + /** + * Loads the property value from localStorage or returns the default value if not found or invalid. + * + * @returns {T} The loaded or default value of the property. + */ + private loadValue(): T { + try { + let storedValue = this.storage.getItem(this.key); + let parsedValue: any; + if (storedValue !== null) { + parsedValue = (typeof this.defaultValue) !== 'string' ? JSON.parse(storedValue) : storedValue; + } else { + parsedValue = this.defaultValue; + } + return this.validator && !this.validator(parsedValue) ? this.defaultValue : parsedValue; + } catch (error) { + console.error('Error loading value:', error); + return this.defaultValue; + } + } + + /** + * Sets the property value after validation. If the value is valid, it updates the property and calls the save callback. + * + * @param {T} value The new value to set for the property. + */ + set value(value: T) { + if (this.validator && !this.validator(value)) { + console.error('Validation failed for:', value); + return; + } + this.storage.setItem(this.key, typeof value !== 'string' ? JSON.stringify(value) : value); + + if(this.saveCallback) { + this.saveCallback(); + } + + this.subject.next(value); + } + + /** + * Gets the current value of the property. + * + * @returns {T} The current value of the property. + */ + get value(): T { + return this.subject.value; + } +} diff --git a/firebird-ng/src/app/utils/phoenix-three-facade.ts b/firebird-ng/src/app/utils/phoenix-three-facade.ts new file mode 100644 index 0000000..968aa2d --- /dev/null +++ b/firebird-ng/src/app/utils/phoenix-three-facade.ts @@ -0,0 +1,42 @@ +import * as THREE from "three" +import {EventDisplay} from "phoenix-event-display"; +import {EventDisplayService} from "phoenix-ui-components"; + +export class PhoenixThreeFacade { + + public phoenixEventDisplay: EventDisplay; + + public get phoenixThreeManager() { + return this.phoenixEventDisplay.getThreeManager(); + } + + public get activeOrbitControls() { + return (this.phoenixThreeManager as any).controlsManager.getActiveControls(); + } + + + public get mainCamera() { + return (this.phoenixThreeManager as any).controlsManager.getMainCamera(); + } + + public get activeCamera() { + return (this.phoenixThreeManager as any).controlsManager.getActiveCamera(); + } + + public get scene() { + return this.phoenixThreeManager.getSceneManager().getScene(); + } + + public get sceneGeometries() { + return this.phoenixThreeManager.getSceneManager().getGeometries(); + } + + public get sceneEvent() { + return this.phoenixThreeManager.getSceneManager().getEventData(); + } + + constructor(eventDisplay: EventDisplay) { + this.phoenixEventDisplay = eventDisplay; + } + +} diff --git a/firebird-ng/src/app/utils/three-geometry-merge.ts b/firebird-ng/src/app/utils/three-geometry-merge.ts new file mode 100644 index 0000000..d238490 --- /dev/null +++ b/firebird-ng/src/app/utils/three-geometry-merge.ts @@ -0,0 +1,155 @@ +import * as THREE from "three"; +import {mergeGeometries} from "three/examples/jsm/utils/BufferGeometryUtils"; + +export interface MergeResult { + mergedGeometry: THREE.BufferGeometry; + mergedMesh: THREE.Mesh; + material: THREE.Material | undefined; + childrenToRemove: THREE.Object3D[]; + parentNode: THREE.Object3D; +} + +export class NoGeometriesFoundError extends Error { + constructor(message: string = "No geometries found in the provided node.") { + super(message); + this.name = "NoGeometriesFoundError"; + } +} + +export class NoMaterialError extends Error { + constructor(message: string = "No material set or found in geometries.") { + super(message); + this.name = "NoMaterialError"; + } +} + +/** + * Merges all geometries in a branch of the scene graph into a single geometry. + * @param parentNode The parent node of the branch to merge. + * @param name Name of the new merged geometry node + * @param material Material to assign to the merged geometry, if empty the first material found will be used + * @returns MergeResult object containing the merged geometry, material, children to remove, and parent node + */ +export function mergeBranchGeometries(parentNode: THREE.Object3D, name: string, material?: THREE.Material | undefined): MergeResult { + const geometries: THREE.BufferGeometry[] = []; + const childrenToRemove: THREE.Object3D[] = []; + + // Recursively collect geometries from the branch + const collectGeometries = (node: THREE.Object3D): void => { + node.traverse((child: any) => { + + let isBufferGeometry = child?.geometry?.isBufferGeometry ?? false; + //console.log(isBufferGeometry); + if (isBufferGeometry) { + child.updateMatrixWorld(true); + const clonedGeometry = child.geometry.clone(); + clonedGeometry.applyMatrix4(child.matrixWorld); + geometries.push(clonedGeometry); + material = material || child.material; + childrenToRemove.push(child); + } + }); + }; + + collectGeometries(parentNode); + + if (geometries.length === 0) { + throw new NoGeometriesFoundError(); + } else if (material === undefined) { + throw new NoMaterialError(); + } + + // Merge all collected geometries + const mergedGeometry = mergeGeometries(geometries, false); + + // Transform the merged geometry to the local space of the parent node + const parentInverseMatrix = new THREE.Matrix4().copy(parentNode.matrixWorld).invert(); + mergedGeometry.applyMatrix4(parentInverseMatrix); + + // Create a new mesh with the merged geometry and the collected material + const mergedMesh = new THREE.Mesh(mergedGeometry, material); + + // Remove the original children that are meshes and add the new merged mesh + // Remove and dispose the original children + childrenToRemove.forEach((child: any) => { + child.geometry.dispose(); + child?.parent?.remove(child); + // Remove empty parents + if( (child?.parent?.children?.length ?? 1) === 0 ) { + child?.parent?.parent?.remove(child.parent); + } + }); + + mergedMesh.name = name; + + parentNode.add(mergedMesh); + return { + mergedGeometry, + mergedMesh, + material, + childrenToRemove, + parentNode + }; +} + + +/** + * Merges all geometries from a list of meshes into a single geometry and attaches it to a new parent node. + * @param meshes An array of THREE.Mesh objects whose geometries are to be merged. + * @param parentNode The new parent node to which the merged mesh will be added. + * @param name The name to assign to the merged mesh. + * + * (!) This function doesn't delete original meshes Compared to @see mergeBranchGeometries. + * Use MergeResult.childrenToRemove to delete meshes that were merged + * + * @param material + * @returns MergeResult The result of the merging process including the new parent node, merged geometry, material, and a list of original meshes. + */ +export function mergeMeshList(meshes: THREE.Mesh[], parentNode: THREE.Object3D, name: string, material?: THREE.Material|undefined): MergeResult { + const geometries: THREE.BufferGeometry[] = []; + + // Collect geometries and materials from the provided meshes + meshes.forEach(mesh => { + if (mesh?.geometry?.isBufferGeometry) { + mesh.updateMatrixWorld(true); + const clonedGeometry = mesh.geometry.clone(); + clonedGeometry.applyMatrix4(mesh.matrixWorld); + geometries.push(clonedGeometry); + // Check if mesh.material is an array and handle it + if (!material) { // Only set if material has not been set yet + if (Array.isArray(mesh.material)) { + material = mesh.material[0]; // Use the first material if it's an array + } else { + material = mesh.material; // Use the material directly if it's not an array + } + } + } + }); + + if (geometries.length === 0) { + throw new NoGeometriesFoundError(); + } + if (!material) { + throw new NoMaterialError(); + } + + // Merge all collected geometries + const mergedGeometry = mergeGeometries(geometries, false); + + // Transform the merged geometry to the local space of the parent node + const parentInverseMatrix = new THREE.Matrix4().copy(parentNode.matrixWorld).invert(); + mergedGeometry.applyMatrix4(parentInverseMatrix); + + // Create a new mesh with the merged geometry and the collected material + const mergedMesh = new THREE.Mesh(mergedGeometry, material); + mergedMesh.name = name; + parentNode.add(mergedMesh); + + return { + mergedGeometry, + mergedMesh, + material, + childrenToRemove: meshes, // Here, we assume the original meshes are what would be removed if needed + parentNode + }; +} diff --git a/firebird-ng/src/app/utils/three.utils.spec.ts b/firebird-ng/src/app/utils/three.utils.spec.ts new file mode 100644 index 0000000..f25533b --- /dev/null +++ b/firebird-ng/src/app/utils/three.utils.spec.ts @@ -0,0 +1,147 @@ +import {walkObject3DNodes, NodeWalkCallback, findObject3DNodes} from './three.utils'; +import * as THREE from 'three'; +import { isColorable, getColorOrDefault } from './three.utils'; + +describe('walkObject3dNodes', () => { + let root: THREE.Object3D; + let callback: jasmine.Spy; + + beforeEach(() => { + // Create a simple scene graph: root -> child -> grandchild + root = new THREE.Object3D(); + root.name = 'root'; + + const child = new THREE.Object3D(); + child.name = 'child'; + root.add(child); + + const grandchild = new THREE.Object3D(); + grandchild.name = 'grandchild'; + child.add(grandchild); + + // Setup callback spy + callback = jasmine.createSpy('callback'); + }); + + it('should invoke callback for each node when no pattern is given', () => { + walkObject3DNodes(root, callback); + expect(callback.calls.count()).toBe(3); + expect(callback.calls.argsFor(0)).toEqual([root, 'root', 0]); + expect(callback.calls.argsFor(1)).toEqual([root.children[0], 'root/child', 1]); + expect(callback.calls.argsFor(2)).toEqual([root.children[0].children[0], 'root/child/grandchild', 2]); + }); + + it('should respect the maxLevel parameter', () => { + walkObject3DNodes(root, callback, {maxLevel:1}); + expect(callback.calls.count()).toBe(2); + }); + + it('should correctly match nodes when a pattern is given', () => { + const pattern = 'root/child'; + walkObject3DNodes(root, callback, {pattern: pattern}); + expect(callback).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'child' }), 'root/child', 1); + }); + + it('should return the correct number of processed nodes', () => { + const processed = walkObject3DNodes(root, callback); + expect(processed).toBe(3); // Including root, child, grandchild + }); + + it('should not invoke callback for non-matching pattern', () => { + const pattern = 'nonexistent'; + walkObject3DNodes(root, callback, {pattern: pattern}); + expect(callback).not.toHaveBeenCalled(); + }); +}); + + +describe('Material color functions', () => { + describe('isColorable', () => { + it('should return true if material has a color property', () => { + const colorableMaterial = { color: new THREE.Color(255, 0, 0) }; + expect(isColorable(colorableMaterial)).toBeTrue(); + }); + + it('should return false if material does not have a color property', () => { + const nonColorableMaterial = { noColor: true }; + expect(isColorable(nonColorableMaterial)).toBeFalse(); + }); + }); + + describe('getColorOrDefault', () => { + it('should return the material color if material is colorable', () => { + const colorableMaterial = { color: new THREE.Color(255, 0, 0) }; + const defaultColor = new THREE.Color(0, 0, 0); + expect(getColorOrDefault(colorableMaterial, defaultColor)).toEqual(colorableMaterial.color); + }); + + it('should return the default color if material is not colorable', () => { + const nonColorableMaterial = { noColor: true }; + const defaultColor = new THREE.Color(0, 0, 0); + expect(getColorOrDefault(nonColorableMaterial, defaultColor)).toEqual(defaultColor); + }); + }); +}); + +describe('findObject3DNodes', () => { + let root:THREE.Object3D, child1:THREE.Object3D, child2:THREE.Object3D, subchild1:THREE.Object3D, subchild2:THREE.Object3D; + + beforeEach(() => { + // Setup a mock hierarchy of THREE.Object3D nodes + root = new THREE.Object3D(); + root.name = 'root'; + + child1 = new THREE.Object3D(); + child1.name = 'child1'; + root.add(child1); + + child2 = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); + child2.name = 'child2'; + root.add(child2); + + subchild1 = new THREE.Object3D(); + subchild1.name = 'subchild1'; + child1.add(subchild1); + + subchild2 = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); + subchild2.name = 'match'; + child2.add(subchild2); + }); + + it('should return all nodes when no pattern or type is provided', () => { + const results = findObject3DNodes(root, ''); + expect(results.nodes.length).toBe(5); + expect(results.deepestLevel).toBe(2); + expect(results.totalWalked).toBe(5); + }); + + it('should handle no matches found', () => { + const results = findObject3DNodes(root, 'nonexistent'); + expect(results.nodes.length).toBe(0); + }); + + it('should match nodes based on a pattern', () => { + const results = findObject3DNodes(root, '**/match'); + expect(results.nodes.length).toBe(1); + expect(results.nodes[0]).toBe(subchild2); + expect(results.fullPaths[0]).toBe('root/child2/match'); + }); + + it('should filter nodes based on type', () => { + const results = findObject3DNodes(root, '', 'Mesh'); + expect(results.nodes.every(node => node instanceof THREE.Mesh)).toBeTrue(); + expect(results.nodes.length).toBe(2); // Only child2 and subchild2 are Meshes + }); + + it('should respect the maxLevel parameter', () => { + const results = findObject3DNodes(root, '', '', 1); + expect(results.deepestLevel).toBe(1); + expect(results.totalWalked).toBe(3); // root, child1, child2 + }); + + it('should throw an error for invalid parentNode', () => { + expect(() => { + findObject3DNodes(null, ''); + }).toThrow(); + }); +}); diff --git a/firebird-ng/src/app/utils/three.utils.ts b/firebird-ng/src/app/utils/three.utils.ts new file mode 100644 index 0000000..36a590a --- /dev/null +++ b/firebird-ng/src/app/utils/three.utils.ts @@ -0,0 +1,290 @@ +import outmatch from 'outmatch'; + +import * as THREE from "three"; +import {mergeGeometries} from 'three/examples/jsm/utils/BufferGeometryUtils'; +import {GeoNodeWalkCallback, walkGeoNodes} from "./cern-root.utils"; +import {MergeResult} from "./three-geometry-merge"; + +export type NodeWalkCallback = (node: any, nodeFullPath: string, level: number) => boolean; + +interface NodeWalkOptions { + maxLevel?: number; + level?: number; + parentPath?: string; + pattern?: any; +} + +export function walkObject3DNodes(node: any, callback: NodeWalkCallback|null, options: NodeWalkOptions={}):number { + + // Dereference options + let { maxLevel = Infinity, level = 0, parentPath = "", pattern=null } = options; + + if(!level) { + level = 0; + } + + // Check pattern is string or not? + // We assume the pattern is an outmatch object or a string. We need compiled outmatch + if(pattern) { + pattern = typeof pattern === "string"? outmatch(pattern): pattern; + } + + const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name; + let processedNodes = 1; + + // Only invoke the callback if no pattern is provided or if the pattern matches the fullPath + if (!pattern || pattern(fullPath)) { + if(callback){ + callback(node, fullPath, level); + } + } + + // Continue recursion to child nodes if they exist and the max level is not reached + if (node?.children && level < maxLevel) { + // Iterate backwards so if elements of array are removed it is safe + // Still removing elements are discouraged + for (let i = node.children.length-1; i >= 0; i--) { + let child = node.children[i]; + if (child) { + processedNodes += walkObject3DNodes(child, callback, {maxLevel, level: level + 1, parentPath: fullPath, pattern}); + } + } + } + + return processedNodes; +} + +/** + * Represents the results of a node searching operation within a THREE.Object3D hierarchy. + * + * @interface FindResults + * @property {any[]} nodes - An array of nodes that matched the search criteria. These nodes are part of the THREE.Object3D hierarchy. + * @property {string[]} fullPaths - An array of strings, each representing the full path to a corresponding node in the `nodes` array. The full path is constructed by concatenating parent node names, providing a clear hierarchical structure. + * @property {number} deepestLevel - The deepest level reached in the hierarchy during the search. This value helps understand the depth of the search and which level had the last matched node. + * @property {number} totalWalked - The total number of nodes visited during the search process. This count includes all nodes checked, regardless of whether they matched the criteria. + */ +export interface FindResults { + nodes: any[]; + fullPaths: string[]; + deepestLevel: number; + totalWalked: number; +} + +/** + * Searches for and collects nodes in a THREE.Object3D hierarchy based on a given pattern and type. + * + * @param parentNode The root node of the hierarchy to search within. + * @param pattern A string pattern to match node names against. + * @param matchType Optional filter to restrict results to nodes of a specific type. + * @param maxLevel Maximum depth to search within the node hierarchy. + * @returns FindResults Object containing the results of the search: + * - nodes: Array of nodes that match the criteria. + * - fullPaths: Array of full path strings corresponding to each matched node. + * - matches: Total number of nodes that matched the search criteria. + * - deepestLevel: The deepest level reached in the hierarchy during the search. + * - totalWalked: Total number of nodes visited during the search. + */ +export function findObject3DNodes(parentNode: any, pattern: string, matchType: string = "", maxLevel: number = Infinity): FindResults { + let nodes: any[] = []; + let fullPaths: string[] = []; + let deepestLevel = 0; + + // Define a callback using the NodeWalkCallback type + const collectNodes: NodeWalkCallback = (node, fullPath, level) => { + if (!matchType || matchType === node.type) { + nodes.push(node); + fullPaths.push(fullPath); + if (level > deepestLevel) { + deepestLevel = level; + } + } + return true; // Continue traversal + }; + + // Use walkObject3DNodes with the collecting callback and the pattern + let totalWalked = walkObject3DNodes(parentNode, collectNodes, { maxLevel, pattern }); + + return { + nodes, + fullPaths, + deepestLevel, + totalWalked + }; +} + + +export interface Colorable { + color: THREE.Color; +} + + +/** + * Type guard function to check if the material is colorable. + * @param material - The material to check. + * @returns true if the material has a 'color' property, false otherwise. + */ +export function isColorable(material: any): material is Colorable { + return 'color' in material; +} + + +/** + * Retrieves the color of a material if it is colorable; otherwise, returns a default color. + * @param material - The material whose color is to be retrieved. + * @param defaultColor - The default color to return if the material is not colorable. + * @returns The color of the material if colorable, or the default color. + */ +export function getColorOrDefault(material:any, defaultColor: THREE.Color): THREE.Color { + if (isColorable(material)) { + return material.color; + } else { + return defaultColor; + } +} + + +/** Throws an error if the mesh/object does not contain geometry. */ +class NoGeometryError extends Error { + mesh = undefined; + constructor(mesh: any, message: string = "Mesh (or whatever is provided) does not contain geometry.") { + super(message); + this.name = "InvalidMeshError"; + this.mesh = mesh; + } +} + +export interface CreateOutlineOptions { + color?: THREE.ColorRepresentation; + material?: THREE.Material; + thresholdAngle?: number; +} + +/** + * Applies an outline mesh from lines to a mesh and adds the outline to the mesh's parent. + * @param mesh A THREE.Object3D (expected to be a Mesh) to process. + * @param options + */ +export function createOutline(mesh: any, options: CreateOutlineOptions = {}): void { + if (!mesh?.geometry) { + throw new NoGeometryError(mesh); + } + + let { color = 0x555555, material, thresholdAngle = 40 } = options || {}; + + let edges = new THREE.EdgesGeometry(mesh.geometry, thresholdAngle); + let lineMaterial = material as THREE.LineBasicMaterial; + + if (!lineMaterial) { + lineMaterial = new THREE.LineBasicMaterial({ + color: color ?? new THREE.Color(0x555555), + fog: false, + clippingPlanes: mesh.material?.clippingPlanes ? mesh.material.clippingPlanes : [], + clipIntersection: false, + clipShadows: true, + transparent: true + }); + } + + // Create a mesh with the outline + const edgesLine = new THREE.LineSegments(edges, lineMaterial); + edgesLine.name = (mesh.name ?? "") + "_outline"; + edgesLine.userData = {}; + + // Add to parent + mesh.updateMatrixWorld(true); + mesh?.parent?.add(edgesLine); +} + + +type ExtendedMaterialProperties = { + map?: THREE.Texture | null, + lightMap?: THREE.Texture | null, + bumpMap?: THREE.Texture | null, + normalMap?: THREE.Texture | null, + specularMap?: THREE.Texture | null, + envMap?: THREE.Texture | null, + alphaMap?: THREE.Texture | null, + aoMap?: THREE.Texture | null, + displacementMap?: THREE.Texture | null, + emissiveMap?: THREE.Texture | null, + gradientMap?: THREE.Texture | null, + metalnessMap?: THREE.Texture | null, + roughnessMap?: THREE.Texture | null, +}; + +function disposeMaterial(material: any): void { + const extMaterial = material as THREE.Material & ExtendedMaterialProperties; + + if (material?.map) material.map.dispose (); + if (material?.lightMap) material.lightMap.dispose (); + if (material?.bumpMap) material.bumpMap.dispose (); + if (material?.normalMap) material.normalMap.dispose (); + if (material?.specularMap) material.specularMap.dispose (); + if (material?.envMap) material.envMap.dispose (); + if (material?.alphaMap) material.alphaMap.dispose(); + if (material?.aoMap) material.aoMap.dispose(); + if (material?.displacementMap) material.displacementMap.dispose(); + if (material?.emissiveMap) material.emissiveMap.dispose(); + if (material?.gradientMap) material.gradientMap.dispose(); + if (material?.metalnessMap) material.metalnessMap.dispose(); + if (material?.roughnessMap) material.roughnessMap.dispose(); + + if ('dispose' in material) { + material.dispose(); // Dispose the material itself + } +} + +export function disposeNode(node: any): void { + // Dispose geometry + if (node?.geometry) { + node.geometry.dispose(); + } + + // Dispose materials + if (node?.material) { + if (Array.isArray(node.material)) { + node.material.forEach(disposeMaterial); + } else { + disposeMaterial(node.material); + } + } + + node.removeFromParent(); +} + +export function disposeOriginalMeshesAfterMerge(mergeResult: MergeResult) { + // Remove initial nodes + for (let i=mergeResult.childrenToRemove.length-1; i>=0; i-- ) { + disposeNode(mergeResult.childrenToRemove[i]); + mergeResult.childrenToRemove[i].removeFromParent(); + } +} + +export function disposeHierarchy(node: THREE.Object3D): void { + node.children.slice().reverse().forEach(child => { + disposeHierarchy(child); + }); + disposeNode(node); +} + +/** + * Recursively removes empty branches from a THREE.Object3D tree. + * An empty branch is a node without geometry and without any non-empty children. + * @param node - The starting node to prune empty branches from. + * + * Removing useless nodes that were left without geometries speeds up overall rendering + * + */ +export function pruneEmptyNodes(node: THREE.Object3D): void { + // Traverse children from last to first to avoid index shifting issues after removal + for (let i = node.children.length - 1; i >= 0; i--) { + pruneEmptyNodes(node.children[i]); // Recursively prune children first + } + + // After pruning children, determine if the current node is now empty + if (node.children.length === 0 && !((node as any)?.geometry)) { + node.removeFromParent(); + } +} + + diff --git a/firebird-ng/src/assets/diagrams/geometry-pipeline.drawio b/firebird-ng/src/assets/diagrams/geometry-pipeline.drawio new file mode 100644 index 0000000..9fad8ef --- /dev/null +++ b/firebird-ng/src/assets/diagrams/geometry-pipeline.drawio @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firebird-ng/src/assets/diagrams/geometry-pipeline.svg b/firebird-ng/src/assets/diagrams/geometry-pipeline.svg new file mode 100644 index 0000000..6f53cd4 --- /dev/null +++ b/firebird-ng/src/assets/diagrams/geometry-pipeline.svg @@ -0,0 +1,4 @@ + + + +Geometry SourceGeometry SourceROOT geometry optimizationROOT...WebGL/Three.jsOptimizationPrettificationWebGL/Three.js...Render!Render!Text is not SVG - cannot display \ No newline at end of file diff --git a/firebird-ng/src/assets/dirc_event.zip b/firebird-ng/src/assets/dirc_event.zip new file mode 100644 index 0000000..905706b Binary files /dev/null and b/firebird-ng/src/assets/dirc_event.zip differ
Event data
Geometries
Scene