-
Notifications
You must be signed in to change notification settings - Fork 1
Architecture
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)
express-parser ─────┐
v
step-serializer codegen
│ │
│ v
└──────► schema
│
v
step-parser
│
v
core
│
v
ifc-utils
-
Layer 0 packages are schema-agnostic.
step-serializercan parse any ISO 10303-21 file, not just IFC.express-parsercan 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-parsercombines 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.
IFC STEP files are parsed in two sequential passes. This separation keeps each pass simple and testable.
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
IFCWALLmeans — just parses the syntax
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_METADATAfor attribute name/order mapping - Uses
ENTITY_REGISTRYfor UPPERCASE → PascalCase type name resolution -
StepEntityRefvalues become plainnumber(expressID) -
StepEnumvalues become plainstring - Unknown entity types fall back to
IfcGenericEntitywith raw attributes
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 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 (
#42instead 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.
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
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:
- Parse the official EXPRESS schema (
IFC4X3_ADD2.exp) - Generate TypeScript from the AST
- Commit the generated code (for faster builds and IDE support)
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
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.
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:
-
allAttributesincludes all inherited attributes in the correct STEP order - This is essential because STEP files list attributes positionally, including inherited ones
-
abstract: trueentities (likeIfcBuildingElement) cannot be instantiated directly
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 IFCWALL → IfcWall for metadata lookup.
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
| 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 |
Ifc-Factory
Getting Started
Concepts
Packages
API & Reference
Development