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.