Skip to content

Geometry Editing Rules ​

Firebird uses a powerful rule-based system to customize detector geometry appearance. Rules control colors, materials, merging behavior, and outlines for different detector components.

Overview ​

When geometry is loaded from ROOT files, it consists of a hierarchical tree of Three.js Object3D nodes (Groups and Meshes). The geometry editing system allows you to:

  • Match nodes by name patterns (glob-style wildcards)
  • Apply styling (colors, materials, transparency)
  • Merge geometries for better performance
  • Create outlines for visual clarity
  • Process hierarchically ("the rest" rules for unmatched nodes)

Rule Structure ​

Rules are organized in RuleSets, where each ruleset targets specific detectors:

typescript
{
  name: "DIRC*",           // Match detector by name (glob pattern)
  // or
  names: ["DIRC*", "RICH*"],  // Match multiple detectors

  rules: [
    // Array of rules applied in order
  ]
}

Basic Rule Properties ​

typescript
interface EditThreeNodeRule {
  // Node selection
  patterns?: string[] | string;  // Glob patterns to match nodes

  // Geometry handling
  merge?: boolean;               // Merge matched meshes (default: true)
  newName?: string;              // Name for merged mesh
  deleteOrigins?: boolean;       // Remove original meshes after merge (default: true)
  cleanupNodes?: boolean;        // Remove empty groups (default: true)

  // Styling
  color?: ColorRepresentation;   // Hex color (e.g., 0xFF0000)
  material?: Material;           // Three.js material

  // Outlines
  outline?: boolean;             // Create edge outlines (default: true)
  outlineThresholdAngle?: number; // Edge detection angle in degrees (default: 40)
  outlineColor?: ColorRepresentation;

  // Advanced
  applyToDescendants?: boolean;  // Apply to children of matched nodes
  simplifyMeshes?: boolean;      // Reduce vertex count
  simplifyRatio?: number;        // Simplification ratio (default: 0.7)
}

Pattern Matching ​

Patterns use glob-style matching (powered by outmatch):

PatternMatches
*Any single path segment
**Any number of path segments
**/name*Any node whose name starts with "name"
**/v_upstream*Nodes like v_upstream_coating, v_upstream_wall
**/*box*Any node containing "box" in its name

Path Structure ​

Node paths are built from the hierarchy:

BeamPipe_assembly/v_upstream_coating/Left
                 ↑                  ↑
            parent name        child name

The pattern **/v_upstream* matches v_upstream_coating but NOT Left. To match descendants, use applyToDescendants: true (default when merge: false).

Rule Processing Order ​

Rules are processed in order. Earlier rules take precedence:

typescript
rules: [
  // Rule 1: Process specific patterns first
  {
    patterns: ["**/mirror*"],
    color: SILVER,
    merge: true
  },
  // Rule 2: "The rest" - no pattern means all remaining nodes
  {
    color: GREEN_100,
    merge: false,
    outline: true
  }
]

Skip Flags and Hierarchical Processing ​

When a node is processed, it's marked with a skip flag. Subsequent rules respect this:

  1. Individual skip: Processed meshes are skipped by later rules
  2. Hierarchical skip: If a parent is processed, descendants are skipped by "the rest" rules
  3. Outlines: Created outlines are automatically marked to prevent "outline of outline"

Working with Nested Nodes ​

Understanding how rules interact with hierarchical geometry is crucial for effective styling.

Geometry Tree Structure ​

Detector geometry forms a tree of Object3D nodes:

BeamPipe_assembly (Group)
├── v_upstream_coating (Group)
│   ├── Left (Mesh)         ← has geometry, can be styled
│   └── Right (Mesh)        ← has geometry, can be styled
├── v_downstream_section (Mesh)
└── center_pipe (Mesh)

Key distinction:

  • Groups - containers without geometry, used for organization
  • Meshes - have actual geometry that gets rendered

Pattern Matching Depth ​

Patterns match based on the full path from root:

typescript
// Given this structure:
// BeamPipe_assembly/v_upstream_coating/Left

// Pattern "**/v_upstream*" matches:
"BeamPipe_assembly/v_upstream_coating"  ✓  (name starts with v_upstream)

// But does NOT match:
"BeamPipe_assembly/v_upstream_coating/Left"  ✗  (name is "Left", not "v_upstream*")

The applyToDescendants Option ​

When a pattern matches a Group (not a Mesh), you need to decide what happens to its children:

typescript
{
  patterns: ["**/v_upstream_coating"],
  color: 0xFF0000,
  merge: false,
  applyToDescendants: true  // ← Default when merge=false
}
applyToDescendantsBehavior
true (default)Pattern matches Group → all descendant Meshes get styled
falsePattern matches Group → nothing styled (Groups have no geometry)

Example - styling a branch:

typescript
// Style v_upstream_coating AND all its children (Left, Right)
{
  patterns: ["**/v_upstream_coating"],
  color: INDIGO_200,
  merge: false
  // applyToDescendants: true is the default
}

// Result:
// v_upstream_coating (Group) - marked as processed
// ├── Left (Mesh) - colored INDIGO_200
// └── Right (Mesh) - colored INDIGO_200

"The Rest" Rules and Hierarchical Skip ​

A rule without patterns is called a "the rest" rule - it processes everything not yet handled:

typescript
rules: [
  // Rule 1: Specific pattern
  { patterns: ["**/v_upstream*"], color: RED, merge: false },

  // Rule 2: "The rest" - no patterns
  { color: GREEN, merge: false }
]

Hierarchical skip ensures that once a branch is processed, "the rest" rules skip the entire branch:

Processing flow:

1. Rule 1 matches "v_upstream_coating"
   → Marks v_upstream_coating as processed
   → Styles Left and Right (applyToDescendants=true)
   → Marks Left and Right as processed

2. Rule 2 ("the rest") runs
   → Checks each mesh: "Is it in a processed branch?"
   → Left: parent v_upstream_coating is processed → SKIP
   → Right: parent v_upstream_coating is processed → SKIP
   → center_pipe: not in processed branch → style GREEN
   → v_downstream_section: not in processed branch → style GREEN

Common Pitfalls ​

Pitfall 1: Pattern doesn't match children ​

typescript
// WRONG: Only matches v_upstream_coating, not its children
{ patterns: ["**/v_upstream_coating"], merge: true }
// Result: Tries to merge just the Group (no geometry!)

// CORRECT: Match the Group with applyToDescendants
{ patterns: ["**/v_upstream_coating"], merge: false, applyToDescendants: true }
// Result: Styles all children of v_upstream_coating

Pitfall 2: Children processed twice ​

typescript
// PROBLEMATIC:
rules: [
  { patterns: ["**/v_upstream*"], color: RED },      // Matches v_upstream_coating
  { patterns: ["**/Left", "**/Right"], color: BLUE } // Also matches children!
]
// Result: Left and Right get BLUE (second rule overwrites)

// BETTER: Use one pattern and rely on applyToDescendants
rules: [
  { patterns: ["**/v_upstream*"], color: RED, merge: false }
]
// Result: v_upstream_coating AND its children get RED

Pitfall 3: "The rest" processes already-styled nodes ​

typescript
// WRONG ORDER:
rules: [
  { color: GREEN, merge: false },               // "The rest" FIRST - styles everything!
  { patterns: ["**/mirror*"], color: SILVER }   // Never runs - everything already processed
]

// CORRECT ORDER:
rules: [
  { patterns: ["**/mirror*"], color: SILVER },  // Specific patterns FIRST
  { color: GREEN, merge: false }                // "The rest" LAST
]

Pitfall 4: Outline of outline ​

When creating outlines, the outline object is added to the scene. Without protection, a subsequent rule might try to outline the outline:

typescript
// This is handled automatically!
// Outlines are marked with geometryEditingSkipRules = true
// They will be skipped by subsequent rules

Advanced: Multiple Nesting Levels ​

For deeply nested structures:

DRICH_assembly (Group)
├── sector_0 (Group)
│   ├── mirror_panel (Group)
│   │   ├── mirror_surface (Mesh)
│   │   └── mirror_backing (Mesh)
│   └── sensor_array (Group)
│       ├── sensor_0 (Mesh)
│       └── sensor_1 (Mesh)
└── sector_1 (Group)
    └── ...

Strategy 1: Match deepest level first

typescript
rules: [
  // Most specific first
  { patterns: ["**/mirror_surface*"], color: SILVER, merge: true, newName: "mirrors" },
  { patterns: ["**/sensor_*"], color: TEAL_200, merge: true, newName: "sensors" },
  // Everything else
  { color: GREEN_100, merge: false }
]

Strategy 2: Match by branch

typescript
rules: [
  // Match parent groups, style all descendants
  { patterns: ["**/mirror_panel"], color: SILVER, merge: false },
  { patterns: ["**/sensor_array"], color: TEAL_200, merge: false },
  // Everything else
  { color: GREEN_100, merge: false }
]

Strategy 3: Merge entire branches

typescript
rules: [
  // Merge all meshes under mirror_panel into one
  { patterns: ["**/mirror_panel/**"], merge: true, newName: "mirrors" },
  // Merge all sensors
  { patterns: ["**/sensor_array/**"], merge: true, newName: "sensors" }
]

Visualizing the Processing ​

Here's a complete example showing the processing flow:

typescript
// Geometry:
// BeamPipe_assembly
// ├── v_upstream_coating
// │   ├── Left
// │   └── Right
// ├── v_downstream_section
// └── center_pipe

{
  name: "BeamPipe_assembly*",
  rules: [
    // Rule 1: Match v_upstream branch
    {
      patterns: ["**/v_upstream*"],
      color: INDIGO_200,
      merge: false,
      outline: true
    },
    // Rule 2: The rest
    {
      color: INDIGO_80,
      merge: false,
      outline: true
    }
  ]
}

// Processing steps:
//
// 1. Clear all skip flags on BeamPipe_assembly tree
//
// 2. Rule 1 executes:
//    - Pattern "**/v_upstream*" matches "v_upstream_coating"
//    - applyToDescendants=true (default)
//    - Traverse v_upstream_coating:
//      - Left (Mesh): color=INDIGO_200, create outline, mark processed
//      - Right (Mesh): color=INDIGO_200, create outline, mark processed
//    - Mark v_upstream_coating as processed
//
// 3. Rule 2 ("the rest") executes:
//    - Traverse BeamPipe_assembly looking for unprocessed meshes:
//      - v_upstream_coating: skip (processed)
//      - Left: skip (in processed branch - parent is processed)
//      - Right: skip (in processed branch)
//      - Left_outline: skip (marked when created)
//      - Right_outline: skip (marked when created)
//      - v_downstream_section: NOT in processed branch
//        → color=INDIGO_80, create outline, mark processed
//      - center_pipe: NOT in processed branch
//        → color=INDIGO_80, create outline, mark processed
//
// Final result:
// ├── v_upstream_coating (processed)
// │   ├── Left ─────────── INDIGO_200 + outline
// │   ├── Left_outline ─── (auto-created)
// │   ├── Right ────────── INDIGO_200 + outline
// │   └── Right_outline ── (auto-created)
// ├── v_downstream_section ─ INDIGO_80 + outline
// ├── v_downstream_section_outline
// ├── center_pipe ────────── INDIGO_80 + outline
// └── center_pipe_outline

Examples ​

Simple Color Change ​

typescript
{
  name: "HcalBarrel*",
  rules: [
    {
      color: 0x90CAF9,  // Light blue
      merge: true,
      outline: true
    }
  ]
}

Multiple Patterns with Different Styles ​

typescript
{
  name: "DRICH*",
  rules: [
    // Mirrors get special treatment
    {
      patterns: ["**/DRICH_mirror*"],
      color: SILVER,
      merge: true,
      outline: false,
      newName: "DRICH_mirror"
    },
    // PDUs in teal
    {
      patterns: ["**/DRICH*pdu*"],
      color: TEAL_200,
      merge: true,
      newName: "DRICH_pdu"
    },
    // Everything else in light green
    {
      color: GREEN_100,
      merge: false,
      outline: true
    }
  ]
}

Transparent Material ​

typescript
{
  name: "InnerTrackerSupport_assembly*",
  rules: [
    {
      material: new THREE.MeshStandardMaterial({
        color: 0x878F99,      // Titanium
        roughness: 0.4,
        metalness: 0.2,
        transparent: true,
        opacity: 0.7,
        side: THREE.DoubleSide
      }),
      outline: true,
      outlineColor: 0xDBE4EB,  // Chrome
      merge: true
    }
  ]
}

Glass-like Material (DIRC) ​

typescript
{
  patterns: ["**/*box*", "**/*prism*"],
  material: new THREE.MeshPhysicalMaterial({
    color: 0xA5D6A7,        // Green
    metalness: 0.3,
    roughness: 0.2,
    envMapIntensity: 0.5,
    clearcoat: 0.8,
    transparent: true,
    opacity: 0.5,
    reflectivity: 0.2,
    ior: 0.9,
    side: THREE.DoubleSide
  }),
  newName: "DIRC_barAndPrisms"
}

Hierarchical Processing (BeamPipe Example) ​

typescript
{
  name: "BeamPipe_assembly*",
  rules: [
    // Match v_upstream nodes AND all their children
    {
      patterns: ["**/v_upstream*"],
      color: 0x9FA8DA,     // Indigo 200
      merge: false,        // Keep individual meshes
      outline: true
      // applyToDescendants defaults to true
    },
    // "The rest" - skips v_upstream branch entirely
    {
      color: 0xD1D9F0,     // Indigo 80
      merge: false,
      outline: true
    }
  ]
}

The Geometry Color Palette ​

Theme rule sets take their colors from a palette of named constants, @dexvis/firebird-ng/geometry-palette (import { STEEL_BLUE } from '@dexvis/firebird-ng/geometry-palette'). The ePIC pack's cool2 theme uses it, and the /palette page shows every color as a swatch:

Cool Colors (Light/Pastel) ​

ConstantHexUse Case
BLUE_500xE3F2FDVery light backgrounds
CYAN_500xE0F7FALight cyan accents
INDIGO_800xD1D9F0Light indigo
INDIGO_1500xB2BBE0Medium-light indigo
GREEN_1000xC8E6C9Light green (PID)
YELLOW_1000xFFF9C4Light yellow (tracking)

Medium Colors ​

ConstantHexUse Case
AMBER_2000xFFE082Tracking detectors
BLUE_2000x90CAF9HCAL
TEAL_2000x80CBC4TOF components
INDIGO_2000x9FA8DAECAL
ORANGE_2000xFFCC80Roman pots

Metallic Colors ​

ConstantHexUse Case
SILVER0xC0C0C0Mirrors, rails
CHROME0xDBE4EBPolished metal
STEEL_BLUE0x4682B4Beam pipes
TITANIUM0x878F99Supports
COPPER0xB87333Conductors

Color Scheme Philosophy ​

The cool2 theme follows detector-type conventions:

Detector TypeColor FamilyExamples
TrackingYellowish-OrangeVertex, Si Trackers, MPGD
PIDGreenishTOF, DIRC, RICH
ECALPink/VioletForward, Barrel, Backward
HCALBluishLFHCAL, HcalBarrel
Flux ReturnGreyFluxBarrel, FluxEndcap
Beam PipesBlue MetallicElectron pipe
MagnetsNeutral/LightSolenoid, Beamline magnets

Creating Custom Themes ​

To create a custom theme:

  1. Create a new file in your experiment pack:
typescript
// my-theme-geometry-ruleset.ts
import * as THREE from "three";
import { STEEL_BLUE } from "@dexvis/firebird-ng/geometry-palette";   // named colors from the palette above

// Or define your own colors
export const MY_PRIMARY = 0x3498DB;

// Export your ruleset
export const myThemeRules = [
  {
    name: "MyDetector*",
    rules: [
      { color: MY_PRIMARY, merge: true, outline: true }
    ]
  },
  {
    name: "MyBeamPipe*",
    rules: [
      { color: STEEL_BLUE, merge: true, outline: false }
    ]
  },
  // ... more rules
];
  1. Register the theme in your pack, and make it the pack's default if you want (the config page lists every registered theme):
typescript
withGeometryTheme({ id: 'my-theme', label: 'My theme',
  load: () => import('./my-theme-geometry-ruleset').then(m => m.myThemeRules) }),
withConfigDefaults({ 'geometry.themeName': 'my-theme' }),

Performance Considerations ​

Merge for Performance ​

Merging reduces draw calls significantly:

typescript
// Good for performance - single merged mesh
{ merge: true, newName: "HCAL_merged" }

// More flexible but slower - individual meshes
{ merge: false }

Outline Threshold ​

Higher threshold = fewer edges = better performance:

typescript
{
  outline: true,
  outlineThresholdAngle: 60  // Only show sharp edges
}

Simplification ​

For complex geometries:

typescript
{
  simplifyMeshes: true,
  simplifyRatio: 0.5  // Keep 50% of vertices
}

Debugging Rules ​

Check Console Output ​

The geometry processor logs timing information:

[processRuleSets] Applying 25 theme rules...
[processRuleSets] Applying rules took >0.5s: 1234.5 for DRICH_assembly

Verify Patterns ​

Use the browser console to test patterns:

javascript
// In browser dev tools
import outmatch from 'outmatch';
const pattern = outmatch('**/v_upstream*');
pattern('BeamPipe_assembly/v_upstream_coating');  // true
pattern('BeamPipe_assembly/v_upstream_coating/Left');  // false

API Reference ​

Main Functions ​

typescript
// Clear skip flags before processing
clearGeometryEditingFlags(root: Object3D): void

// Apply a single rule to a node (async: mesh simplification awaits three's SimplifyModifier)
editThreeNodeContent(node: Object3D, rule: EditThreeNodeRule): Promise<void>

// Check if node was already processed
isAlreadyProcessed(obj: Object3D): boolean

// Check if node or any ancestor was processed
isInProcessedBranch(obj: Object3D): boolean

ThreeGeometryProcessor ​

typescript
class ThreeGeometryProcessor {
  // Apply rulesets to detectors; await the result before rendering the geometry
  processRuleSets(
    ruleSets: DetectorThreeRuleSet[],
    detectors: Subdetector[]
  ): Promise<void>
}

Loading Rules from JSON ​

Rules can be loaded from JSONC files:

typescript
import { ruleSetsFromObj } from './three-geometry.processor';

// Parse JSON with material support
const ruleSets = ruleSetsFromObj(jsonData);

JSON format supports materialJson for Three.js materials:

json
{
  "name": "MyDetector*",
  "rules": [
    {
      "patterns": ["**/*box*"],
      "color": "0xA5D6A7",
      "materialJson": {
        "type": "MeshStandardMaterial",
        "color": 10809767,
        "roughness": 0.5,
        "metalness": 0.3
      }
    }
  ]
}