Skip to content

Architecture

Maarten Vroegindeweij edited this page Feb 18, 2026 · 1 revision

Architecture

Package Layers

Ifc-Factory is structured as a layered monorepo where each package has well-defined responsibilities and dependencies. Lower layers have zero knowledge of higher layers.

Layer 0:  express-parser    step-serializer     (no internal deps)
              │
Layer 1:   codegen                              (depends on express-parser)
              │
Layer 2:   schema          step-parser          (schema is generated; step-parser uses both L0 packages + schema)
              │                 │
Layer 3:          core                          (main library, depends on L0-L2)
                    │
Layer 4:        ifc-utils                       (convenience utils, depends on core)

Dependency Graph

express-parser ─────┐
                    v
step-serializer    codegen
       │              │
       │              v
       └──────► schema
                  │
                  v
              step-parser
                  │
                  v
                core
                  │
                  v
              ifc-utils

Why This Layering?

  • Layer 0 packages are schema-agnostic. step-serializer can parse any ISO 10303-21 file, not just IFC. express-parser can parse any EXPRESS schema.
  • Layer 1 (codegen) transforms EXPRESS AST to TypeScript code — a build-time tool, not a runtime dependency.
  • Layer 2 (schema) contains generated code. step-parser combines raw STEP parsing with schema knowledge to produce typed entities.
  • Layer 3 (core) provides the high-level API that most users interact with.
  • Layer 4 (ifc-utils) is optional convenience utilities.

Two-Pass Parsing

IFC STEP files are parsed in two sequential passes. This separation keeps each pass simple and testable.

Pass 1: Raw STEP Parsing (step-serializer)

The step-serializer package tokenizes and parses the ISO 10303-21 physical file format without any schema knowledge:

Input: "ISO-10303-21;\nHEADER;\n...\nDATA;\n#1=IFCWALL('guid',#2,'name',$,$,#15,#30,$,.SOLIDWALL.);\n..."

Step 1: Tokenizer
  → Token stream: [ISO_TAG, HEADER, SEMICOLON, ..., HASH, INTEGER(1), EQUALS, IDENT("IFCWALL"), ...]

Step 2: Reader
  → StepFile {
      header: { fileDescription, fileName, fileSchema },
      entities: Map {
        1 → { id: 1, typeName: "IFCWALL", attributes: ['guid', EntityRef(2), 'name', null, null, EntityRef(15), EntityRef(30), null, Enum('SOLIDWALL')] }
      }
    }

Key characteristics:

  • Generator-based tokenizer for memory efficiency
  • Handles all STEP value types (integers, reals, strings, booleans, enums, entity refs, typed values, lists, null, derived)
  • Unicode escape decoding (\X\, \X2\, \X4\, \S\)
  • No knowledge of what IFCWALL means — just parses the syntax

Pass 2: Typed Entity Construction (step-parser)

The step-parser uses schema metadata to transform raw records into typed entities:

Input: StepEntityInstance { id: 1, typeName: "IFCWALL", attributes: ['guid', EntityRef(2), 'name', ...] }

Step 1: Registry Lookup
  ENTITY_REGISTRY["IFCWALL"] → "IfcWall"

Step 2: Metadata Lookup
  SCHEMA_METADATA["IfcWall"].allAttributes → [
    { name: "GlobalId",        type: "string",  optional: false },
    { name: "OwnerHistory",    type: "ref",     optional: true  },
    { name: "Name",            type: "string",  optional: true  },
    { name: "Description",     type: "string",  optional: true  },
    { name: "ObjectType",      type: "string",  optional: true  },
    { name: "ObjectPlacement", type: "ref",     optional: true  },
    { name: "Representation",  type: "ref",     optional: true  },
    { name: "Tag",             type: "string",  optional: true  },
    { name: "PredefinedType",  type: "enum",    optional: true  },
  ]

Step 3: Attribute Mapping
  attributes[0] → GlobalId = 'guid'
  attributes[1] → OwnerHistory = 2          (EntityRef → number)
  attributes[2] → Name = 'name'
  attributes[3] → Description = null        ($ → null)
  ...
  attributes[8] → PredefinedType = 'SOLIDWALL'  (Enum → string)

Output: {
  expressID: 1,
  type: "IfcWall",
  GlobalId: "guid",
  OwnerHistory: 2,
  Name: "name",
  Description: null,
  ObjectType: null,
  ObjectPlacement: 15,
  Representation: 30,
  Tag: null,
  PredefinedType: "SOLIDWALL"
}

Key characteristics:

  • Uses SCHEMA_METADATA for attribute name/order mapping
  • Uses ENTITY_REGISTRY for UPPERCASE → PascalCase type name resolution
  • StepEntityRef values become plain number (expressID)
  • StepEnum values become plain string
  • Unknown entity types fall back to IfcGenericEntity with raw attributes

Entity Storage

Map + Type Index

The EntityStore maintains two data structures:

class EntityStore {
  // Primary store: O(1) lookup by expressID
  private entities: Map<number, IfcGenericEntity>;

  // Secondary index: O(1) lookup by type name
  private typeIndex: Map<string, Set<number>>;
}

Operations:

Operation Complexity Example
get(id) O(1) Get entity #42
getAllOfType(type) O(k) where k = count of type Get all IfcWall entities
create(type, attrs) O(1) Create new entity
update(id, changes) O(1) Update entity attributes
delete(id) O(1) Delete entity

Both structures are updated atomically on every mutation.

Entity References

Entity references are stored as plain number values (expressIDs), not object references:

// ✅ How Ifc-Factory stores references
{
  type: 'IfcRelAggregates',
  RelatingObject: 42,           // expressID of the parent
  RelatedObjects: [43, 44, 45], // expressIDs of children
}

// ❌ NOT like this (would cause circular references)
{
  type: 'IfcRelAggregates',
  RelatingObject: { type: 'IfcBuilding', ... },
  RelatedObjects: [{ type: 'IfcBuildingStorey', ... }, ...],
}

Benefits of number-based references:

  • No circular reference issues (critical for IFC's bidirectional relationships)
  • Trivial JSON serialization
  • Straightforward STEP output (#42 instead of resolving nested objects)
  • Lower memory footprint
  • Easy diffing and comparison

Trade-off: You need model.get(id) to resolve a reference. This is O(1) so it's fast.

Relationship Index

The RelationshipIndex provides pre-built inverted indexes for fast relationship traversal:

class RelationshipIndex {
  // IfcRelContainedInSpatialStructure
  containedIn: Map<number, Set<number>>;      // spatialId → elementIds

  // IfcRelAggregates
  aggregatedIn: Map<number, Set<number>>;     // parentId → childIds

  // IfcRelDefinesByProperties
  definedByProperties: Map<number, Set<number>>;  // elementId → relIds
}
Query Index Used Example
"What elements are on Floor 1?" containedIn containedIn.get(floor1Id)Set{wall1, wall2, door1}
"What are the children of this building?" aggregatedIn aggregatedIn.get(buildingId)Set{storey1, storey2}
"What property sets does this wall have?" definedByProperties definedByProperties.get(wallId)Set{rel1, rel2}

Rebuild strategy:

  • Eager rebuild on model load: scan all relationship entities once, build all indexes
  • Incremental update on mutation: when creating/updating/deleting relationship entities, update only affected index entries

Generated Code Strategy

Why Code Generation?

The IFC4X3 schema defines 876 entity types with complex inheritance. Hand-writing TypeScript types would be:

  • Error-prone (thousands of attributes)
  • Unmaintainable (schema updates)
  • Incomplete (missing edge cases)

Instead, we:

  1. Parse the official EXPRESS schema (IFC4X3_ADD2.exp)
  2. Generate TypeScript from the AST
  3. Commit the generated code (for faster builds and IDE support)

Interfaces, Not Classes

Entity types are generated as TypeScript interfaces, not classes:

// Generated: packages/schema/src/generated/entities/IfcWall.ts
export interface IfcWall extends IfcBuildingElement {
  PredefinedType: string | null;
}

Why interfaces?

  • No circular import issues at runtime (interfaces are erased by TypeScript)
  • No class instantiation overhead
  • Compatible with plain object literals
  • Entity inheritance maps naturally to interface extension

Type Discrimination

Root entities declare readonly type: string, which all children inherit:

// Root entity
export interface IfcRoot {
  readonly type: string;    // ← declared here
  expressID: number;
  GlobalId: string;
  OwnerHistory: number | null;
  Name: string | null;
  Description: string | null;
}

// Child entities inherit `type` — no redeclaration
export interface IfcWall extends IfcBuildingElement {
  PredefinedType: string | null;
  // type is inherited from IfcRoot
}

Why string instead of literal types? Literal type discriminants (readonly type: 'IfcWall') conflict with parent interfaces when TypeScript checks extends. Since IfcWall extends IfcBuildingElement extends ... extends IfcRoot, the child's literal type would need to be assignable to the parent's literal type.

Schema Metadata

Runtime metadata provides the information needed for STEP parsing and serialization:

// Generated: packages/schema/src/generated/metadata/schema-metadata.ts
export const SCHEMA_METADATA: Record<string, EntityMetadata> = {
  IfcWall: {
    parent: 'IfcBuildingElement',
    abstract: false,
    allAttributes: [
      // Inherited from IfcRoot:
      { name: 'GlobalId', type: 'string', optional: false },
      { name: 'OwnerHistory', type: 'ref', optional: true },
      { name: 'Name', type: 'string', optional: true },
      { name: 'Description', type: 'string', optional: true },
      // Inherited from IfcObject:
      { name: 'ObjectType', type: 'string', optional: true },
      // Inherited from IfcProduct:
      { name: 'ObjectPlacement', type: 'ref', optional: true },
      { name: 'Representation', type: 'ref', optional: true },
      // Inherited from IfcElement:
      { name: 'Tag', type: 'string', optional: true },
      // Own:
      { name: 'PredefinedType', type: 'enum', optional: true },
    ],
  },
  // ... 875 more entities
};

Key design:

  • allAttributes includes all inherited attributes in the correct STEP order
  • This is essential because STEP files list attributes positionally, including inherited ones
  • abstract: true entities (like IfcBuildingElement) cannot be instantiated directly

Entity Registry

Maps STEP uppercase names to TypeScript PascalCase names:

export const ENTITY_REGISTRY: Record<string, string> = {
  'IFCWALL': 'IfcWall',
  'IFCDOOR': 'IfcDoor',
  'IFCWINDOW': 'IfcWindow',
  // ... 876 entries
};

Used during Pass 2 to resolve IFCWALLIfcWall for metadata lookup.


ID Management

The IdManager allocates unique expressIDs:

class IdManager {
  private nextId: number;

  constructor(existingEntities: Map<number, any>) {
    // Start after the highest existing ID
    this.nextId = Math.max(0, ...existingEntities.keys()) + 1;
  }

  allocate(): number {
    return this.nextId++;
  }
}
  • IDs are auto-incrementing integers starting from max(existing) + 1
  • This prevents collisions when adding entities to parsed models
  • IDs are never reused after deletion

Design Decisions Summary

Decision Choice Rationale
Entity storage Map<number, IfcGenericEntity> + type index O(1) lookup, fast type queries
Entity references number (expressID) No circular refs, easy serialization
Relationship indexes Eager rebuild + incremental update Fast spatial traversal without full scan
Generated code Interfaces, not classes No circular imports, lighter output
Schema metadata Runtime object with allAttributes STEP parser needs flat ordered attribute list
Type discriminant readonly type: string on IfcRoot Compatible with interface inheritance
GUID generation crypto.randomUUID() + IFC base64 Node.js 20+ built-in, no external deps
Delete cascade Off by default, opt-in Safe: geometry/properties can be shared
Module format Dual ESM/CJS via tsup Maximum compatibility
No geometry engine Data operations only (v1) Focused scope, geometry preserved in round-trips

Clone this wiki locally