Back to home page

EIC code displayed by LXR

 
 

    


Warning, /firebird/docs/extensions.md is written in an unsupported language. File is not indexed.

0001 # Extension System
0002 
0003 Firebird is assembled from features: an application declares what it wants —
0004 data types, painters, loaders, commands, settings — and the framework collects
0005 the contributions through Angular dependency injection. Firebird's own
0006 built-ins register through the same API, so anything a built-in can do, your
0007 extension can do.
0008 
0009 The working template is the `packages/firebird-example-extension` package in
0010 the repository: a custom event data type (Cherenkov rings) and its painter
0011 with a configurable ring color, installable with one line.
0012 
0013 > **Note:** Earlier versions used the `@firebird/` scope; replace `@firebird/ng`, `@firebird/core`, `@firebird/root2dex` and `@firebird/example-extension` with `@dexvis/firebird-ng`, `@dexvis/firebird-core`, `@dexvis/root2dex` and `@dexvis/firebird-example-extension`.
0014 
0015 ## Composing an application
0016 
0017 ```ts
0018 // app.config.ts
0019 import { provideFirebird, withAppVersion, withWorkers } from '@dexvis/firebird-ng';
0020 import { withExampleCherenkov } from '@dexvis/firebird-example-extension';
0021 import { withMyExperiment } from './my-experiment';
0022 import { version } from '../../package.json';
0023 
0024 export const appConfig: ApplicationConfig = {
0025   providers: [
0026     provideZonelessChangeDetection(),
0027     provideRouter(routes),
0028     provideHttpClient(withFetch()),
0029     provideFirebird(
0030       withAppVersion(version),
0031       withWorkers({
0032         geometry: () => new Worker(new URL('./workers/geometry.worker', import.meta.url), { type: 'module' }),
0033         rootFile: () => new Worker(new URL('./workers/root-file.worker', import.meta.url), { type: 'module' }),
0034       }),
0035       withMyExperiment(),
0036       withExampleCherenkov(),
0037     ),
0038   ],
0039 };
0040 
0041 // app.routes.ts: Firebird's pages, each loaded lazily
0042 import { firebirdRoutes } from '@dexvis/firebird-ng';
0043 
0044 export const routes: Routes = [
0045   { path: '', redirectTo: '/display', pathMatch: 'full' },
0046   ...firebirdRoutes(),   // display, split-window, config
0047 ];
0048 ```
0049 
0050 The two worker entry modules belong to the application, so that its build
0051 bundles them; each is one call:
0052 
0053 ```ts
0054 // workers/geometry.worker.ts
0055 import { runGeometryWorker } from '@dexvis/firebird-ng/workers/geometry';
0056 runGeometryWorker(self);
0057 
0058 // workers/root-file.worker.ts
0059 import { runRootFileWorker } from '@dexvis/firebird-ng/workers/root-file';
0060 runRootFileWorker(self);
0061 ```
0062 
0063 The geometry worker bundles jsroot's geometry code, which can import
0064 node-only renderers: add `"externalDependencies": ["@resvg/resvg-js",
0065 "canvas"]` to the build options in `angular.json`.
0066 
0067 The pages, `<firebird-display>` and the display services come from
0068 `@dexvis/firebird-ng/display`, which pulls three.js and Angular Material:
0069 reach it through routes or `import()` only. To build a page of your own, put
0070 `<firebird-display (attached)="onAttached()">` where the canvas belongs: it
0071 attaches the display to its element, follows the element's size, loads the
0072 configured geometry and events, and runs the startup commands; `(attached)`
0073 fires once the scene exists.
0074 
0075 `provideFirebird()` installs Firebird's built-ins (data types, painters,
0076 loaders, commands, camera views, the navigation cube, the data selector tabs)
0077 before your features. One contribution per `with*()` call. To ship several
0078 contributions as one installable package (an "experiment pack"), compose them
0079 into a pack with an id:
0080 
0081 ```ts
0082 export function withMyExperiment(): FirebirdFeature {
0083   return firebirdPack('my-experiment',
0084     withEventPiece(MyDataFactory),
0085     withLazyPainter('my.DataType', () => import('./my.painter').then(m => m.MyPainter)),
0086     withDefaultGeometry('https://my.host/detector.root'),
0087   );
0088 }
0089 ```
0090 
0091 The pack id makes the pack replaceable and removable, a pack installed twice
0092 is installed once, and development builds name the pack in collision
0093 warnings. `firebirdFeatures(...)` composes features without an id.
0094 
0095 The flagship app is `provideFirebird(withEpic({...}), withExampleCherenkov())`:
0096 everything Firebird knows about the ePIC detector sits in one pack,
0097 `@dexvis/firebird-epic` (`packages/epic/`), built from the functions on this
0098 page. Its options are config defaults: `withEpic({ geometry: url })` sets the
0099 default geometry, and server config, a value saved on the config page, and a
0100 `?config.` URL value override it.
0101 
0102 ## Identity, precedence and opting out
0103 
0104 Every registry follows one rule. Contributions keep registration order: the
0105 order of the `provideFirebird()` arguments, depth first, after the
0106 built-ins. A later contribution with the id of an earlier one replaces it at
0107 the earlier one's position. Where one entry is picked among several, the
0108 earliest wins: the first loader whose `canLoad()` accepts a source loads it,
0109 and the first painter registered for a piece type is its default. The ids:
0110 
0111 | Registry | Id |
0112 |----------|----|
0113 | Features (`with*()` calls, packs) | the feature id: `firebirdPack(id, ...)`, or derived, for example `url-alias:epic://`, `data-selector-tab:manual`, `camera-preset:home` |
0114 | Event and geometry loaders | `DataLoaderMeta.id` |
0115 | Command handlers | `CommandHandler.type` |
0116 | Piece factories | `EventPieceFactory.type` |
0117 | Painters of one piece type | `PainterMeta.id` |
0118 | Data selector tabs, themes, post-processors, toolbar actions | their `id` |
0119 | Camera presets | their `name` |
0120 
0121 To take over a built-in, register under its id. To drop one, pass
0122 `withoutFeatures()` with its feature id:
0123 
0124 ```ts
0125 provideFirebird(
0126   withMyExperiment(),
0127   withoutFeatures('firebird.navigation-cube', 'data-selector-tab:physics'),
0128 )
0129 ```
0130 
0131 The built-ins are named sub-features, each exported for reuse:
0132 
0133 | Sub-feature | Feature id | Contributes |
0134 |-------------|------------|-------------|
0135 | `withServerConfig()` from `@dexvis/app-features` | `server-config` | The server config URL and Firebird's defaults under it |
0136 | `withFirebirdUrlShorthands()` | `firebird.url-shorthands` | The `?geometry=`, `?dex=` and `?event=` deep-link shorthands |
0137 | `withBoxHits()` | `firebird.box-hits` | BoxHit decoder and painter |
0138 | `withTrajectories()` | `firebird.trajectories` | PointTrajectory decoder and painters |
0139 | `withDexEvents()` | `firebird.dex-events` | DEX `.firebird.json`/`.zip` loader |
0140 | `withRootEventsInBrowser()` | `firebird.root-events-in-browser` | EDM4eic/EDM4hep ROOT conversion in the browser |
0141 | `withServerConversion()` | `firebird.server-conversion` | ROOT conversion through pyrobird (XRootD) |
0142 | `withRootGeometry()` | `firebird.root-geometry` | TGeo geometry loader, the `grey` theme |
0143 | `withFirebirdCommands()` | `firebird.commands` | The command vocabulary |
0144 | `withStandardCameraPresets()` | `firebird.camera-presets` | Face views and `home` |
0145 | `withNavigationCube()` | `firebird.navigation-cube` | The camera navigation cube |
0146 | `withDataSelectorTabs()` | `firebird.data-selector-tabs` | Presets, Physics and Manual tabs |
0147 
0148 The whole built-in pack is `firebird.builtins`. Replacing one of its parts by
0149 id is the intended customization and draws no warning. Dropping a pack
0150 (`withoutFeatures('my-experiment')`) drops everything it contributes,
0151 including its replacements of built-in parts: the built-in versions take
0152 effect again. Development builds warn when two of your packs contribute the
0153 same feature id with different content (for example one URL alias prefix
0154 with two bases), or ship factories for one piece type.
0155 
0156 ## The feature functions
0157 
0158 | Function | Registers | Notes |
0159 |----------|-----------|-------|
0160 | `withEventPiece(FactoryClass)` | A decoder that turns a DEX piece of your `type` into a model object | The model class is plain TypeScript (worker-safe, no Angular) |
0161 | `withPainter(PainterClass, {forPieceType})` | A painter: turns model data into three.js objects | Eager — lands in the initial bundle |
0162 | `withLazyPainter(type, () => import(...))` | Same, loaded on demand | Preferred: painter code pulls three.js material code |
0163 | `withThreeExtension(ExtClass)` | A rendering-machinery hook (see lifecycle below) | Instantiated through DI; `inject()` works in the constructor |
0164 | `withLazyThreeExtension(() => import(...))` | Same, loaded after the scene is up, in its own chunk | For heavy extensions (VR, big overlays) |
0165 | `withGeometryLoader(LoaderClass)` | A geometry file-format/URL-scheme loader | First loader whose `canLoad()` accepts the source wins; see [Loaders](#loaders) |
0166 | `withEventLoader(LoaderClass)` | An event data loader | Same selection rule. `matchesFileExtensions()` claims by `meta.fileExtensions`; a `meta.urlSchemes` entry claims only names with no file extension, so `asset://data/run.root` still reaches the `.root` loader |
0167 | `withWorkers({geometry, rootFile})` | The factories that start Firebird's two web workers from the application's entry modules | Required for ROOT geometry and in-browser ROOT conversion |
0168 | `withAppVersion(version)` | The application version the logo menu shows | |
0169 | `withRootGeometryRules(rules)` | Pre-build TGeo edit rule sets and cut lists | Plain data posted to the geometry worker; see [Geometry pipeline](#geometry-pipeline) |
0170 | `withGeometryTheme({id, label, load})` | A geometry theme selected by `geometry.themeName` | `load` is a dynamic import of the rule sets |
0171 | `withGeometryPostProcessor({id, after, load})` | Main-thread code run after each geometry load | May be async; ordered by `after` |
0172 | `withCameraPreset(preset)` | A named camera view for `camera-preset:<name>`, the cube's home button and the camera panel | Register `home` to pin the start view |
0173 | `withCameraLimits({minDistance, maxDistance})` | Fixed orbit distance limits [mm] | Without it, each geometry load derives them |
0174 | `withCollisionIntro(() => import(...))` | The animation played before an event's time animation | One per application |
0175 | `withToolbarAction({id, icon, label, command})` | A button in the time toolbar that dispatches a command | |
0176 | `withCommandHandler(HandlerClass)` | A command type on the [command bus](/command-bus) | Works from URL, server, and batch immediately |
0177 | `withUrlAlias(prefix, base)` | A URL scheme alias, e.g. `epic://` | |
0178 | `withConfigDefaults({key: value})` | Setting defaults (lowest priority tier) | A pack configures, never locks — every other source overrides |
0179 | `withDefaultGeometry(url)` | The detector geometry loaded when nothing else selects one | Sugar for `withConfigDefaults({'geometry.selectedGeometry': url})` |
0180 | `withDataCatalog({entries, facets, ...})` | Datasets the data selector offers: presets, physics tags, URL lists | Several contributions merge; see below |
0181 | `withDataSelectorTab({id, label, load})` | A tab of the data selector control | `load` is a dynamic import; the component reads and writes `DataSelectionService.draft` |
0182 | `withUrlShorthand(param, commandType)` | A URL query shorthand: `?param=X` runs what `?cmd=commandType:X` runs | Firebird's own `dex`, `geometry` and `event` are shorthands |
0183 
0184 `withCommandHandler`, `withUrlAlias`, `withConfigDefaults`,
0185 `withUrlShorthand` and `withoutFeatures` come from the generic
0186 [`@dexvis/app-features`](https://github.com/dexvis/app-features-ng) package,
0187 together with the feature type (`FirebirdFeature` is its `AppFeature`), the
0188 config registry and the command bus. `@dexvis/firebird-ng` re-exports them;
0189 import everything from `@dexvis/firebird-ng`.
0190 
0191 ## Painter or ThreeExtension?
0192 
0193 If the code turns **data** (event pieces, field maps, geometry) into visuals,
0194 it is a **painter** — time-aware, per-piece, selected by the data's type.
0195 If it hooks the **machinery** — scene lifecycle, frame loop, input, UI — it is
0196 a **ThreeExtension**. Magnetic field lines that visualize a field map are a
0197 painter; a hover-probe that raycasts under the mouse is an extension.
0198 
0199 A built-in example to read: the camera navigation cube
0200 (`packages/firebird-ng/display/src/lib/extensions/viewport-gizmo.extension.ts`) binds the
0201 `@dexvis/viewport-gizmo` package to the scene, controls, frame loop and
0202 command bus, and is registered with `withLazyThreeExtension` inside
0203 `withNavigationCube()`.
0204 
0205 ## ThreeExtension lifecycle
0206 
0207 ```ts
0208 @Injectable()
0209 export class HoverInfoExtension implements ThreeExtension {
0210   onSceneInit(ctx: SceneContext): void {
0211     // scene, cameras, renderer, views are ready; listen for pointer input
0212     // on ctx.mainView.container (not ctx.canvas)
0213   }
0214   onFrame(ctx: FrameContext): void {
0215     // every rendered frame, before rendering; keep cheap, no allocation
0216   }
0217   onEventLoaded(event: Event): void {}
0218   onDispose(): void {
0219     // once, at application teardown; remove listeners and objects
0220   }
0221 }
0222 ```
0223 
0224 - `onSceneInit` fires strictly AFTER the renderer's async initialization —
0225   extensions never see a half-initialized scene, and never need "defer until
0226   ready" logic of their own.
0227 - `onEventLoaded` fires once for every event that becomes the painted one:
0228   the first event of each load, and each switch to another event. It runs
0229   after the event is painted.
0230 - Rendering rules: state changes travel through signals/effects — never poll
0231   application state inside `onFrame`. After mutating anything renderable, call
0232   `ctx.invalidate()` — the next animation frame renders. The render loop is
0233   on-demand by default (config key `rendering.mode`, values `on-demand` and
0234   `continuous`): a mutation without an `invalidate()` appears only when
0235   something else triggers a render.
0236 - `onFrame` runs only on frames that render. An animation sustains itself by
0237   calling `invalidate()` from its own update — start it with one seed
0238   `invalidate()`, and the chain ends when the animation stops updating.
0239   `deltaTime` is the time since the previous rendered frame.
0240 - Do not start your own requestAnimationFrame chain against the scene; use
0241   `onFrame`.
0242 - Hooks are isolated: an `onFrame` that throws is logged and not called
0243   again, and the render loop and the other extensions continue. An
0244   exception from `onSceneInit`, `onEventLoaded` or `onDispose` is logged.
0245 - Lifetime: the scene and the extensions are application-scoped. Leaving
0246   `/display` stops the render loop and keeps the scene; returning re-attaches
0247   the canvas and does not call `onSceneInit` again. `onDispose` runs once,
0248   at application teardown.
0249 - Pointer input: listen on a view's `container` (`ctx.mainView.container`),
0250   not on `ctx.canvas`. On multi-view pages the view containers sit above the
0251   shared canvas, so the canvas receives no pointer events. A view can move to
0252   another container on page switches; an overlay's `onViewContainerChange`
0253   reports it.
0254 
0255 ## Render views and overlays
0256 
0257 The display renders through view objects over one shared scene. `SceneContext`
0258 exposes them: `ctx.views` (live list), `ctx.mainView` (the display page's
0259 camera and controls), `ctx.addView(options)` and `ctx.removeView(view)`.
0260 Each view owns its DOM container, perspective+orthographic cameras, orbit
0261 controls, picking, and its viewport rectangle inside the shared canvas — the
0262 quad-projection page (`/split-window`) is four views over the same scene.
0263 
0264 A view can carry its own geometry cut (experimental: edits to the loaded
0265 geometry reach the cut views only through `ctx.rebuildGeometrySlice()`).
0266 Create the shared slice with `ctx.createGeometrySlice()` (an independently
0267 clipped copy of the detector geometry; event data is never clipped), then
0268 pass `geometrySlice`, a `clipPlane` (world-space normal + constant) and
0269 optionally `tracksOnTop: true` (event data drawn over geometry regardless of
0270 depth) in the `addView` options; the view then renders the copy. Mutate
0271 `view.clipPlane` to move the cut at runtime. The display rebuilds the copy
0272 after every geometry load. All views sharing the slice write their own plane
0273 value before rendering: per-view cut positions are free; do not try to give
0274 views different plane COUNTS (that is what the slice copy exists for).
0275 
0276 Views select what they draw through camera layers, exported as
0277 `GEOMETRY_MAIN_LAYER`, `GEOMETRY_SLICE_LAYER` and `EVENT_DATA_LAYER`. To add
0278 objects to the event data (drawn in every view, never clipped, over the
0279 geometry in tracks-on-top views), call `ctx.addEventObject(object)`.
0280 
0281 To draw on top of a view (annotations, axes, widgets), attach an overlay:
0282 
0283 ```ts
0284 onSceneInit(ctx: SceneContext): void {
0285   this.overlay = {
0286     render: (view) => { /* after the view's scene render, every frame */ },
0287     onViewResize: (view) => { /* view size/position changed */ },
0288     onViewContainerChange: (view) => { /* view moved to another element */ },
0289     dispose: () => { /* view or overlay removed */ },
0290   };
0291   ctx.mainView.addOverlay(this.overlay);
0292 }
0293 ```
0294 
0295 An overlay that renders its own viewport must set and restore the renderer's
0296 viewport/scissor itself; `view.viewportRect` gives the view's rectangle with
0297 the y coordinate already matching the active backend (WebGPU counts from the
0298 top-left, WebGL from the bottom-left). The camera navigation cube is the
0299 built-in consumer of this seam.
0300 
0301 ## Painter selection and knobs
0302 
0303 A painter describes itself with a static `meta` block — its id, the piece
0304 types it paints, and its configurable knobs:
0305 
0306 ```ts
0307 export class MyPainter extends EventPiecePainter {
0308   static meta: PainterMeta = {
0309     id: 'my-painter',
0310     forPieceTypes: ['my.DataType'],
0311     label: 'My painter',
0312     configs: [
0313       { key: 'colorMode', default: 'pid', options: ['pid', 'momentum', 'solid'], label: 'Coloring' },
0314       { key: 'lineWidth', default: 30, min: 1, max: 300, label: 'Line width [mm]' },
0315     ],
0316   };
0317 
0318   override onConfigChanged(): void {
0319     // restyle existing objects from this.config.value(...) — live, no rebuild
0320   }
0321 }
0322 ```
0323 
0324 - When several painters register for one piece type, the config key
0325   `painters.byPiece.<pieceName>` selects which one draws each piece — from
0326   the painter panel, a server config file, or a `?config.` URL parameter.
0327   Without a selection, the first painter registered for the type draws it,
0328   whichever lazy chunk arrives first.
0329 - Knobs are config keys too (`painters.byPiece.<pieceName>.<key>`), so the
0330   same precedence and scriptability apply. The right-pane painter panel
0331   auto-renders the knobs from the meta; there is no per-painter UI code.
0332 - The knob names `visible` and `time` are reserved
0333   (`RESERVED_PAINTER_KNOB_KEYS`): `visible` is the piece visibility toggle the
0334   display applies, and `time` would share a storage key with the painter
0335   selection's timestamp. Registering a painter that declares either throws,
0336   and the display reports the painter as not registered.
0337 - In contexts without a config system (web workers, scripts), painters run on
0338   the declared defaults.
0339 
0340 ## Selection and the model tree
0341 
0342 The left pane's physics tree lists the event's pieces and entities from the
0343 data model. Clicking an entity highlights its objects in 3D; clicking an
0344 object in 3D reveals and highlights the entity in the tree. To join in,
0345 painters call `registerEntityObject(index, object)` while building, and piece
0346 types override `entityLabel(i)` / `entityRefs(i)` so entities get meaningful
0347 labels and navigable reference links (a ring links to its trajectory, for
0348 example).
0349 
0350 ## Data catalog and the data selector
0351 
0352 The data selector is the control behind the **folder** button of the display
0353 and the first card of the config page. It has three built-in tabs: **Presets**
0354 (named datasets), **Physics** (pick by tags: process, beam, ...), and
0355 **Manual** (geometry and events by URL or from a local file). The first two
0356 list catalog content and hide themselves when no catalog is contributed, so
0357 an installation without a catalog shows Manual only.
0358 
0359 A catalog is plain data. Contribute it from a pack:
0360 
0361 ```ts
0362 withDataCatalog({
0363   facets: [
0364     { key: 'process', label: 'Process', values: {
0365         'dis-nc': { label: 'DIS NC', description: 'Deep inelastic scattering, neutral current.', link: 'https://...' } } },
0366     { key: 'beam', label: 'Beam', values: { '10x100': { label: '10 × 100' } } },
0367   ],
0368   entries: [
0369     {
0370       name: 'DIS NC 10x100',
0371       description: 'Pythia 8, 5 events.',
0372       geometry: 'epic://tgeo/epic_craterlake.root',
0373       events: 'https://host/nc_10x100.firebird.zip',
0374       tags: { process: 'dis-nc', beam: '10x100' },
0375     },
0376   ],
0377   geometrySources: ['epic://tgeo/epic_ip6.root'],   // extra URLs for the Manual drop-down
0378 })
0379 ```
0380 
0381 The same object can come from the server (`dataCatalog` in `config.jsonc`,
0382 which pyrobird passes through) or from a remote JSON file named by the
0383 `catalog.url` setting. All sources merge in that order; facets with the same
0384 key merge their value maps.
0385 
0386 Entry fields: `geometry` absent keeps the loaded geometry (event-only
0387 datasets); `events` absent shows no events (a geometry-only preset).
0388 `eventRange` and `collections` apply when `events` is a ROOT file.
0389 
0390 Tabs come from DI too. To add one:
0391 
0392 ```ts
0393 withDataSelectorTab({
0394   id: 'campaigns', label: 'Campaigns', order: 15,
0395   load: () => import('./campaigns-tab.component').then(m => m.CampaignsTabComponent),
0396 })
0397 ```
0398 
0399 The tab component has no inputs. It injects `DataSelectionService`, shows
0400 whatever it wants, and calls `updateDraft({ geometry, events, eventRange, collections })`
0401 with what the user chose. The host owns the Show/Cancel buttons and applies
0402 the draft through one path: the selection is written to the config keys
0403 (`geometry.selectedGeometry`, `events.dexEventsSource` or
0404 `events.rootEventSource`, `events.rootEventRange`, `events.rootCollections`),
0405 and a live display reloads from them, the same load it runs at startup. The
0406 config page has no display: it writes the keys and navigates to `/display`.
0407 
0408 Local files the user picked are not persisted (a blob URL dies with the page):
0409 they wait in the selection service and the display's next load consumes them.
0410 After a reload the last URL loads again.
0411 
0412 ## Settings for extensions
0413 
0414 A setting that changes how data looks is a painter knob. The template's ring
0415 color is one:
0416 
0417 ```ts
0418 export class CherenkovRingPainter extends EventPiecePainter {
0419   static meta: PainterMeta = {
0420     id: 'example-cherenkov-rings',
0421     forPieceTypes: ['example.CherenkovRing'],
0422     configs: [{ key: 'ringColor', default: '#00e5ff', label: 'Ring color' }],
0423   };
0424 
0425   // Build with this.config.value<string>('ringColor'); restyle here on changes.
0426   // The display schedules the next frame after this call.
0427   override onConfigChanged(): void { /* recolor the existing rings */ }
0428 }
0429 ```
0430 
0431 The knob becomes the config key `painters.byPiece.<pieceName>.ringColor`
0432 (`painters.byPiece.ExampleRings.ringColor` for the sample data), appears in
0433 the painter panel, and obeys the source priority (pack defaults < server <
0434 saved browser settings < URL `?config.key=` < runtime), so a deep link or
0435 batch script sets it too. A pack ships another default with
0436 `withConfigDefaults({'painters.byPiece.ExampleRings.ringColor': '#ff4d00'})`.
0437 
0438 Machinery extensions (a `ThreeExtension`) declare their settings through
0439 `ConfigService.declare({key, default, label})` and react to
0440 `valueSignal()` or `changes$`; the same priority applies.
0441 
0442 ## Geometry pipeline
0443 
0444 Three seams adjust detector geometry for an experiment, in pipeline order:
0445 
0446 1. **Pre-build TGeo rules**: `withRootGeometryRules({ editRules, cutLists })`.
0447    Named edit rule sets (selected by `geometry.rootFilterName`) remove or
0448    hide TGeo detail, and named cut lists (selected by `geometry.cutListName`)
0449    remove top-level detectors. The rules are plain data: the geometry worker
0450    receives them with each load request, because a prebuilt worker cannot
0451    import pack code. A rule pattern that matches several detectors edits each
0452    of them. The default for both config keys is `off`; a pack selects its rule
0453    set through `withConfigDefaults`. The data selector's "Optimize geometry"
0454    toggle switches between `off` and the rule set named `default`; it shows
0455    only when a pack registers that rule set, with the rule set's `label` as
0456    its hint.
0457 2. **Themes**: `withGeometryTheme({ id, label, load })` registers subdetector
0458    rule sets (color, merge, outline) selected by `geometry.themeName`. The
0459    built-in theme is `grey`; `off` keeps the geometry's own colors.
0460 3. **Post-processors**: `withGeometryPostProcessor({ id, after, load })`
0461    registers main-thread code that runs after each load, once the geometry
0462    sits in the scene (scaled to mm) and before the first frame shows it. The
0463    class gets `{ geometry, sceneGeometry, scene, renderer, clippingPlanes,
0464    fastMaterials }`, may be async, and is instantiated through DI.
0465 
0466 ```ts
0467 firebirdPack('my-experiment',
0468   withRootGeometryRules(() => import('./my-geometry-rules').then(m => m.myRules)),
0469   withConfigDefaults({ 'geometry.rootFilterName': 'default', 'geometry.themeName': 'my-colors' }),
0470   withGeometryTheme({ id: 'my-colors', label: 'My colors',
0471     load: () => import('./my-theme').then(m => m.myRuleSets) }),
0472   withGeometryPostProcessor({ id: 'my.mirrors',
0473     load: () => import('./my-mirrors').then(m => m.MirrorProcessor) }),
0474 )
0475 ```
0476 
0477 The ePIC pack (`packages/epic/`) is the working example: its rule data, three
0478 themes, a mirror prettifier and a detector arranger. Themes can take their
0479 colors from the named palette `@dexvis/firebird-ng/geometry-palette`
0480 (`STEEL_BLUE`, `AMBER_200`, ...; see geometry-rules.md).
0481 
0482 ## Camera views, the collision intro and toolbar actions
0483 
0484 `withCameraPreset()` registers named views for `?cmd=camera-preset:<name>`,
0485 the navigation cube's home button and the camera debug panel. The built-in
0486 presets are the six face views and `home`, which frames the loaded geometry
0487 from above. A pack pins its own `home` by registering a preset with that
0488 name: the camera then starts there. Without `withCameraLimits()`, every
0489 geometry load sets the orbit limits from the geometry's bounding sphere.
0490 
0491 `withCollisionIntro(() => import('./beams-intro').then(m => m.BeamsIntro))`
0492 sets the animation played before an event's time animation: the
0493 `animate-collision` command and offline recordings play it. The intro class
0494 builds its objects in `begin(parent)`, shows the state at a time in
0495 `update(elapsedMs)`, and disposes them in `end()`.
0496 
0497 `withToolbarAction({ id, icon, label, tooltip, command })` adds a button to
0498 the time toolbar; a click dispatches the command. The ePIC pack adds the
0499 collision button this way:
0500 
0501 ```ts
0502 withToolbarAction({ id: 'collision', icon: 'close_fullscreen', label: 'Collision',
0503   tooltip: 'Animate with beam particles collision', command: { type: 'animate-collision' } })
0504 ```
0505 
0506 ## Loaders
0507 
0508 A loader teaches Firebird a file format or URL scheme. It returns data and
0509 never touches the display: `EventDisplayService` picks the loader, passes it
0510 a context, and puts the result on screen. Every source takes this path: the
0511 configured geometry and events, picked files, the data selector, the
0512 `open-geometry` and `open-dex` commands.
0513 
0514 ```ts
0515 @Injectable()
0516 export class IgesGeometryLoader implements GeometryDataLoader {
0517   readonly meta = { id: 'iges', label: 'IGES geometry', fileExtensions: ['.igs', '.iges'] };
0518   readonly millimetersPerUnit = 1;
0519 
0520   canLoad(source: DataSource): boolean {
0521     return matchesFileExtensions(source, this.meta);
0522   }
0523 
0524   async load(source: DataSource, context: LoaderContext): Promise<Object3D> {
0525     const url = typeof source === 'string' ? context.resolveUrl(source) : URL.createObjectURL(source);
0526     const response = await fetch(url, { signal: context.signal });
0527     if (!response.ok) throw new Error(`HTTP ${response.status} (${url})`);
0528     return parseIges(await response.arrayBuffer());
0529   }
0530 }
0531 ```
0532 
0533 - **Claiming.** The first registered loader whose `canLoad(source)` accepts
0534   the source loads it. A `.root` name is ambiguous (geometry or events): the
0535   data selector probes the file and asks `canLoadContent(probe)` which field
0536   it belongs to.
0537 - **Results.** `load()` resolves to the geometry root object in the loader's
0538   length unit, `millimetersPerUnit` (default 1; ROOT TGeo: 10). The display
0539   scales the geometry container by it, themes and post-processes the
0540   geometry, and replaces the previous one. `loadEvents()` resolves to a
0541   `DataExchange` (`DataExchange.fromDexObj(json)`); the display shows its
0542   first event.
0543 - **Context.** `context.resolveUrl(url)` turns URL aliases (`asset://`, a
0544   pack's alias) and server paths into a URL `fetch()` reads.
0545   `context.signal` aborts when a newer load of the same kind starts: pass it
0546   to `fetch()`, and reject with `context.signal.reason` when it fires. The
0547   replaced load ends without an error.
0548 - **Failures.** Reject with an `Error` whose message says what went wrong
0549   (HTTP status, unsupported version, entries outside the file): the display
0550   shows it to the user and lists it in `window.firebird.errors`.
0551 
0552 `EventDisplayService.openGeometry(source)` and `openEvents(source)` (from
0553 `@dexvis/firebird-ng/display`) start a load from code; they resolve to null
0554 when a newer load replaced theirs. The built-in loaders
0555 (`DexEventLoader`, `Root2DexEventLoader`, `Edm4eicEventLoader`,
0556 `RootGeometryLoader`) are exported, so an application can register them
0557 again in another order.
0558 
0559 ## Bundle discipline
0560 
0561 Everything referenced from `app.config.ts` is loaded with the application
0562 shell. Painter classes and display services pull three.js — hundreds of kB.
0563 Keep them out of the startup path:
0564 
0565 - register painters with `withLazyPainter` (dynamic import), and themes,
0566   post-processors, intros and large rule data through their `load` imports,
0567 - in loaders and command handlers, load `@dexvis/firebird-ng/display`
0568   with a dynamic `import()` inside the method that needs it: a top-level
0569   import puts three.js into the initial bundle. A pack may inject
0570   `EventDisplayService`, `DataModelService` and `SelectionService` from
0571   there,
0572 - import the core model from `@dexvis/firebird-core/model` (and loader or
0573   catalog helpers from `/loaders` and `/data-catalog`) in modules that
0574   `app.config.ts` reaches: the package root re-exports the painters, which
0575   pull three.js.
0576 
0577 The built-in implementations in `packages/firebird-ng/src/lib/` follow this
0578 pattern and are the reference.
0579 
0580 ## Rendering caveat
0581 
0582 The WebGPU renderer silently skips `THREE.LineLoop` objects — they exist in
0583 the scene, report `visible: true`, and never draw. Use a closed `THREE.Line`
0584 strip (repeat the first point) instead. `Line`, `LineSegments`, `Line2`,
0585 points and meshes render normally.