ERP.db SRS — Discipline Validation Database¶
Foundation: BBC · DATA_MODEL · BIM_COBOL · MANIFESTO · TestArchitecture
Version: 1.3 (2026-03-31) Depends on: DISC_VALIDATE_SRS.md §9-10, DocAction_SRS.md §1.3, CALIBRATION_SRS.md
1. Schema — ERP.db (50 tables)¶
Authoritative DDL: migration/DV001_disc_validation_schema.sql, migration/DV003_element_mep_alias.sql
From-scratch rebuild: scripts/rebuild_erp.sh (DV001→DV049 + W019, zero manual SQL)
| Table | PK | Rows | Purpose |
|---|---|---|---|
ad_space_type |
space_type_id | 41 | Space type taxonomy (BEDROOM, OFFICE, etc.) |
ad_element_mep |
element_type | 12 | Canonical MEP types (OUTLET, SPRINKLER, etc.) |
ad_space_type_mep_bom |
(space_type_id, mep_product_id) | 186 | Discipline schedule: what MEP goes in each room |
ad_fp_coverage |
hazard_class | 4 | NFPA 13 sprinkler coverage thresholds |
ad_assembly_connector |
connector_id | 10 | Assembly connection topology |
ad_assembly_manifest |
manifest_id | 37 | Assembly interface definitions |
ad_wall_face |
id | 204 | Room boundary faces per building |
placement_rules |
id | 4801 | Host/offset placement rules |
ad_space_adjacency |
(space_type_a, space_type_b) | 22 | Room adjacency relationships |
ad_fp_trigger |
trigger_id | 12 | FP system trigger conditions |
ad_code_requirement |
(code_id, clause, element_type, space_type) | 23 | Building code requirements |
ad_room_slot |
slot_id | 38 | Room assembly slot definitions |
ad_space_dim |
space_type | 37 | Space dimension constraints |
ad_space_exterior_rule |
space_type_id | 24 | Exterior exposure rules |
ad_space_type_opening |
(space_type_id, opening_role, family_id) | 103 | Opening requirements per space |
ad_space_type_furniture |
space_type_id | 37 | Furniture schedule per space |
ad_space_type_mep |
space_type_id | 22 | MEP service requirements per space |
ad_element_mep_alias |
alias_id | 84 | IFC version-agnostic product resolution (§2.1) |
ad_ifc_class_map |
ifc_class | 46 | IFC class extraction authority (§2.2) |
ad_val_rule |
ad_val_rule_id | 415 | Mined dimension rules: typical W/D/H per (ifc_class, storey) |
ad_val_rule_param |
ad_val_rule_param_id | 1245 | Rule parameters (typical_width_mm, typical_depth_mm, typical_height_mm) |
W_Calibration_Result |
id | 0 | CalibrationTest output (runtime writes) |
AD_SysConfig |
Name | 3 | Schema/seed/alias version tracking |
ad_verb_pattern |
pattern_id | 9 | Verb detection hints: (product_type, ifc_class) → expected verb (DV035) |
ad_element_product_alias |
alias_id | 79 | Abstract product naming: ifc_class/element_name → product_id (DV034) |
ad_mep_anchor |
anchor_id | 0 | MEP anchor points extracted by IFCtoERP (W019, runtime populated) |
ad_mep_pattern |
pattern_id | 0 | MEP topology patterns mined by RouteWalker (W019, runtime populated) |
M_Product_Category carries AD_Org_ID (DV036) wiring the discipline chain:
M_Product → M_Product_Category → AD_Org_ID per §6.4. 127 seed categories
(IFC leaf classes + discipline parents + floor/room codes). Products auto-categorized
by ProductRegistrar.ensureProductCatalog() from ifc_class lookup.
Compliance tables (AD_Val_Rule, AD_Clash_Rule, AD_Occupancy_Class, AD_Validation_Result) and shared discipline recipes (M_BOM, M_BOM_Line) also live in ERP.db. Full compliance schema: see DocValidate.md.
2. Cross-Database References¶
SQLite has no cross-database FK. References use name convention — same
pattern as iDempiere AD_Reference lookups:
| ERP.db column | Resolves to | Method |
|---|---|---|
ad_element_mep.element_type |
M_Product by alias cascade |
Java: try ifc_class → predefined_type → type_class → element_name LIKE |
ad_space_type_mep_bom.mep_product_id |
ad_element_mep.element_type |
SQL within ERP.db (same DB) |
ad_assembly_connector.assembly_id |
M_Product.name |
Java: SELECT * FROM M_Product WHERE name = ? |
placement_rules.element_name |
M_Product.name or m_bom.bom_id |
Java: lookup by name |
ERP.db component_library.db
┌─────────────────────────┐ ┌──────────────────────┐
│ ad_element_mep │ │ M_Product │
│ element_type: SPRINKLER──── name ────▶│ name: SPRINKLER │
│ ifc_class: IfcFire... │ │ width, depth, height│
│ discipline: FP │ │ ifc_class │
│ ports: [{"IN":0.015}] │ └──────────────────────┘
└─────────────────────────┘
No geometry in ERP.db. No discipline metadata in component_library.db.
2.1 IFC Version-Agnostic Resolution — ad_element_mep_alias¶
IFC2x3 lumps all MEP into generic classes (IfcFlowTerminal). IFC4 splits them into specific subtypes (IfcOutlet). Real-world IFC files use vendor-specific naming. 4-tier resolution cascade:
Priority 1: ifc_class — IfcOutlet → OUTLET (IFC4 direct match)
Priority 2: predefined_type — POWEROUTLET → OUTLET (IFC4 enum)
Priority 3: type_class — IfcOutletType → OUTLET (IFC2x3 via IfcRelDefinesByType)
Priority 4: element_name — %Receptacle% → OUTLET (name pattern, last resort)
84 aliases covering all 12 canonical MEP types. DX resolution: 101/119 distinct MEP names (85%).
2.2 IFC Class Extraction Authority — ad_ifc_class_map¶
Authority table for extract.py. Read at startup — adding a new IFC type
= one INSERT, zero code changes.
ad_ifc_class_map (46 rows)
┌──────────────────────┬────────────┬─────────────────┬─────────────────┬──────────┬───────────┐
│ ifc_class (PK) │ discipline │ category │ attachment_face │ ifc_schema│ domain │
├──────────────────────┼────────────┼─────────────────┼─────────────────┼──────────┼───────────┤
│ IfcTrackElement │ RAIL │ TRACK_ELEMENT │ BOTTOM │ IFC4X3 │ RAIL │
│ IfcCourse │ ROAD │ PAVEMENT_LAYER │ BOTTOM │ IFC4X3 │ ROAD │
│ IfcBeam │ STR │ BEAM │ ENDS │ IFC4 │ BUILDING │
│ IfcLightFixture │ ELEC │ LIGHT │ TOP │ IFC4 │ BUILDING │
│ ... │ ... │ ... │ ... │ ... │ ... │
└──────────────────────┴────────────┴─────────────────┴─────────────────┴──────────┴───────────┘
See InfrastructureAnalysis.md §3.3.
3. Connection Map¶
CompilationPipeline → component_library.db (LOD)
PlacementValidator → ERP.db (compliance rules)
CalibrationDAO → ERP.db + TE_BOM.db
MEPAD/MEPBOMResolver → ERP.db (discipline metadata)
ManifestResolver → ERP.db (discipline metadata)
DocEvent → ERP.db (schedules) + component_library.db (LOD fetch)
Handler cascade H1-H6 → ERP.db (discipline metadata + compliance rules)
Connection compConn; // component_library.db — LOD catalog
Connection discConn; // ERP.db — discipline metadata + compliance rules
Connection bomConn; // {prefix}_BOM.db — building BOM
4. File Location¶
library/
├── component_library.db ← LOD catalog (M_Product, geometries)
├── ERP.db ← discipline metadata + compliance rules
└── {PREFIX}_BOM.db ← per-building BOM
migration/
├── DV001_ERP_schema.sql ← schema DDL (19 tables)
├── DV002_seed_from_component.sql ← seed via ATTACH (17 tables)
├── DV003_element_mep_alias.sql ← IFC alias cascade (84 rows)
├── DV005_ifc_class_map.sql ← IFC class extraction authority (46 rows)
└── V001..V006 ← compliance rule migrations
5. Traceability¶
| Witness | What it Proves | Test |
|---|---|---|
| W-DV-DB-SCHEMA | DDL creates all 20 required tables | DiscValidationDBTest |
| W-DV-DB-SEED | Seed data matches component_library.db source counts | DiscValidationDBTest |
| W-DV-DB-REF | Reference pointers resolve across databases | DiscValidationDBTest |
| W-DV-DB-ALIAS | Alias cascade resolves IFC2x3↔IFC4 (84 rows, 4 tiers) | DiscValidationDBTest |
| W-DV-DB-ND | Schema changes do not disturb component_library.db | DiscValidationDBTest |
| W-TACK-STABLE | Tack chain FP error ≤ 0.005mm per pair across all fleet buildings (evidence: ≤ 0.002mm, 1,653 pairs) | GEO gate G3-DIGEST |
6. AD_Org ↔ M_Product_Category — The Abstract Discipline Model¶
Core coupling¶
Every IFC product belongs to a category (WHAT it is) and every category belongs to a discipline (WHO manages it). One FK each — no strings, no switch statements, no per-building logic.
AD_Org (discipline) M_Product_Category (taxonomy) M_Product
┌──────────────┐ ┌───────────────────────┐ ┌──────────────┐
│ AD_Org_ID: 1 │◄── AD_Org_ID ──│ IFC_WALL │◄── Cat_ID ──│ WALL_EXT_290 │
│ Value: ARC │ │ IFC_DOOR │ │ DOOR_INT_810 │
│ │ │ IFC_WINDOW │ │ ... │
├──────────────┤ │ IFC_SLAB ...13 total │ └──────────────┘
│ AD_Org_ID: 2 │◄────────────│ IFC_BEAM │
│ Value: STR │ │ IFC_COLUMN ...6 total │
├──────────────┤ ├───────────────────────┤
│ AD_Org_ID: 3 │◄────────────│ IFC_FIRESUPPTERM │
│ Value: FP │ │ IFC_ALARM ...2 total│
├──────────────┤ ├───────────────────────┤
│ AD_Org_ID: 4 │◄────────────│ IFC_LIGHTFIXTURE │
│ Value: ELEC │ │ IFC_OUTLET ...6 total│
├──────────────┤ ├───────────────────────┤
│ AD_Org_ID: 10│◄────────────│ IFC_DUCTSEGMENT │
│ Value: MEP │ │ IFC_PIPESEGMENT │
│ │ │ IFC_VALVE ...9 total│
├──────────────┤ ├───────────────────────┤
│ Value: ACMV │◄────────────│ IFC_AIRTERMINAL │
│ Value: CW │◄────────────│ CW (parent) │
│ Value: SP │◄────────────│ IFC_SANITARYTERM │
│ Value: LPG │◄────────────│ LPG (parent) │
│ Value: * │◄────────────│ ASM (assembly) │
└──────────────┘ └───────────────────────┘
49 categories are linked to disciplines (DV036). Any IFC file — residential,
commercial, industrial — drops products into these categories via ifc_class
lookup. The discipline resolves automatically: M_Product.ifc_class → M_Product_Category.IFC_Class → AD_Org_ID.
Why this scales¶
The coupling is industry-level, not building-level. A residential house (RE) and a commercial terminal (CO) share the same 49-category discipline map. Adding a new industry vertical means adding IFC class categories, not code.
| Archetype | Example buildings | Disciplines exercised |
|---|---|---|
| RE (Residential) | SH, DX, FK | ARC, STR, MEP |
| CO (Commercial) | TE (Terminal) | ARC, STR, FP, ELEC, ACMV, CW, SP, LPG |
| IN (Infrastructure) | RD, RL | ARC, STR |
Archetype patterns (§6.12.3) are also abstract — CW_TERMINAL_01 was mined from a commercial terminal but describes universal cold-water topology (meter → junction → junction → fixture). Any building's anchors match against it.
What AD_Org replaces¶
All former string-based discipline resolution collapses to one FK:
m_bom.bom_categorystring →AD_Org_IDFK on M_Product_CategoryC_OrderLine.Disciplinestring →AD_Org_IDFK- Scattered
resolveDiscipline(ifcClass)logic → single FK lookup
Forensic verification¶
The pipeline emits a [FORENSIC] DISC CHAIN log line after every compilation
showing the live AD_Org ↔ M_Product_Category coupling with product counts.
Any regression (uncategorized products, missing disciplines) triggers a WARN.
6.1 Spatial Model — Space + Occupant + Verb + Rule¶
A discipline is a contractor with a checklist, not a room with walls.
Disciplines are not spatial containers — a fire protection pipe network spans the entire floor. Discipline is a line attribute (AD_Org_ID), not a tree level. See TerminalAnalysis.md §Compilation Status.
The BOM hierarchy is recursive and abstract:
SPACE (M_Product, IsBOM=Y)
└── OCCUPANT line (M_BOM_Line, with AD_Org_ID + verb_ref)
A SPACE has an AABB (extent) and an M_Product_Category (what kind of space). An OCCUPANT has an AD_Org_ID (who), a verb_ref (how), and AD_Val_Rule (checklist).
The compiler resolves placement through three stages, matching how iDempiere processes documents:
┌─────────────────────────────────────────────────────────────┐
│ 1st: DocEvent per Org (discipline blanket + govt standards) │
│ AD_DocEvent_Rule fires top-down as the walker traverses │
│ root → leaf. AD_Org blanket-applies ALL rules for the │
│ discipline — spacing, connectivity, host, AND government │
│ standards (NFPA 13, UBBL). Jurisdiction-swappable here. │
│ Same as iDempiere ModelValidator per organization. │
│ │
│ 2nd: AttributeSet (per-product / per-instance) │
│ M_AttributeSet defines what CAN vary per product type. │
│ M_AttributeSetInstance carries actual values per instance. │
│ Resolved per line item — K-factor, dimensions, material. │
│ │
│ 3rd: AD_Val_Rule (user per-line override — last) │
│ User sees exploded sub-lines, adds/changes/waives rules. │
│ Same as iDempiere AD_Val_Rule — a lookup filter the user │
│ attaches to specific lines. Not automatic. Not blanket. │
└─────────────────────────────────────────────────────────────┘
for each BOM line in parent:
verb = line.verb_ref → Strategy (GoF)
org = child.product.AD_Org_ID → 1st: DocEvent blanket + standards
asi = orderline.ASI → 2nd: per-instance attributes
verb.place(child, parent.space, asi)
// 3rd: AD_Val_Rule — only if user attached override to this line
This is standard iDempiere processing order: ModelValidator (Org-scoped) → line item resolution (ASI) → user validation rules (AD_Val_Rule).
Anti-pattern: shouldSkip(). There is ONE compile path, not two paths
with a skip. The walker always walks the BOM tree. The verb determines what
happens at each line — PLACE emits at tack offset, ROUTE generates from
rules, FRAME generates structural grid. A shouldSkip() that produces an
empty BuildingSpec and falls through to a separate emit path is the same
structural cheat as if ("CO".equals(...)) — just checking verbs instead
of category. The fix is one walker, verb-dispatched, no skip.
No if ("CO".equals(...)). No if (discipline == "FP"). Behaviour from
metadata, not from code — same as iDempiere's DocAction pattern.
Covering vs Inside — two spatial relationships, both just BOM lines:
| Relationship | Verb family | Example |
|---|---|---|
| INSIDE | PLACE | Sofa at (dx,dy,dz) in living room |
| COVERING | ROUTE, TILE, FRAME, WIRE | Sprinklers covering a floor per NFPA 13 |
INSIDE: child sits AT a point within the parent space (tack offset = position). COVERING: child SPANS the parent space (verb determines pattern, rule determines density).
6.2 Discipline Profiles — Abstract Recipe, Space-Dependent Placement¶
Each discipline has a recipe (BOM cascade from its top-level Category) and Org defaults (discipline-wide practice). The parent space determines quantity and placement. Government standards validate the result post-hoc.
| AD_Org | Top Category | Verb | Spatial | Recipe cascade |
|---|---|---|---|---|
| ARC (1) | ARC_DESIGN | PLACE, TILE | INSIDE | Walls, doors, windows, plates, furniture |
| STR (2) | STR_FRAME | FRAME | COVERING | Column + Beam + Slab |
| FP (3) | FP_MAIN_ROOM | ROUTE | COVERING | Riser → branches → fittings → heads |
| ELEC (4) | ELEC_DISTRIBUTION | WIRE | COVERING | Panel → circuits → fixtures |
| ACMV (5) | ACMV_PLANT | ROUTE | COVERING | AHU → ducts → fittings → terminals |
| CW (6) | CW_SUPPLY | ROUTE | COVERING | Riser → pipe runs → fittings → valves |
| SP (7) | SP_DRAINAGE | ROUTE | COVERING | Stack → drainage pipes → fixtures |
| LPG (8) | LPG_SUPPLY | ROUTE | COVERING | Meter → gas piping → fittings |
OrderLine entry point: C_OrderLine.Product has a Category (the top
Category of that discipline). BomDrop explodes the product's BOM, cascading
through the discipline's own BOM tree. Category at each tier = the product
group (substitution shelf). The designer can swap any product for another
in the same Category without changing the BOM structure.
The recipe is abstract — "cover this zone with sprinklers." Processing follows iDempiere order: DocEvent per Org (1st, discipline blanket + government standards) → ASI resolution per instance (2nd) → AD_Val_Rule user override on specific lines (3rd, on demand).
Cardinal rules (also in class Javadoc): - MEP_RECIPE is abstract and reusable. It encodes a geometric pattern (standoff, chain geometry). Never explode recipe runs into per-instance LEAF rows in compilation. - Validation Rules drive final expression. DV rules (AD_Rule / M_BOM Validation Layer) resolve a recipe to a specific project. The callout only registers scope and Qty. - One DISCIPLINE row per discipline. Qty = IFC element count from extraction (ad_sysconfig MEP__COUNT), not recipe archetype count. - LEAF children from _SYSTEM BOM only** (FP_RISER, FP_SPRINKLER_LAYOUT, etc.).
See:
OrderLineProductCallout.javaclass Javadoc,IFCtoERP.javaclass Javadoc.
6.3 Three-Stage Validation — iDempiere Processing Order¶
| Stage | iDempiere parallel | What it does | Fires | Example |
|---|---|---|---|---|
| 1st: DocEvent per Org | ModelValidator.docValidate() | Blanket discipline rules INCLUDING government standards, top-down | Automatically during BOM walk, per AD_Org | FP: NFPA 13 spacing, UBBL fire rating, general pipe sizing rules |
| 2nd: AttributeSet | M_AttributeSetInstance | Per-product/per-instance resolution | Per line item during placement | K-factor=5.6, pipe_dia=50mm, material=copper |
| 3rd: AD_Val_Rule | AD_Val_Rule (lookup filter) | User-initiated per-line rule addition or override | On demand, after explosion, on specific sub-lines | User adds stricter spacing rule to a particular FP branch |
| Cross-discipline | AD_Clash_Rule | Clearance between disciplines | After all disciplines placed | 150mm clearance FP vs ELEC |
Key distinction: In iDempiere, AD_Val_Rule is a lookup filter — it narrows available choices per field, not a document validator. ModelValidator (DocEvent) is where real validation lives. Government standards (NFPA 13, UBBL, MS1183) are general enough to be 1st-stage blanket rules — they apply to EVERY element in the discipline. 3rd-stage AD_Val_Rule is for when the user sees specific exploded sub-lines and wants to add, change, or waive a rule on THOSE lines.
1st: DocEvent(org) → blanket discipline rules + government standards
2nd: ASI resolution → per-instance attributes on each line
3rd: AD_Val_Rule → user adds/changes/waives rule on specific lines
Per-space compliance (S100-p82, f3c4d793): Room dimension evaluation (area, width, height) against AD_DocEvent_Rule is implemented. SKIP propagation cascades through the BOM walk.
Jurisdiction-swappable: Government standards live in 1st-stage DocEvent
rules, scoped by jurisdiction. Same BOM, same Org, different jurisdiction
→ different DocEvent rules fire. Malaysian building uses UBBL rules,
US building uses NFPA/IBC rules. The BOM and ASI don't change.
AD_DocEvent_Rule — 1st Stage Schema (ERP.db)¶
DocEvent rules are shared across all buildings (like AD_Org, M_Product). They live in ERP.db. Each rule is scoped to an AD_Org (discipline) and optionally to a jurisdiction.
-- Ready-made discipline event rules in ERP.db
-- Fires automatically during BOM walk when AD_Org matches
CREATE TABLE IF NOT EXISTS AD_DocEvent_Rule (
ad_docevent_rule_id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_org_id INTEGER NOT NULL DEFAULT 0, -- 0=all, 3=FP, 4=ELEC, etc.
name TEXT NOT NULL, -- 'NFPA13_LH_SPACING'
description TEXT,
rule_type TEXT NOT NULL, -- SPACING, CONNECTIVITY, HOST,
-- COMPLETENESS, DIMENSION, STANDARD
standard_ref TEXT, -- 'NFPA 13 §8.6.2.2.1', 'UBBL s.43(1)'
jurisdiction TEXT, -- MY, US, UK, SG, INTL, NULL=universal
check_method TEXT NOT NULL, -- MIN_DISTANCE, MAX_DISTANCE,
-- REQUIRED_HOST, COUNT_PER_AREA,
-- MIN_DIMENSION, MAX_COVERAGE, DIMENSION_RANGE
ifc_class TEXT, -- target element (NULL=all in discipline)
m_product_category_id TEXT, -- target category (NULL=all)
severity TEXT NOT NULL DEFAULT 'WARN', -- BLOCK, WARN
firing_event TEXT NOT NULL DEFAULT 'BEFORE_PLACE',
-- BEFORE_PLACE, AFTER_PLACE, AFTER_COMPLETE
is_active INTEGER NOT NULL DEFAULT 1,
provenance TEXT, -- EXTRACTED:UBBL_1984, MINED:TE, RESEARCHED
FOREIGN KEY (ad_org_id) REFERENCES AD_Org(ad_org_id)
);
CREATE TABLE IF NOT EXISTS AD_DocEvent_Rule_Param (
ad_docevent_rule_param_id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_docevent_rule_id INTEGER NOT NULL,
name TEXT NOT NULL, -- max_spacing_mm, min_area_m2, min_count
value TEXT NOT NULL, -- '4600', '9.3', '1'
value_type TEXT DEFAULT 'NUM', -- NUM, TEXT, BOOL
condition_expr TEXT, -- 'productCategory IN (BD,LR,DR)'
FOREIGN KEY (ad_docevent_rule_id) REFERENCES AD_DocEvent_Rule(ad_docevent_rule_id)
);
CREATE INDEX idx_docevent_rule_org ON AD_DocEvent_Rule(ad_org_id);
CREATE INDEX idx_docevent_rule_jurisdiction ON AD_DocEvent_Rule(jurisdiction);
Existing ERP.db ad_val_rule (415 rows): These are mined DIMENSION_RANGE
observations from 34 buildings — they are effectively 1st-stage DocEvent rules.
Migration path: copy qualifying rows into AD_DocEvent_Rule with
rule_type='DIMENSION', check_method='DIMENSION_RANGE',
provenance='MINED:{building}'.
6.3.1 Current DV Rule Coverage — MEP Gap¶
Audit findings (S104-closeout, 2026-04-01). Queries run against library/ERP.db.
Stage 1 — AD_DocEvent_Rule (8 rows total):
- 7 rows at AD_Org_ID=0 (blanket/ARC): UBBL dimension rules (room area, kitchen dim,
corridor width, staircase width, ceiling height, bathroom area, egress distance).
All FiringEvent=BEFORE_COMPILE. CheckMethods: MIN_AREA, MIN_DIMENSION, MIN_WIDTH,
MIN_HEIGHT, MAX_DISTANCE.
- 1 row at AD_Org_ID=3 (FP): UBBL_PVII_SPRINKLER_SPACING — SPACING, MAX_SPACING.
- Gap: ACMV, CW, ELEC, SP, LPG have zero Stage 1 DocEvent rules. §6.3 declares
"FP: NFPA 13 spacing, UBBL fire rating, general pipe sizing rules" — only UBBL
sprinkler spacing is present. NFPA 13 coverage area, pipe sizing, flow rate rules
are absent from AD_DocEvent_Rule.
Stage 3 — ad_val_rule (415 rows):
- All rows are check_method='DIMENSION_RANGE' (physical dimension observations
mined from 34 buildings). No AD_Org_ID column — mapped by ifc_class.
- MEP IFC classes covered: IfcAirTerminal (3), IfcDuctFitting (4), IfcDuctSegment (4),
IfcFireSuppressionTerminal (1), IfcFlowController (5), IfcFlowFitting (10),
IfcFlowSegment (11), IfcFlowTerminal (29). Total MEP dimension rules: ~67 rows.
- These are mined dimension ranges (W/D/H observations), not code requirement checks.
No spacing limits, flow rates, clearance requirements, or coverage area rules.
- Gap: No Stage 3 rules that enforce NFPA, IMC, IPC, NEC, UBBL MEP clauses.
Hardcoded Java thresholds (geometric heuristics, not code requirements):
- RoutingConstraints.java (all values extracted from PATTERN G3, IFC-mined):
- Wall clearance by discipline: ACMV 175mm, FP 173mm, CW 216mm, SP 223mm, LPG 106mm
- Ceiling clearance averages and minimums per discipline
- 50mm wall clearance tolerance (isValidWallClearance line 101)
- 10mm ceiling clearance tolerance (isValidCeilingClearance line 116)
- FederationConstants.java:94: MEP-to-structure minimum clearance constant
- Assessment: these are geometric routing heuristics from observed IFC data, not
codified requirements. They belong in RoutingConstraints.java or could migrate
to ad_code_requirement but are not missing from any DV stage.
ad_code_requirement table (23 rows):
- Populated with real code requirements: NFPA_13 (sprinkler spacing/coverage by room),
NEC_2020 (outlet spacing, GFCI, lighting, switch clearance), IMC_2021 (exhaust fan),
IPC_2021 (sink/toilet), MS_1184 (door/toilet), MS_1228 (floor trap), NFPA_101
(emergency lighting distance).
- Schema has: max_spacing_m, min_clearance_m, coverage_radius, qty_per_area etc.
- Not yet connected to DV rule generation. No query path from ad_code_requirement
→ AD_DocEvent_Rule or ad_val_rule. This table is the intended home for NFPA/UBBL
MEP code requirements (confirmed by schema), but migration to Stage 1 is not done.
Conclusion: MEP DV rule coverage is a real gap. Stage 1 has 1 FP rule (UBBL sprinkler
spacing). Stages 2–3 have no MEP code requirement checks. ad_code_requirement has the
right data but is not wired into the validation pipeline. A future migration prompt should
bridge ad_code_requirement → AD_DocEvent_Rule for each discipline's code references.
AD_Val_Rule — 3rd Stage (ERP.db, compliance rules)¶
The V001 schema stays as-is within ERP.db. AD_Val_Rule is a user-initiated per-line rule addition/change/waiver (government standards are 1st-stage DocEvent, not 3rd-stage AD_Val_Rule).
The user opens an exploded order, sees specific sub-lines, and attaches: - ADD: "Apply stricter 3000mm spacing to THIS branch" (new rule on line) - CHANGE: "Override threshold from 4600mm to 3800mm for THIS zone" - WAIVE: "Acknowledge and accept this deviation" (AD_Val_Rule_Exception)
This is exactly how iDempiere's AD_Val_Rule works — a lookup filter that the user configures on a specific field/line to narrow or adjust what's valid.
6.4 BOM Tree Structure¶
BUILDING → FLOOR → LEAF — same depth for all building categories.
Tack is element.minX - floor.minX. Always positive. Discipline resolves
from the child product: m_bom_line.child_product_id →
M_Product → M_Product_Category → AD_Org_ID. Standard iDempiere —
every record carries AD_Org, the line is just a relationship.
| Component | Responsibility |
|---|---|
DisciplineBomBuilder |
LEAF lines directly under FLOOR (no DISCIPLINE SET level) |
BomValidator |
W-TACK-1/W-BUFFER-1 check FLOOR→LEAF |
CompilationPipeline |
Single walk path, no category skip hack |
PlacementCollectorVisitor |
Resolve AD_Org_ID from child product, not discipline stack |
6.5 The BOM Is Already Perfect¶
A BOM is a BOM — same as manufacturing a car. You don't label an engine assembly with a tier tag. It's a product with children. The tree structure IS the hierarchy:
- Root:
getParentBOM()returns null - Any level:
getChildren()returns its children - Leaf:
getChildren()returns empty - Category:
getProductCategory()returns the substitution shelf
No level labels. No vocabulary. A building, a car, a bridge, a ship — same three methods, different products. M_Product_Category groups interchangeable products at each level (same shelf = same swap pool).
v_qualified_bom view currently uses a legacy bom_type
bind parameter. Migration pending to use M_Product_Category instead.
6.6 Shared Discipline Recipes in ERP.db¶
Discipline BOMs are shared across all buildings. FP is FP — one recipe, all buildings, same rules. ACMV is ACMV. The recipe does not change; the space and rules determine the result.
ERP.db holds the shared recipes alongside AD_Org:
ERP.db
├── AD_Org (WHO: FP, ACMV, ELEC, CW, SP, LPG)
├── AD_SysConfig (discipline-wide defaults per Org)
├── M_Product (WHAT: sprinklers, pipes, ducts, fittings)
├── M_Product_Category (TIER: product taxonomy = substitution shelf)
└── M_BOM — shared discipline recipes (each a BOM cascade):
├── FP_SYSTEM (Category=FP_MAIN_ROOM, Org=FP)
│ ├── FP_RISER (Category=FP_RISER, verb=ROUTE)
│ ├── FP_SPRINKLER_LAYOUT (Category=FP_DISTRIBUTION, verb=ROUTE)
│ └── FP_PUMP_LINK (Category=FP_SUPPLY, verb=ROUTE)
├── ACMV_SYSTEM (Category=ACMV_PLANT, Org=ACMV)
├── ELEC_SYSTEM (Category=ELEC_DISTRIBUTION, Org=ELEC)
└── CW_SYSTEM (Category=CW_SUPPLY, Org=CW)
ERP.db also contains (government standards):
├── AD_Val_Rule (NFPA 13, UBBL, MS1183 — compliance checks)
├── AD_Val_Rule_Param (thresholds per rule)
└── AD_Clash_Rule (cross-discipline clearance)
component_library.db is strictly leaf geometry (meshes, LODs). It never holds BOMs or recipes — only what things look like, not how they assemble.
OrderLine → top Category → BOM cascade:
C_Order: "Build TE"
├── C_OrderLine #1: TE_ARC_STR (Category=ARC, Org=ARC) ← extracted BOM
├── C_OrderLine #2: FP_SYSTEM (Category=FP_MAIN_ROOM, Org=FP)
├── C_OrderLine #3: ACMV_SYSTEM (Category=ACMV_PLANT, Org=ACMV)
├── C_OrderLine #4: ELEC_SYSTEM (Category=ELEC_DISTRIBUTION, Org=ELEC)
├── C_OrderLine #5: SP_SYSTEM (Category=SP_DRAINAGE, Org=SP)
├── C_OrderLine #6: CW_SYSTEM (Category=CW_SUPPLY, Org=CW)
├── C_OrderLine #7: LPG_SYSTEM (Category=LPG_SUPPLY, Org=LPG)
└── C_OrderLine #8: STR_SYSTEM (Category=STR_FRAME, Org=STR)
Eight lines. Each line's Product has a top Category — the entry point into that discipline's BOM cascade. BomDrop explodes recursively. At each tier, Category = the substitution shelf (designer can swap products within it).
Processing order (same as iDempiere document processing): 1. DocEvent per Org — discipline blanket rules + government standards (NFPA 13, UBBL, MS1183) apply top-down as walker traverses root→leaf. Jurisdiction-swappable at this stage. 2. ASI resolution — per-product/per-instance attributes (K-factor, dimensions, capacity) 3. AD_Val_Rule — user-initiated per-line override/addition on specific exploded sub-lines
Jurisdiction-swappable: same BOM, same Org, different AD_DocEvent_Rule set for Malaysian (UBBL) vs US (NFPA) code. BOM and ASI don't change.
6.6.1 Discipline Separation — Two-Class Architecture¶
Discipline separation spans two pipeline classes:
Class A — IFCtoBOM (extraction): Produces the ARC+STR envelope only.
MEP elements (FP, ACMV, ELEC, CW, SP, LPG) are not written into the
per-building *_BOM.db. IFCtoBOM counts MEP elements per discipline from
elements_meta.discipline and writes them back into the YAML
(disciplines: [{disc: FP, qty: 99}]) AND to ad_sysconfig in BOM DB.
The YAML becomes the reusable preset template for that building type.
YAML as chooser: Designer opens the YAML, sees pre-populated discipline
qtys from extraction, can reduce scope (e.g., qty: 50 for a partial wing).
Qty=0 or qty>capacity both mean "fill the whole building per rules" —
the compiler fills until no more space and stops gracefully, never forces.
For RE buildings, mep_disciplines: controls which DISCs the Callout
creates (default: ELEC + SP). Deleting a discipline from YAML removes it.
Without YAML (direct OrderLine), user gets default DISCs from Callout.
Class B — DAGCompiler (compilation): Product Callout reads YAML
disciplines: qty, then creates DISCIPLINE OrderLines pointing to shared
recipes in ERP.db (FP_SYSTEM, ACMV_SYSTEM, etc.). DocEvent per Org
fires on each discipline OrderLine — applies jurisdiction rules (NFPA 13,
UBBL, MS 1228). RouteBuilder/CrawlRouter generates MEP routing until qty
terminals served. AD_Val_Rule validates the output.
Pending: DisciplineBomBuilder currently writes all elements flat
under FLOOR. Corrective in prompt 00b_discipline_separation.txt —
IFCtoBOM produces ARC+STR only, MEP comes from ERP.db shared recipes
via DAGCompiler Callout.
For generative buildings, the compiler applies the shared recipes using verb Strategy + DocEvent per Org + ASI. AD_Val_Rule validates the output.
6.7 GoF Design Patterns¶
| Pattern | Application |
|---|---|
| Composite | BOM tree: SPACE contains OCCUPANT lines, recursively |
| Visitor | BOMWalker visits each line |
| Strategy | Verb determines placement method (DocEvent + ASI) |
| Specification | AD_DocEvent_Rule validates during walk (1st, blanket + standards). AD_Val_Rule = user override (3rd, per-line) |
6.8 Cross-References¶
| Building | Disciplines | Concern |
|---|---|---|
| TE | 8 (ARC,STR,FP,ACMV,ELEC,CW,SP,LPG) | SET as tree level → tack overflow. TerminalAnalysis.md |
| DM | 3 (ARC,STR,FP) | First FP trial — addDiscipline(). DemoHouseAnalysis.md |
| FK | 2 (ARC,STR) + ROOF debate | FZKHausAnalysis.md |
| Infrastructure | ROAD,RAIL,GEO,LAND,SIGN | Extended codes. InfrastructureAnalysis.md |
6.9 Stair Validation Rules — Candidate AD Table Extensions (S100-p84)¶
Existing infrastructure: ad_stair_requirement (7 rows in BOM.db, seeded by
scripts/create_ad_vertical_circulation.py), VerticalCirculationAD.java,
VerticalCirculationValidator.java, StairwellCheck.java.
Rules NOT yet in ad_stair_requirement — candidates for addition:
| Rule ID | Parameter | Value | Standard |
|---|---|---|---|
| STAIR_COMFORT_2RG | 2R+G check | 550-700mm (ideal 630) | Blondel formula |
| STAIR_HEADROOM | min_headroom_mm | 2000 | UBBL practice / BS 5395 |
| STAIR_MAX_FLIGHT | max_flight_rise_mm | 3000 | UBBL By-Law 168 |
| STAIR_RISER_UNIFORM | max_variance_mm | 9.5 | IBC s1011.5.4 |
| STAIR_GUARD_HEIGHT | min_guard_mm | 1070 | IBC s1015.3 |
| STAIR_GUARD_OPENING | max_sphere_mm | 100 | IBC s1015.4 (child safety) |
| STAIR_NOSING_MAX | max_nosing_mm | 32 | IBC s1011.5.5 |
| STAIR_PRESSURIZE | pressure_pa | 50-100 | UBBL By-Law 178 (>18m) |
TE relevance: 178 unfactored stair components across GF-L4. Building height 59.8m → >18m threshold → 2.0hr fire rating, min 1200mm width, pressurization required, min 2 stairs. These rules + ASI (per-instance run length, landing width) resolve stair geometry without manual pattern recognition. See TerminalAnalysis.md §Stair Validation Rules.
6.10 Movement Verbs — Routing Linear Elements Through Buildings¶
Linear MEP elements (pipes, ducts, cables) don't just get placed at points. They move through the building — following surfaces, bending at corners, branching at junctions, penetrating floors. Each direction change or connection produces a joint fitting product alongside the segment.
TE extraction proves fittings outnumber segments in most disciplines:
| Discipline | Segments | Fittings | Ratio | Implication |
|---|---|---|---|---|
| FP | 2,672 | 3,146 | 1.18× | More joints than pipes |
| ACMV | 568 | 713 | 1.26× | Every duct turn = fitting |
| CW | 619 | 638 | 1.03× | Nearly 1:1 |
| SP | 455 | 372 | 0.82× | Longer runs, fewer turns |
| LPG | 75 | 87 | 1.16× | Small system, many valves |
Movement Verb Catalogue¶
Each movement verb produces two BOM lines: a segment (pipe/duct/cable) and a joint fitting (elbow/tee/reducer/sleeve). The fitting is an M_Product from the component library with its own LOD mesh.
| Verb | Action | Joint product | Geometry |
|---|---|---|---|
| FOLLOW | Trace along surface (wall, ceiling, beam) | None — straight run | PipeSegment / DuctSegment, qty = length ÷ stock_size |
| BEND | Change direction at angle | Elbow fitting (90°, 45°, custom) | ForgeEngine: PIPE_BEND (arc geometry, S99) |
| RISE / DROP | Change elevation (through floor or along wall) | Elbow or offset fitting | Vertical segment + 2 elbows |
| BRANCH | Split into sub-paths | Tee or Wye fitting | T-junction, diameters from parent + children |
| REDUCE | Change diameter | Reducer fitting | Concentric or eccentric reducer |
| PENETRATE | Pass through floor or wall | Sleeve + fire collar (if fire-rated) | Hole + sleeve product + sealant |
Composition in BIM COBOL¶
Movement verbs compose into a routing script. Each line in the script produces BOM lines (segments + fittings):
ROUTE FP FROM PUMP_ROOM
FOLLOW CEILING SPACING 4500 → PipeSegment × N
BEND 90 AT GRID_A → PipeFitting (elbow)
BRANCH TEE TO ROOM_101 ROOM_102 → PipeFitting (tee) + 2 sub-routes
REDUCE 50mm TO 25mm → PipeFitting (reducer)
PENETRATE SLAB WITH FIRE_COLLAR → Sleeve + FireCollar products
The BOM IS the routing. No graph data structure needed — parent-child with sequence controlling path order. Each fitting is a BOM child with qty=1 (EA) at the transition point.
UOM conversion: CrawlOps produce lengths in mm internally. RouteStage converts to the product's cost_uom at persistence: mm ÷ 1000 → M for pipe/duct segments; fitting qty stays 1 (EA). This is the single conversion point — all internal geometry is mm, all persisted qty matches M_Product.cost_uom.
Joint Product Resolution¶
When a movement verb needs a fitting, it resolves from the component library:
Inputs: verb (BEND), angle (90°), parent_diameter (50mm), material (Poly Steel)
Lookup: M_Product WHERE ifc_class='IfcPipeFitting'
AND diameter=50 AND material='Poly Steel' AND angle=90
Result: Product_ID → LOD mesh from component_library.db
If no exact match: ForgeEngine computes the geometry (PIPE_BEND, S99). ASI carries per-instance overrides (actual angle, actual diameter).
Movement Verbs per Discipline¶
| Discipline | Primary verbs | Typical route | Standards governing routing |
|---|---|---|---|
| FP | FOLLOW ceiling, BRANCH tee, PENETRATE slab | Riser → floor header → branches → heads | NFPA 13 §8 (spacing), UBBL Part VII |
| ACMV | FOLLOW ceiling void, BEND, BRANCH, REDUCE | AHU → main duct → branches → diffusers | MS 1525 (duct sizing), ASHRAE 62.1 |
| ELEC | FOLLOW cable tray, BRANCH, PENETRATE | DB → riser → tray → outlets/lights | MS IEC 60364 (cable sizing), NEC 300.4 |
| CW | FOLLOW wall/ceiling, RISE, BRANCH, REDUCE | Tank → riser → floor header → fixtures | MS 1228 (pipe sizing by fixture unit) |
| SP | DROP (gravity), FOLLOW gradient, BRANCH wye | Fixtures → waste → stack → drain | MS 1228 (min gradient 1:40 / 1:60) |
| LPG | FOLLOW ext wall, BRANCH, REDUCE | Meter → riser → kitchen → gas points | MS 830 (gas installation) |
SP is special: all other disciplines flow outward/upward from a source. SP flows downward by gravity. FOLLOW must maintain minimum gradient (1:40 for 100mm pipe). The verb checks slope at each segment.
6.11 Parasitic Discipline Implementation Tasks¶
Implementation in 4 phases: POC first to prove assumptions, then build out.
Phase 0 — POC: Prove the Wiring (2-3 prompts)¶
Early proof-of-concept tasks that validate the architecture before committing to the full build. Each is a standalone Rosetta Stone test.
| Task | What it proves | Deliverable | Gate |
|---|---|---|---|
| T0.1 Service room categories | ARC rooms with discipline-typed M_Product_Category tack correctly; FP_SYSTEM origin resolves from ARC pump room | Seed 6 service room products (FP, ACMV, ELEC, CW, SP, LPG) in ERP.db M_Product. Add to SH YAML as dummy rooms. Verify category match query returns correct dx/dy/dz. | SH 7/7, query returns pump room coords |
| T0.2 OrderLine callout POC | Callout reads CO BOM children and auto-creates discipline OrderLines | Implement OrderLineProductCallout.java. Wire to C_OrderLine.M_Product_ID. Test: set product=BUILDING_TE_STD → verify 8 OrderLines created with correct AD_Org_ID and sequence. |
Unit test: 8 lines, correct orgs |
| T0.3 Parasitic qty walk | Walker handles qty-only BOM lines (no dx/dy/dz) without crashing; produces container c_orderlines with correct qty | Add FP_SYSTEM as 2nd OrderLine on SH (qty=2 sprinklers, dummy). Verify walker produces c_orderline with qty=2, host_type=LEAF, AD_Org_ID=3. No placement — just qty passthrough. | SH 7/7 (no regression), FP orderline exists |
| T0.4 FOLLOW verb POC | ROUTE verb extended: FOLLOW a ceiling surface, lay N segments of stock length | Add FOLLOW as ROUTE sub-mode. Test: FOLLOW ceiling in SH living room → produces PipeSegment × ceil(room_length / stock_pipe_length). Fitting count = 0 (straight run). | Witness: W-FOLLOW-1 |
Phase 1 — Movement Verbs (3-4 prompts)¶
Core routing verbs, each tested independently on SH before fleet.
| Task | Verb | Joint product | Test |
|---|---|---|---|
| T1.1 BEND | Change direction, insert elbow | Elbow fitting from component library or ForgeEngine PIPE_BEND | W-BEND-1: angle + diameter → correct fitting product |
| T1.2 BRANCH | Split path, insert tee/wye | Tee fitting, parent + child diameters | W-BRANCH-1: main → 2 sub-routes, tee inserted |
| T1.3 REDUCE | Change diameter, insert reducer | Reducer fitting | W-REDUCE-1: 50mm→25mm, reducer product |
| T1.4 PENETRATE | Pass through slab/wall | Sleeve + fire collar (if fire-rated) | W-PENETRATE-1: sleeve inserted at floor crossing |
Phase 2 — Discipline Routing (3-4 prompts)¶
Wire movement verbs into discipline-specific DocEvent rules.
| Task | Discipline | Route pattern | Standard |
|---|---|---|---|
| T2.1 FP routing | FP | Riser → floor header → branches → sprinkler grid | NFPA 13 §8 spacing, ad_fp_coverage hazard class |
| T2.2 ELEC routing | ELEC | DB → cable tray → light fixture grid per room | MS 1525 lighting power density, IES lux |
| T2.3 CW + SP routing | CW, SP | CW: tank → riser → fixtures. SP: fixtures → stack → drain (gravity) | MS 1228, UPC gradient rules |
| T2.4 ACMV routing | ACMV | AHU → main duct → branches → air terminals | MS 1525, ASHRAE 62.1 air changes |
Phase 3 — Integration (2-3 prompts)¶
| Task | What | Deliverable |
|---|---|---|
| T3.1 Multi-discipline TE | All 8 OrderLines explode on TE with parasitic walk | TE discipline distribution matches extraction (§3.6.3 expected counts) |
| T3.2 Cross-discipline clearance | NEC_ELEC_SP_CLEARANCE fires after all disciplines placed | Detect 11 known overlaps from TE mining (M12) |
| T3.3 Infrastructure POC | BR (bridge) with zone-based anchors instead of rooms | BR 7/7 with STR + DRAIN discipline OrderLines |
| T3.4 RE subset | SH with ARC + ELEC + SP (3 disciplines, subset callout) | SH 7/7, 3 OrderLines, light fixtures + plumbing placed |
T3.1 Implementation — Pipeline Wiring¶
P104 verification found 4 blockers. Resolution:
B1 — RouteDocEvent not in pipeline. RouteDocEvent.fireAll() must be called during compilation, after CompileStage (which produces ARC c_orderline positions) and before WriteStage (which writes output.db). The callout (OrderLineProductCallout.onProductChanged + expandDisciplineLines) must also move from BuildingRegistryTest into the pipeline at the same point.
Pipeline sequence with routing:
CompileStage → [callout + RouteDocEvent.fireAll()] → WriteStage
The callout reads ERP.db shared BOMs, creates DISCIPLINE OrderLines, expands LEAF children with qty. RouteDocEvent reads BuildingGeometry (ARC c_orderline positions) and produces RouteResult per discipline. Both must fire before WriteStage persists to output.db.
B2 — No edge persistence. CrawlRouter produces CONNECTS_TO edges in-memory. BIMEyes P16 (WasteGradientProof) and P17 (SystemConnectedProof) need queryable data in output.db.
Output tables:
| Table | Columns | Source | Consumer |
|---|---|---|---|
| system_edges | discipline, from_index, to_index, from_xyz, to_xyz, edge_type | RouteResult.edges() | P17 BFS connectivity |
| system_nodes | discipline, node_index, node_type, xyz, diameter_mm, product, length_mm | RouteResult.segments() + fittings() | P16 SP gradient check |
B4 — BIMEyes gate. ProveStage gates P15/P16/P17 behind hasRelationalData() which checks ad_room_boundary. CO buildings have DISCIPLINE OrderLines but no ad_room_boundary. Gate must also check system_edges > 0.
T3.4 Implementation — RE Subset¶
B3 — Callout pre-populates by category, YAML removes exceptions.
The Callout inserts a sensible default per M_Product_Category. The user (or YAML) always sees something — never starts from blank:
| Category | Callout default | Rationale |
|---|---|---|
| CO (Commercial) | all 6 MEP (FP, ELEC, ACMV, CW, SP, LPG) | Commercial buildings need full MEP |
| RE (Residential) | ELEC, SP | Every house needs electrical + plumbing at minimum |
| IN (Infrastructure) | none | Roads/bridges have no building MEP |
Two-phase flow:
- Callout fires → inserts default discipline OrderLines for the category
- YAML handling → reads
remove_disciplines, deactivates (IsActive='N') those entries. The OrderLine row stays visible so the user can re-enable.
# RE house — default [ELEC, SP] already inserted by Callout
# No YAML key needed — user sees ELEC + SP and can add/remove in GUI
# RE house that doesn't want plumbing
remove_disciplines: [SP] # deactivates SP, keeps ELEC
# CO warehouse that doesn't need gas
remove_disciplines: [LPG] # deactivates LPG, keeps 5 others
# RE house that wants fire protection added beyond default
add_disciplines: [FP] # adds FP to the ELEC + SP default
This is the iDempiere Configure-to-Order pattern: category provides the template, user modifies by exception. Deactivation (not deletion) preserves the discipline row for audit trail and re-enablement.
BIM Designer GUI equivalent: user selects building category → discipline
OrderLines appear pre-populated. Toggle switch per discipline to
enable/disable. Same IsActive column that YAML's remove_disciplines
controls. Adding a discipline not in the default = addDiscipline() mutation
(Session A).
Code changes needed:
-
OrderLineProductCallout.onProductChanged()— add RE default set[ELEC, SP]. Currently RE returns 0 unless YAML whitelist present. New logic: if no YAML override, insert category default. IN stays at 0. -
New:
applyYamlOverrides()— after callout, readremove_disciplinesfrom ad_sysconfig, SETIsActive='N'on matching DISCIPLINE OrderLines. Readadd_disciplines, insert any not already present. -
Remove current
mep_disciplineswhitelist logic (lines 56-60, 87-91) — replaced by the two-phase default+override pattern.
SH: mep_disciplines: [ELEC, SP] → 2 DISCIPLINE OrderLines + ARC + STR = 4.
T3.5 Finding — MEP UOM Correction¶
Finding (S100 watchdog): M_Product.cost_uom in ERP.db is M3 (cubic meters)
for nearly all MEP products (pipe segments, fittings, ducts, terminals, valves).
This came from extraction — bounding box volume was computed for all elements.
ARC/STR products have correct UOM (walls=M2, beams=M, doors=EA). MEP products do not. Correct trade UOM by ifc_class:
| ifc_class | Current | Correct | Reason |
|---|---|---|---|
| IfcPipeSegment | M3 | M | Pipe bought by linear meter |
| IfcPipeFitting | M3 | EA | Fittings bought per piece |
| IfcDuctSegment | M3 | M | Duct bought by linear meter |
| IfcDuctFitting | M3 | EA | Fittings bought per piece |
| IfcFlowTerminal | M3 | EA | Sprinkler heads, taps = each |
| IfcFlowFitting | M3 | EA | Couplings, adapters = each |
| IfcFlowController | M3 | EA | Valves, dampers = each |
| IfcAirTerminal | M3 | EA | Diffusers, grilles = each |
| IfcLightFixture | M3 | EA | Light fixtures = each |
| IfcFireSuppressionTerminal | M3 | EA | Sprinkler heads = each |
| IfcValve | M3 | EA | Valves = each |
| IfcAlarm | M3 | EA | Alarms = each |
| IfcReinforcingBar | M3 | KG | Rebar bought by weight (PWD 203A, NRM2) |
| IfcCourse | M3 | M2 | Masonry = wall face area (PWD 203A §G) |
| IfcCovering | M3 | M2 | Cladding, insulation, ceiling tiles = area (PWD 203A §H) |
| IfcFurnishingElement | M3 | EA | Furniture bought per piece |
Impact: RouteStage persists qty from CrawlRouter. Pipe segment qty should be in meters (length from FollowOp), fitting qty should be 1 (each from BendOp/ BranchOp/ReduceOp). If cost_uom is M3, the 5D cost engine will multiply qty × unit_cost incorrectly (volume vs length vs count).
Fix: DV migration updating cost_uom by ifc_class for MEP products. iDempiere
convention: C_UOM_ID FK to C_UOM table. Current schema uses TEXT cost_uom
— acceptable for now, align to C_UOM_ID INTEGER FK in a future PK conformance
pass.
Migration SQL pattern (for coder to implement as DV029):
-- Linear segments: M3 → M (bought by linear meter)
UPDATE M_Product SET cost_uom = 'M'
WHERE ifc_class IN ('IfcPipeSegment', 'IfcDuctSegment', 'IfcFlowSegment')
AND cost_uom = 'M3';
-- Discrete fittings/terminals: M3 → EA (bought per piece)
UPDATE M_Product SET cost_uom = 'EA'
WHERE ifc_class IN ('IfcPipeFitting', 'IfcDuctFitting', 'IfcFlowTerminal',
'IfcFlowFitting', 'IfcFlowController', 'IfcAirTerminal',
'IfcLightFixture', 'IfcFireSuppressionTerminal', 'IfcValve', 'IfcAlarm')
AND cost_uom = 'M3';
-- Furnishings: M3 → EA (bought per piece)
UPDATE M_Product SET cost_uom = 'EA'
WHERE ifc_class IN ('IfcFurnishingElement', 'IfcFurniture')
AND cost_uom = 'M3';
-- Rebar: M3 → KG (bought by weight — PWD 203A, NRM2, AIQS)
UPDATE M_Product SET cost_uom = 'KG'
WHERE ifc_class = 'IfcReinforcingBar'
AND cost_uom = 'M3';
-- Masonry courses: M3 → M2 (wall face area — PWD 203A §G)
UPDATE M_Product SET cost_uom = 'M2'
WHERE ifc_class = 'IfcCourse'
AND cost_uom = 'M3';
-- Coverings: M3 → M2 (area coverage — PWD 203A §H)
UPDATE M_Product SET cost_uom = 'M2'
WHERE ifc_class = 'IfcCovering'
AND cost_uom = 'M3';
Scope: ERP.db M_Product table (~320 MEP + 346 rebar + 7 course + 35 covering rows). component_library.db M_Product must also be updated (same SQL). BOM.db copies are regenerated on next extraction — no manual fix needed. New UOM value: KG (not previously used). No schema change — cost_uom is TEXT.
Dependencies¶
T0.1 (service rooms) ──→ T0.3 (qty walk) ──→ T2.x (discipline routing)
T0.2 (callout) ──→ T3.1 (multi-disc TE)
T0.4 (FOLLOW) ──→ T1.x (movement verbs) ──→ T2.x
P94 (BomWriter) ──→ T1.x (new BOM lines need single write path)
T0.1 is the critical first task. If the category-match query doesn't return the right room coordinates, the entire parasitic model breaks. Prove it on SH first (small, fast, 7/7 GREEN).
6.12 Routing Architecture Assessment — Industry Position & Known Gaps¶
The CrawlRouter (§10.4.10) is a prescriptive recipe engine: each discipline builder encodes a standard installation pattern as a sequence of CrawlOps. This section assesses the approach against industry practice and documents known gaps for future work.
Industry Comparison¶
| Approach | Used by | How it works |
|---|---|---|
| Search-based (A*/Dijkstra on voxel grid) | Revit auto-route, GenMEP, academia | Voxelize building → pathfind → connect endpoints |
| Pathway/spine fill | EVOLVE MEP | User defines routing spine, tool fills segments along it |
| Port-to-port connector | Bentley OpenPlant | Shortest path between two component connection ports |
| Prescriptive recipe | This compiler (CrawlOp) | Discipline builder encodes installation pattern as ops |
| AI/generative | Auto BIM Route AI | ML generates candidate routes within constraints |
No other tool produces BOM-integrated, discipline-aware, deterministic routing. Search-based tools produce geometry first; BOM is an afterthought. Our CrawlOp model produces the BOM as primary output — segments and fittings are product references with diameter, length, and position. Geometry is derived from the BOM.
Strengths of Prescriptive Recipe¶
-
BOM is primary output. Every segment and fitting is an M_Product with qty, UOM, and cost. No post-processing step to extract material take-off.
-
Deterministic. Same building + same recipe = same output, always. Auditable: every segment traces to a CrawlOp in a spec-cited builder.
-
Discipline-aware. Each builder encodes domain knowledge — FP knows riser→header→branch (NFPA 13), SP knows gravity drainage (MS 1228 gradient 1:40), ACMV knows duct sizing (ASHRAE 62.1). A* does not know any of this.
-
O(n) not O(V log V). No voxel grid needed. Routing scales linearly with op count, not building volume.
-
IFC-compatible.
system_edges/system_nodesmap toIfcRelConnectsPorts+IfcFlowSegment/IfcFlowFitting. CrawlOp is the generation engine; IFC is the serialization target.
Known Gaps¶
Gap 1: Ceiling void routing — CLOSED (P118)¶
Problem: All 6 builders route at floor.zMm() (floor slab top). In
practice, pipes and ducts run in the ceiling void — underside of the slab
above, minus clearance.
Fix (P118, S100-p118): ceilingHeightMm(floorRef) added to
BuildingGeometry + SqlBuildingGeometry. All 6 RouteBuilders updated:
horizontal MEP runs at ceiling void Z (nextFloor.z - slabThickness - 50mm
clearance). P118b: SqlBuildingGeometry.floors() now uses absolute Z from
walked placements instead of BOM-relative c_orderline dz.
Gap 2: No obstacle avoidance during routing¶
Problem: CrawlOps execute sequentially with no spatial awareness of other routes or ARC elements. If FP riser and ACMV duct occupy the same shaft, the router does not detect or avoid the conflict.
Current mitigation: CheckClashVerb (BIM COBOL verb) runs in VerbStage
(Step 7) as a post-route R-tree overlap check with 50mm clearance
(BIMConstants.MEP_STRUCTURE_CLEARANCE). Clash is detected after the fact,
not prevented.
Industry practice: GenMEP and academia use obstacle-aware A* (voxels blocked by existing elements). This is the main advantage of search-based routing.
Future hybrid: Prescriptive recipes for the discipline skeleton (riser→header→branch pattern) + search-based pathfinding for last-mile segment paths within each branch. BlenderBIM Issue #6521 proposes a voxel-A* orthogonal pathfinder that could serve as the last-mile solver.
Gap 3: Verb bypass — CLOSED (P119)¶
Problem: RouteBuilders composed CrawlOps directly, bypassing VerbRegistry. Routing was not auditable as verb lines in the pipeline log.
Fix (P119, f9fc4bc9):
CrawlOp.toVerbLine() on all 5 ops. DisciplineRouteBuilder.plan() replaces
buildRoute() — builders return RoutePlan, default buildRoute() logs verb
lines at INFO then executes via CrawlRouter. All 6 builders refactored.
SH: ELEC 15 + SP 13 verb lines. DX: ELEC 25 + SP 23.
Gap 4: Missing real-world concerns¶
| Concern | Status | Path to fix |
|---|---|---|
| Hanger/support spacing | EXISTS (HangVerb, SMACNA 1200mm) |
Already a verb — wire into RouteBuilder output |
| Insulation | CLOSED (P121) | Insulation as BOM child per discipline: FP 25mm (fire-rated), ACMV 50mm (thermal), CW 25mm (condensation), SP/ELEC 0mm |
| Soffit clearance | MISSING | Derive from ceiling void height. Minimum 50mm below soffit per MS 1525 |
| Access/maintenance points | MISSING | Valves and cleanouts at branch points. SP needs cleanout access per MS 1228 §5 |
| LPG wall thickness | CLOSED (P121) | wallThickness(floorRef) added to BuildingGeometry. Queries c_orderline for LEAF WALL elements. Fallback: 200mm |
Gap 5: Standard citation depth — CLOSED (P120)¶
Problem: RouteBuilders cited standards but didn't trace to specific clauses.
Fix (P120, c78c743b):
standardRefs() on all 6 builders — NFPA 13, MS 1228, MS 1525, MS 830,
ASHRAE 62.1. Logged at INFO with parameter values for compliance audit.
Proof Consumption (P15/P16/P17)¶
The three BIMEyes proofs that consume routing edges are fully implemented:
| Proof | What it checks | Data consumed | Status |
|---|---|---|---|
| P15 PIPE_IN_HOST | Pipe bbox within host room bbox | PlacementData + RoomData | Gated by relational data |
| P16 WASTE_GRADIENT | SP pipes slope downward | CONNECTS_TO edges, fromZ >= toZ |
Gated by system_edges > 0 |
| P17 SYSTEM_CONNECTED | BFS — every terminal reaches a source | CONNECTS_TO adjacency graph | Gated by system_edges > 0 |
Gate at ProveStage (Step 11): hasRelationalData() OR isGenerative() OR hasSystemEdges().
Currently blocked for most buildings because ad_element_dependency (the
CONNECTS_TO edge source for P16/P17) is populated only for legacy buildings.
The RouteStage edges land in system_edges/system_nodes — wiring these
into P16/P17 is the next integration step.
Room-Aware Branching (Level 2)¶
Gaps 1–5 fix the routing skeleton — correct Z, auditable verbs, traceable standards, missing products. This section addresses the next layer: making branches spatially intelligent within rooms.
Current behaviour¶
When a route branches to a room, every builder does the same thing:
RoomDimensions roomDims = geo.roomDimensions(room.ref());
double roomRun = roomDims != null ? roomDims.longestAxis() : 3000;
branchOps.add(new FollowOp(roomRun, STOCK_LENGTH_MM));
The branch enters the room and follows the longest axis for its full length. It does not know where it enters, where fixtures are, or how the room is shaped. Every room gets the same treatment regardless of discipline.
What each discipline actually needs¶
| Discipline | In-room behaviour | Standard | What's missing |
|---|---|---|---|
| FP | Grid of sprinkler heads at ceiling, max spacing per hazard class | NFPA 13 §8.6.2 (LH 4.6m), §8.6.3 (OH 4.0m) | Grid layout from room AABB, head count = ceil(width/spacing) × ceil(depth/spacing) |
| ELEC | Perimeter run at dado height (sockets), ceiling run (lights) | MS IEC 60364, IES lux tables | Two sub-routes per room: ceiling grid + wall perimeter |
| CW | Drop from ceiling to fixture positions (basin, sink, WC) | MS 1228 fixture unit table | Fixture type → position offset from room origin. ad_fixture_type table |
| SP | Drop from fixture to floor waste, connect to horizontal waste | MS 1228 §5 gradient tables | Gravity: fixture height → floor level. Gradient maintained on horizontal |
| ACMV | Ceiling diffuser grid, spacing per air changes/hour | ASHRAE 62.1, MS 1525 §6 | Diffuser count = room_volume × ACH / diffuser_capacity. Room height matters |
| LPG | Single drop from ceiling to gas point (kitchen range, heater) | MS 830 §4 | One connection point per room. Gas cock fitting at drop |
Data already available¶
RoomTarget has ref, position, discipline. RoomDimensions has
widthMm, depthMm, heightMm. This is enough for grid layouts
(FP sprinklers, ACMV diffusers) and perimeter runs (ELEC sockets).
What's not available: fixture positions within a room. CW needs to know
where the basin is. SP needs to know where the WC is. These come from the
BOM — m_bom_line LEAF elements with product category matching
IfcSanitaryTerminal, IfcFlowTerminal, etc. The BOM walker (CompileStage,
Step 3) already placed these elements with world coordinates.
Proposed: BuildingGeometry.fixturesInRoom(roomRef, discipline)¶
New query method on the interface:
record FixtureTarget(String ref, Point3D position, String ifcClass, String product)
List<FixtureTarget> fixturesInRoom(String roomRef, String discipline)
Implementation in SqlBuildingGeometry: query elements_meta or
c_orderline LEAF rows whose parent chain includes the room and whose
discipline matches. These are the ARC-placed elements from CompileStage —
real positions, not invented.
Branch sub-route composition¶
With fixture positions available, each discipline's branch becomes a mini-route within the room:
FP (sprinkler grid):
enter room at ceiling Z → compute grid from AABB + NFPA spacing
→ FOLLOW to first row → BRANCH to each head position
→ FOLLOW to next row → BRANCH to each head position
CW (fixture drops):
enter room at ceiling Z → for each fixture:
→ FOLLOW along ceiling to fixture X,Y
→ BEND 90° down → FOLLOW to fixture Z (basin height ~850mm)
→ fitting: stop valve + connector
SP (waste collection):
for each fixture → DROP to floor (fixture Z → floor Z)
→ FOLLOW along floor to waste pipe (gradient 1:40)
→ connect to floor waste header
Each sub-route uses the same CrawlOps (FollowOp, BendOp, BranchOp) but now with real target positions instead of "follow longest axis."
Phasing¶
Level 2 builds on Gaps 1–5:
| Phase | Prereq | Scope |
|---|---|---|
| L2.1 | P118 (ceiling Z) | fixturesInRoom() query on BuildingGeometry |
| L2.2 | L2.1 | FP grid layout — sprinkler head spacing from NFPA 13 per room AABB |
| L2.3 | L2.2 | CW/SP fixture drops — real fixture positions from BOM |
| L2.4 | L2.3 | ELEC dual-route — ceiling lights + perimeter sockets |
| L2.5 | L2.4 | ACMV diffuser grid — air changes per room volume |
Each phase is one bounded task for a coder. FP grid (L2.2) is the natural first because sprinkler spacing is the most formula-driven (NFPA 13 table lookup from hazard class + room AABB → grid dimensions → head count).
6.12.1 Compilation Isolation Invariant (S103)¶
Rule: The DAGCompiler SHALL NOT open any extraction DB, IFC file, or input/
source during compilation. The only permitted connections are:
| Connection | Purpose | Direction |
|---|---|---|
bom.db (System.getProperty) |
BOM recipes, C_OrderLine, ad_sysconfig | Read + Write |
library/ERP.db |
Shared discipline recipes, products, validation rules | Read-only |
output.db |
Compilation output (elements_rtree, c_orderline, system_edges) | Write |
Verification: GEO (PlacementCollectorVisitor.emitGeoSummary) opens the extraction DB in a separate proof stage (read-only comparison). It cannot feed back into the walk. GEO is an auditor, not a participant.
Why this matters for LMP: The tack chain in m_bom_line.dx/dy/dz was computed
at extraction time from IFC positions. At compilation time, the BOM walker reads
only m_bom_line — it never sees the IFC source. The BUILDING origin is the single
anchor point; everything below cascades parent-relative. No absolute borrowing.
Enforcement: Code review. No *_extracted.db or *_input* import exists in
DAGCompiler/src/main/java/. Any future addition must pass this gate.
6.12.2 MEP Placement — Shim + Joint Piece Architecture (S103/S104)¶
There is no difference between ARC placement and MEP placement. The walker reads M_BOM → M_BOM_Line → dx/dy/dz and accumulates. Same code path for placing a wall, a desk, a pipe tee, or a sprinkler head. Same PlacementCollectorVisitor. Same maths. Same GEO proof. No routing engine, no canvas, no pathfinding, no runtime computation.
1. Tack Point — Same as ARC¶
A tack point is just a point. Parent tack + dx/dy/dz = child tack. The walker accumulates. That's the whole algorithm — same for ARC, same for MEP.
The tack point is NOT tied to AABB or LBD convention. What the point physically represents depends on the product — LBD corner of a wall, centre of a pipe end, centre of a screw hole. The BOM doesn't care. It stores a number. The walker adds numbers. The product's geometry knows where to render relative to its tack point.
Tack stability invariant: PlacementCollectorVisitor computes each
element's world absolute tack as:
child_abs = parent_abs + rotated(dx, dy, dz) + bomOrigin
where parent_abs is the immediately preceding ancestor's absolute tack,
read from the anchor stack (anchorStack.peek()). Computation is O(1) per
node — no recomputation from root, no mutable running counter. The stack is
the cache of all ancestor absolute tacks; each node reads only its direct
parent. Accumulated FP error across a chain is bounded by the GEO proof
(evidence: ≤ 0.002mm over 1,653 element pairs, SH/TE/RM fleet). The
tolerance for GEO gate passage is ≤ 0.005mm per pair (LMP threshold).
2. The Shim — Zero Offset Host Alignment¶
The shim is a phantom product — no geometry, no mass, not rendered. It represents the host surface where MEP attaches. The shim melts into the host: its position IS the host surface position. Zero offset.
A shim placed against a wall has the wall's XYZ. A shim placed against a ceiling has the ceiling's XYZ. No gap, no clearance, no 5mm standoff. The shim IS the surface in coordinate terms.
The device that attaches to the host (pipe bracket, sprinkler mount) carries its own standoff intrinsically. A bracket that sits 5mm proud of the wall? That 5mm is in the device's M_BOM_Line dx/dy/dz within the recipe — the product knows its own offset. The shim contributes nothing except the coordinate frame origin at the host surface.
ERP.db M_Product — Shims (phantom host surface anchors, ~10-15 types)
FP_CEILING_SHIM host_ifc_class=IfcCovering mount=BOTTOM
ELEC_CEILING_SHIM host_ifc_class=IfcCovering mount=BOTTOM
ELEC_WALL_SHIM host_ifc_class=IfcWall mount=SIDE
CW_CEILING_SHIM host_ifc_class=IfcCovering mount=BOTTOM
SP_FLOOR_SHIM host_ifc_class=IfcSlab mount=TOP
ACMV_CEILING_SHIM host_ifc_class=IfcCovering mount=BOTTOM
LPG_WALL_SHIM host_ifc_class=IfcWall mount=SIDE
The shim's position comes from the MAKE line dx/dy/dz in the BOM tree — same as any other BOM line. No runtime host matching. No query. No ShimMatcher computation. The position was extracted from the IFC once and stored as a number on the M_BOM_Line.
3. Entry Point — OrderLine → MEP_System BOM (abstract, VR-resolved)¶
The MEP walk enters through an OrderLine whose Product has AD_Org = discipline (FP, ELEC, ACMV, CW, SP, LPG). That product IS the root MEP_System BOM in ERP.db. BomDropper explodes it — finds the shim (phantom parent) — recurses into children. Same explosion as ARC OrderLine → BUILDING BOM → FLOOR → ROOM.
OrderLine (AD_Org=FP, Product=FP_CEILING_SHIM)
→ BomDropper explodes FP_CEILING_SHIM M_BOM
→ children: PIPE_STRAIGHT_50MM, PIPE_TEE_50_25MM, SPRINKLER_HEAD_K56...
→ walker accumulates dx/dy/dz for each child
Each child is identified by exact product ID — no fuzzy matching, no
proximity search. The recipe says PIPE_STRAIGHT_50MM_POLY_STEEL, that's
what gets placed.
4. Joint Pieces — The Toolbox¶
Joint pieces are M_Products in ERP.db. Extracted from RosettaStone IFCs
by ifc_class and PredefinedType — a pipe segment IS a straight piece,
an IfcPipeFitting IS a tee or elbow. No IfcRelConnectsPorts needed
(our IFCs don't carry port data). The fleet teaches vocabulary by example.
Each piece in component_library.db carries LOD metadata for orientation:
- attachment_face — CENTER / TOP / BOTTOM / SIDE
- up_axis — which local axis points up (Z)
- forward_axis — which local axis is the run direction (Y or X)
- orientation — PENDANT / UPRIGHT / HORIZONTAL / VERTICAL / MIXED
This is facing data only — no tack-in/tack-out ports. Rotation from tack
point is on M_BOM_Line.rotation_rule. The walker reads it, applies it.
The piece itself is the simplest BOM — one product with orientation metadata.
5. The MEP BOM — Shim Root, Joint Piece Children¶
FLOOR (in *_BOM.db)
└── FP_CEILING_SHIM (KITCHEN) ← phantom, melts into ceiling XYZ
├── PIPE_STRAIGHT_50MM dx=0 dz=-5 ← 5mm below ceiling (device offset)
├── PIPE_TEE_50_25MM dx=L dz=-5 ← tee at branch point
├── PIPE_STRAIGHT_25MM dy=S dz=-5 ← branch from tee
└── SPRINKLER_HEAD_K56 dy=0 dz=-305 ← terminal drop (300mm pendant)
FLOOR
└── FP_CEILING_SHIM (KITCHEN) ← one shim per room per discipline
└── FP_CEILING_SHIM (CORRIDOR)
└── ELEC_WALL_SHIM (KITCHEN)
└── SP_FLOOR_SHIM (BATHROOM)
The walker recurses: FLOOR → shim → pieces. Same as FLOOR → ROOM → FURNITURE. All dx/dy/dz are small, local, verifiable — the shim absorbed the building-scale positioning. Device standoffs are intrinsic to each child's BOM line offset — the shim contributes zero.
6. InterimWorkshop — Variable-Length Pieces¶
A pipe run between two fittings may need a non-standard length. Like a real contractor who cuts pipe from stock to fit the gap.
BOM Line Model Extension (from Compiere/e-Evolution Manufacturing)¶
The M_BOM_Line gains c_uom_id — the Unit of Measure from iDempiere.
qty becomes REAL (was INTEGER). The UOM tells the walker how to
interpret qty:
| c_uom_id | qty meaning | Walker action |
|---|---|---|
| EA | Instance count (default) | expandVerb() — TILE/ROUTE/CLUSTER/etc |
| MM | Length in millimetres | InterimWorkshop — recompute primitive |
| M | Length in metres | InterimWorkshop — recompute primitive |
When c_uom_id is a length unit (MM/M), the walker calls
InterimWorkshop instead of expandVerb(). No CUT verb needed — the
UOM is the signal. This follows Compiere convention where BOMQty +
C_UOM_ID drive the BOM Drop interpretation.
qty_type (VARIABLE/FIXED) guards the line: VARIABLE allows runtime
adjustment, FIXED rejects it. A tee fitting is FIXED (tack i/o
predetermined). A straight pipe is VARIABLE (outlet shifts with length).
Mathematical Primitives, Not Parametric¶
MEP pieces are mathematical primitives — cylinder, box, torus arc. The workshop does not stretch or deform an existing mesh. It recomputes the primitive from first principles:
Cylinder(diameter=50mm, length=2345mm, LOD=16 faces)
→ vertices: r*cos(θ), r*sin(θ), z ∈ [0, 2345mm]
→ 16 side faces + 2 end caps
This is NOT parametric mesh generation (which is forbidden). Parametric means exploring design space with arbitrary parameters. This is geometry from data — same principle as the entire compiler. The LOD (face count, tessellation) comes from the library template. The dimensions come from the BOM line. The maths produces the mesh.
Materials are flat RGBA per face (no UV mapping, no textures). A shorter cylinder has shorter rectangular side faces with the same uniform colour. No stretching, no shrinking artifact.
InterimWorkshop Interface¶
/**
* Runtime mesh computation for variable-length pieces.
* Like a contractor's workshop: recompute the primitive at the required
* length. LOD from library template. Dimensions from BOM line.
* Result is ephemeral — not stored back.
*/
public class InterimWorkshop {
/** Recompute primitive mesh at target length along forward axis. */
static MeshResult recompute(MProduct product, String forwardAxis,
double targetLengthMm, int lodFaceCount);
}
Workshop Extension — Envelope Trim (BBC §6.1)¶
InterimWorkshop §6 above handles 1D length adjustment (pipes). The same pattern extends to 2D/3D fabrication — trimming products against the building envelope (roof, ground, perimeter walls).
Evidence (S143): SH Sample House has 4 elements overshooting the curved roof (Z=3.0m base): WALL_EXT_NS at 3.828m (+828mm), WALL_EXT_EW at 3.291m (+291mm), curtain wall at 3.221m (+221mm). These are correct rectangular LODs from IFC extraction — the trim is a workshop operation.
Workshop sub-verb model:
| Sub-verb | c_uom_id | Workshop action |
|---|---|---|
| (none) | EA | No workshop — place as-is |
| (none) | MM/M | Length recompute (current InterimWorkshop) |
CUT_TOP |
EA | Trim upper extent to constraining surface |
CUT_BOTTOM |
EA | Trim lower extent to ground/slab |
NOTCH |
EA | Rectangular cutout for penetration |
Sub-verb is stored on M_AttributeSetInstance (per-instance, not per-product).
The catalog product stays rectangular. Each placed instance gets an ASI with:
cut_face, cut_ref_bom_id (constraining element), cut_profile (FLAT/CURVED/PITCHED),
cut_offset_mm (overshoot depth).
Envelope pass: After placement, a single pass identifies all elements whose AABB overshoots the building envelope (roof, ground). For each overshoot, it generates an ASI with the cut instruction. The constraining element (roof BOM) defines the cut profile. This is the same pattern as Compiere's BOM Drop + ASI resolution — placement first, then per-instance attributes.
Flat Sibling Pattern — Small BOMs¶
MEP pieces under the shim are flat siblings, not nested chains. Each is a 1-2 piece mini-BOM — like a contractor's work packet:
FP_CEILING_SHIM (KITCHEN)
├── PIPE_STRAIGHT_50MM dx=0 qty=2345 c_uom_id=MM qty_type=VARIABLE
├── PIPE_TEE_50_25MM dx=2.345 qty=1 c_uom_id=EA qty_type=FIXED
├── PIPE_STRAIGHT_50MM dx=2.345 qty=1800 c_uom_id=MM qty_type=VARIABLE
├── PIPE_TEE_50_25MM dx=4.145 qty=1 c_uom_id=EA qty_type=FIXED
└── SPRINKLER_HEAD_K56 dx=2.345 dy=0.5 dz=-0.3 qty=1 c_uom_id=EA
Fittings are at fixed positions (from spacing rules / extraction). Straight pipes fill gaps between fittings — their length (qty in MM) is exactly the gap. Each sibling is independently tacked from the shim. Shortening one pipe does NOT cascade to neighbours — the next fitting's dx is independently defined.
The tack i/o for a VARIABLE piece: inlet = dx (BOM line offset from parent). Outlet = dx + qty(mm) along forward_axis. The next sibling's dx equals that outlet point. Deterministic, no drift.
7. IFCtoERP — Tack Extraction¶
Tack offsets between joined MEP pieces are extracted by IFCtoERP while reading RosettaStone IFC geometry. Only at extraction time are both pieces' positions available. The maths: child_pos - parent_pos = dx/dy/dz. Same subtraction as ARC walls and slabs.
IFCtoERP writes directly to ERP.db (no intermediate database): - Joint piece M_Products (piece type + diameter from AABB) - Shim M_Products (phantom, per discipline x host surface type) - M_BOM recipes (shim-rooted, children with dx/dy/dz tack offsets)
The dx/dy/dz on each M_BOM_Line IS the vector from parent tack to child tack. Extracted once. Stored as data. Walked as numbers.
8. Slope, Branching, and Risers — Three Triaged Edge Cases¶
These three patterns are not new walker code. They are data on the BOM line — the walker already handles them through existing mechanisms (dz accumulation, sub-assemblies, rotation). The triage closes each gap by defining how the metadata tables express the pattern.
8a. Slope / Gradient (Sanitary Drainage)¶
MS 1228 §5 requires waste pipes at minimum 1:40 gradient (25mm drop per metre). This is NOT a runtime computation — the gradient is baked into each successive pipe segment's dz on the M_BOM_Line.
SP_FLOOR_SHIM (BATHROOM)
├── PIPE_STRAIGHT_50MM dx=0 dz=0 qty=1000 c_uom_id=MM
├── PIPE_STRAIGHT_50MM dx=1.0 dz=-0.025 qty=1000 c_uom_id=MM ← 25mm drop
├── PIPE_STRAIGHT_50MM dx=2.0 dz=-0.050 qty=1000 c_uom_id=MM ← 50mm drop
└── FLOOR_TRAP dx=0.5 dz=-0.010 qty=1 c_uom_id=EA
The gradient is expressed as metadata in ad_mep_laying_rule:
| rule_id | discipline | rule_type | parameter | value | standard | section |
|---|---|---|---|---|---|---|
| LAY-SP-001 | SP | GRADIENT | min_slope | 0.025 | MS 1228 | §5.3 |
| LAY-SP-002 | SP | GRADIENT | max_slope | 0.040 | MS 1228 | §5.3 |
| LAY-LPG-001 | LPG | GRADIENT | min_slope | 0.010 | MS 830 | §4.2 |
The IFCtoERP extraction computes per-segment dz from IFC positions.
The validation rule (AD_Val_Rule) checks placed pipe Z-deltas against
the table. The code is abstract — it reads min_slope from the table,
compares against placed dz/dx ratio. No hardcoded gradients.
8b. Branching at Tees¶
A tee fitting has 3 connections: main-in, main-out, branch-out. The main run continues as flat siblings. The branch is a nested sub-BOM under the tee — the tee IS a sub-assembly with its own children.
FP_CEILING_SHIM (KITCHEN)
├── PIPE_STRAIGHT_50MM dx=0 qty=2345 c_uom_id=MM ← main run
├── PIPE_TEE_50_25MM dx=2.345 qty=1 c_uom_id=EA ← tee (sub-assembly)
│ └── PIPE_STRAIGHT_25MM dy=0 qty=1500 c_uom_id=MM ← branch child
│ └── SPRINKLER_HEAD_K56 dy=1.5 dz=-0.3 qty=1 c_uom_id=EA
├── PIPE_STRAIGHT_50MM dx=2.345 qty=1800 c_uom_id=MM ← main run continues
└── PIPE_TEE_50_25MM dx=4.145 qty=1 c_uom_id=EA ← next tee
The tee's branch children have dx/dy/dz relative to the tee's tack point (centre of the fitting). The walker recurses into the tee sub-BOM (onSubAssembly), walks its children, exits (onSubAssemblyComplete), then continues with the next main-run sibling. Standard BOM recursion — no branching-specific code.
Branch metadata in ad_mep_fitting_rule:
| rule_id | piece_type | connection | axis | offset_rule | standard |
|---|---|---|---|---|---|
| FIT-TEE-001 | PIPE_TEE | MAIN_OUT | X | diameter/2 from centre | — |
| FIT-TEE-002 | PIPE_TEE | BRANCH_OUT | Y | diameter/2 from centre | — |
| FIT-ELB-001 | PIPE_ELBOW | OUT | varies | angle × bend_radius | — |
| FIT-RED-001 | PIPE_REDUCER | OUT | X | length along axis | — |
The table defines each fitting's connection offsets. IFCtoERP reads these when building M_BOM recipes. The walker doesn't know about fittings — it just sees sub-assemblies with dx/dy/dz.
8c. Vertical Risers¶
A riser is a pipe running vertically through floors. The BOM line
carries rotation_rule = 90° around the horizontal axis, rotating the
pipe's forward_axis from Y (horizontal default) to Z (vertical).
BUILDING
└── FP_RISER_ASSEMBLY (bom_type=MEP)
├── PIPE_STRAIGHT_50MM dz=0 rotation_rule=PI/2 qty=3000 c_uom_id=MM
├── PIPE_STRAIGHT_50MM dz=3.0 rotation_rule=PI/2 qty=3000 c_uom_id=MM
└── PIPE_STRAIGHT_50MM dz=6.0 rotation_rule=PI/2 qty=3000 c_uom_id=MM
The walker's existing rotation stack handles this — cumulative rotation is pushed on onSubAssembly, applied to dx/dy/dz in onLeaf, popped on exit. InterimWorkshop recomputes along the library's forward_axis (Y); the walker's rotation transforms the half-extents to world frame (Z).
Riser metadata in ad_mep_riser_rule:
| rule_id | discipline | parameter | value | standard | section |
|---|---|---|---|---|---|
| RSR-FP-001 | FP | max_floor_height_mm | 4000 | NFPA 13 | §8.15 |
| RSR-FP-002 | FP | riser_diameter_mm | 65 | NFPA 13 | §8.15.1 |
| RSR-CW-001 | CW | min_pressure_kpa | 150 | MS 1228 | §3.4 |
| RSR-SP-001 | SP | stack_min_diameter_mm | 100 | MS 1228 | §5.7 |
8d. Route Direction → Piece Orientation (Triage)¶
Context: In _BOM.db (ARC/STR), orientation is inherited from the parent BOM
hierarchy — BUILDING → FLOOR → ROOM → LEAF. Each level carries dx/dy/dz forming
a spatial chain. MEP mini BOMs in ERP.db have no such parent hierarchy. The
mini BOM is standalone; bom-to-bom connection is via anchors, not tree nesting.
Extracted path (§6.12.2): No gap. buildMepBomRecipes() extracts rotation_rule
from IFC direction changes (atan2 of perpendicular vs along-axis). Each piece's
dx/dy/dz encodes the run direction implicitly. The Walker reads rotation_rule
from M_BOM_Line and applies it via the rotation stack. Complete.
Generated path (§6.12.3): Gap. RouteWalker generates CW/SP c_orderline rows
from pattern steps. Each step has direction_axis (X, Y, Z, GRADIENT). But
RouteWalker does NOT set rotation_rule on generated lines. For horizontal
runs (default), this works (rotation_rule defaults to 0, forward_axis aligned
with run). For vertical drops or direction changes, rotation_rule is missing.
Walk direction per discipline (from BBC.md §3.6):
| Discipline | Walk direction | Implication |
|---|---|---|
| CW | Ground up ↑ then horizontal → | Riser needs rotation_rule=PI/2 |
| SP | Top down ↓ (gravity) | Gradient pieces: rotation_rule from dz/dx |
| FP | Vertical ↑ then horizontal → | Same as CW |
| ELEC | Vertical ↑ then radial → | Radial = per-room walk direction varies |
| ACMV | Horizontal from AHU → | Usually single-axis per floor |
| LPG | Horizontal from meter → | Single-axis, no vertical |
This table is spec text only — not stored in any runtime-accessible table.
Three gaps to close:
-
RouteWalker must set rotation_rule on generated lines when direction_axis changes between consecutive steps (horizontal → vertical, or axis change). Computable: if step N is X-axis and step N+1 is Z-axis, the transition piece gets rotation_rule = PI/2.
-
Walk direction as metadata: The per-discipline walk direction (BBC.md §3.6 table) should be stored on the system BOM or on ad_mep_laying_rule so the Walker can read it at runtime. Candidate column:
walk_directionon M_BOM WHERE AD_Org_ID > 0. -
Piece orientation alignment: At runtime, the Walker must align each piece's
forward_axis(from component_library.db) with the route direction. This is a rotation:rotation = angle_between(forward_axis, route_direction). The existing rotation stack handles it — but the route_direction must be available as data, not inferred.
Resolution sequence: Gap 2 first (store walk direction as data), then Gap 1 (RouteWalker sets rotation_rule), then Gap 3 (Walker aligns forward_axis). All three are data additions — no new walker logic beyond reading existing columns.
9. Metadata Tables — Standards as Data, Code as Abstract Walker¶
All laying rules, fitting rules, and riser rules live in ERP.db as metadata tables. The code reads them. The code never embeds standard numbers, gradient values, spacing limits, or diameter rules.
ERP.db metadata tables (read by walker + validation, never by user):
ad_mep_laying_rule — slope gradients, clearances, material rules
ad_mep_fitting_rule — connection offsets per fitting type
ad_mep_riser_rule — vertical run constraints per discipline
ad_val_rule — post-walk validation (existing, P15/P16/P17)
ad_space_type_mep_bom — room type → discipline mapping (existing, 186 rows)
Why this matters for usability: A user (engineer, project manager) can open ERP.db, read the laying rule table, see "MS 1228 §5.3: min slope = 0.025", and understand exactly what the system enforces. No source code reading required. Changing a regulation = updating a row in the table, not editing Java. The compiler is truly user-friendly when the rules are visible metadata and the code is an abstract walker.
Validation flow:
1. Walker places pieces (accumulates dx/dy/dz, calls InterimWorkshop)
2. AD_Val_Rule reads placed positions from output.db
3. Compares against ad_mep_laying_rule / ad_mep_riser_rule thresholds
4. Emits PASS/FAIL per rule per element — no hardcoded thresholds in code
10. Validation — Standards Confirm the Walk¶
Standards validate placement AFTER the walk. They do NOT drive it.
Validation layering (three tiers):
| Tier | Mechanism | When | Scope |
|---|---|---|---|
| 1 — GEO proof | G3-DIGEST gate (W-TACK-STABLE) | Per RosettaStone run | Tack FP stability ≤ 0.005mm across all fleet elements |
| 2 — CheckClashVerb (primary) | AD_Val_Rule clash rules | Post-walk audit pass | Cross-discipline penetration; host-surface clearance |
| 3 — Per-element soft check (supplementary) | PlacementValidator.isValidPlacement() | During BOM walk, per leaf | Room-bounds overreach; early-warning log only, no throw |
Tier 3 is supplementary — it logs early warnings but does not replace Tier 2. CheckClashVerb remains the primary clearance enforcement mechanism. A Tier 3 violation is a warning; a Tier 2 violation is a FAIL. Tier 3 MUST NOT be promoted to a hard fail without first confirming CheckClashVerb cannot detect the same violation.
| Standard | AD_Val_Rule check | Source table | Input |
|---|---|---|---|
| NFPA 13 §8.6 | Sprinkler spacing ≤ 4600mm (LH) | ad_mep_laying_rule | Placed sprinkler positions |
| NFPA 13 §8.15 | Riser diameter ≥ 65mm | ad_mep_riser_rule | Riser product dimensions |
| MS 1228 §5.3 | Waste gradient ≥ 1:40 | ad_mep_laying_rule | SP pipe dz/dx ratio |
| MS 1228 §3.4 | Min pressure at highest fixture | ad_mep_riser_rule | Riser height vs pressure |
| ASHRAE 62.1 | Air changes per room | ad_mep_laying_rule | Diffuser count vs room volume |
| MS 830 §4.2 | Gas clearance ≥ 150mm | ad_mep_laying_rule | LPG pipe vs ignition sources |
| MS 830 §4.2 | Gas pipe gradient ≥ 1:100 | ad_mep_laying_rule | LPG pipe dz/dx ratio |
11. Phasing¶
| Phase | Scope | Prereq |
|---|---|---|
| J1 | IFCtoERP: extract joint piece M_Products + tack offsets from RosettaStone IFCs → ERP.db | S103 |
| J2 | Shim products: phantom host surface anchors in ERP.db (zero offset) | J1 |
| J3 | MEP recipes: shim-rooted M_BOM assemblies with dx/dy/dz in ERP.db | J2 |
| J4 | M_BOM_Line model: c_uom_id (EA/MM/M), qty→REAL, qty_type guard (FIXED/VARIABLE) | J3 |
| J5 | InterimWorkshop: mathematical primitive recomputation for length-unit lines | J4 |
| J6 | Metadata tables: ad_mep_laying_rule, ad_mep_fitting_rule, ad_mep_riser_rule | J5 |
| J7 | Validation: AD_Val_Rule reads metadata tables, post-walk PASS/FAIL per rule | J6 |
| J8 | Fleet: TE + RM, measure coverage per discipline per room | J7 |
J1-J3 are data preparation (ERP.db). J4 is the BOM line model extension (iDempiere convention: BOMQty + C_UOM_ID). J5 is the only new walker code (mathematical primitive recomputation — not parametric). J6 defines the metadata tables that make standards visible to users without reading code. J7-J8 are verification. The walker itself (PlacementCollectorVisitor) gains a UOM check in onLeaf() but no MEP-specific logic — UOM drives the dispatch. The code stays abstract; the rules live in tables.
Existing Infrastructure¶
| Component | Status | Role |
|---|---|---|
| PlacementCollectorVisitor | DONE | Walks M_BOM, accumulates dx/dy/dz — identical for ARC and MEP |
| BomDropper | DONE | Explodes recipes recursively — identical for ARC and MEP |
| IFCtoERP | DONE (J1+J2) | 785 joint piece + 11 shim M_Products in ERP.db |
| DisciplineBomBuilder | DONE (J3) | MEP elements in BOM with shim as parent |
| X_M_BOMLine | EXTEND (J4) | Add c_uom_id, qty→REAL, qty_type FIXED/VARIABLE guard |
| InterimWorkshop | PROMPT (00f) | Primitive recomputation: cylinder/box/torus from LOD + dimensions |
| ad_mep_laying_rule | NEW (J6) | Slope gradients, clearances, spacing — standards as visible data |
| ad_mep_fitting_rule | NEW (J6) | Connection offsets per fitting type (tee, elbow, reducer) |
| ad_mep_riser_rule | NEW (J6) | Vertical run constraints per discipline |
| CrawlRouter + RouteBuilders | DONE (S100) | Generative fallback (buildings without IFC MEP data) |
| system_edges | DONE (S100) | P15/P16/P17 proof input |
| ad_space_type_mep_bom | EXISTS (ERP.db, 186 rows) | Room type → discipline mapping |
6.12.3 Hybrid Pattern Architecture — RouteWalker for Unclassified Buildings (S104)¶
Problem statement: Some IFC models (RM and equivalent IFC2x3 buildings) carry MEP geometry
as generic IfcFlowSegment/IfcFlowFitting with no sub-discipline attribute. Per §11.1, CW and
SP cannot be disambiguated at the element level for these buildings (G2). The §6.12.2 shim+recipe
architecture requires element-level discipline assignment, which G2 blocks.
Solution: Pattern-over-anchors. The RouteWalker mines topology patterns from discipline-complete
buildings (TE: CW 619 segments, SP 455 segments, discipline confirmed via elements_meta.discipline)
and applies them to anchor points extracted from the unclassified building (RM). Individual pipe
classification is not required — the pattern defines the system.
This does not violate §6.12.1. IFCtoERP extracts anchor coordinates from the RM extraction DB at extraction time and writes them to ERP.db. DAGCompiler reads ERP.db only. No extraction DB access at compile time.
1. Anchors — Source and Terminal Points¶
An anchor is an extracted coordinate pair (source_xyz, terminal_xyz) representing a connection between MEP endpoints. The 491 RM generic pipe elements yield anchor pairs — pipe start and end points — without discipline assignment.
Anchor types: - METER — connection from water source/riser to fixture (identified by flanking fixture presence) - FIXTURE — terminal endpoint: sink, basin, toilet, WC, floor trap (from element_type keywords) - VALVE — flow controller (from IfcFlowController) - GENERIC — pipe endpoint with no identifiable context
IFCtoERP writes anchors to ad_mep_anchor:
CREATE TABLE ad_mep_anchor (
anchor_id TEXT PRIMARY KEY,
source_building TEXT NOT NULL,
anchor_type TEXT NOT NULL CHECK(anchor_type IN ('METER','FIXTURE','VALVE','GENERIC')),
x_m REAL NOT NULL,
y_m REAL NOT NULL,
z_m REAL NOT NULL,
storey TEXT,
ifc_guid TEXT
);
Anchor extraction rule: for each MEP element AABB in the extraction DB, compute the geometric centre (cx, cy, cz). If the element has two spatially distinct ends (length > 3× min AABB dim), emit two anchors (start and end). Otherwise emit one anchor at centre. Fixture-type elements (toilet/sink/drain keywords) are typed as FIXTURE.
1b. Port-Graph Enrichment — Authored Connectivity (Future)¶
Principle: Prefer authored IFC connectivity over spatial inference when available.
IFC models with proper MEP modeling carry IfcDistributionPort entities on each
fitting/segment, connected via IfcRelConnectsPorts. This is a directed graph
(SOURCE→SINK) with system membership (IfcSystem). When present, this is more
stable than inferring connectivity from spatial proximity.
Reality check — not all IFC carries ports:
| LOD / Source | Ports present? | Anchor strategy |
|---|---|---|
| LOD400+ (Revit MEP, OpenPlant) | Yes — full port graph | Use authored connectivity |
| LOD300 (generic modelers) | Partial — some fittings have ports | Authored where available, infer remainder |
| LOD200 / IFC2x3 legacy | No — bare geometry only | Spatial inference only (current path) |
| Generative (RouteWalker output) | N/A — no IFC source | Pattern-based (§6.12.3 §3) |
Extraction enhancement in IFCtoERP (at extraction time only):
from ifcopenshell.util.system import (
get_ports, get_connected_to, get_connected_from,
get_element_systems
)
for element in mep_elements:
ports = get_ports(element)
if ports: # authored connectivity exists
for port in ports:
downstream = get_connected_to(element)
upstream = get_connected_from(element)
systems = get_element_systems(element)
# write to mep_connectivity with source='authored'
else:
# fall back to spatial proximity anchors with source='inferred'
New table — mep_connectivity in extracted DB (not ERP.db — raw extraction data):
CREATE TABLE mep_connectivity (
from_guid TEXT NOT NULL,
to_guid TEXT NOT NULL,
port_type TEXT, -- SINK, SOURCE, SOURCEANDSINK
flow_direction TEXT, -- same as port_type, explicit for clarity
system_name TEXT, -- IfcSystem.Name (e.g. 'Cold Water', 'Sprinkler')
system_type TEXT, -- IfcSystem class (IfcDistributionSystem etc.)
source TEXT NOT NULL DEFAULT 'inferred'
CHECK(source IN ('authored','inferred')),
PRIMARY KEY (from_guid, to_guid)
);
How RouteWalker benefits:
- For buildings with authored ports: mep_connectivity WHERE source='authored'
gives the directed graph directly. Pattern matching validates against it
rather than replacing it. Anchors are still extracted but connectivity is
pre-resolved — no proximity guessing.
- For buildings without ports: unchanged. Spatial inference → anchors → pattern
application. The source='inferred' flag makes the quality level explicit.
- Cross-check gate (future): when both authored and inferred exist, compare
them. Divergence flags extraction bugs or broken IFC port modeling.
Does not change: §6.12.1 isolation invariant (extraction-time only),
RouteWalker interface (reads anchors + patterns from ERP.db), compile-time
contract. IFCtoERP populates mep_connectivity during extraction alongside
anchors. DAGCompiler reads it from ERP.db if present, ignores if absent.
S173 implementation refs:
- Schema: extractIFCtoDB.py REFERENCE_SCHEMA — port_elements, port_connections tables
- Extraction: TODO — extract from IfcRelConnectsPorts + IfcRelConnectsPortToElement
- Load-time rotation proof: scripts/stress_blender_test.py §PROOF PORT_CONNECT
(reconstruct port world position via rot @ local_offset + centre, connected ports must meet)
- See also: docs/DISC_VALIDATE_SRS.md §6.2.1
2. Pattern — Topology Rows in ad_mep_pattern¶
A pattern is a sequence of routing steps mined from a discipline-complete building. Each row is
one step: a node type transition (from → to), the direction axis, and the offset rule. Steps are
ordered by sequence. RouteWalker iterates steps in order against the anchor set.
CREATE TABLE ad_mep_pattern (
pattern_id TEXT NOT NULL,
discipline TEXT NOT NULL, -- CW, SP, FP, ACMV, ELEC
building_type TEXT NOT NULL, -- COMMERCIAL, RESIDENTIAL, TERMINAL, CLINIC
sequence INTEGER NOT NULL,
from_node_type TEXT NOT NULL, -- METER, FIXTURE, VALVE, RISER, JUNCTION, STACK
to_node_type TEXT NOT NULL,
direction_axis TEXT NOT NULL, -- X, Y, Z, GRADIENT
piece_type TEXT NOT NULL, -- PIPE_STRAIGHT, PIPE_ELBOW, FLOOR_TRAP, etc.
offset_rule TEXT, -- DIRECT, MIN_GRADIENT, STACK_OFFSET
gradient REAL, -- for GRADIENT axis: dz per metre (e.g. 0.025 for SP)
notes TEXT,
source_building TEXT, -- which building this pattern was mined from
PRIMARY KEY (pattern_id, sequence)
);
CW pattern (mined from TE, discipline=CW): supply run from meter/riser → horizontal main → branch to fixture. Direction: horizontal (X or Y), then vertical drop (Z) to fixture connection.
SP pattern (mined from TE, discipline=SP): fixture drain → horizontal run with gradient → stack/waste riser. Direction: horizontal with GRADIENT (dz/dx = 0.025), then vertical stack (Z).
Example SP pattern rows (mined from TE 455 SP segments):
pattern_id=SP_TERMINAL_01 discipline=SP building_type=TERMINAL
seq from_node_type to_node_type direction_axis piece_type offset_rule gradient
10 FIXTURE JUNCTION GRADIENT PIPE_STRAIGHT MIN_GRADIENT 0.025
20 JUNCTION JUNCTION GRADIENT PIPE_STRAIGHT MIN_GRADIENT 0.025
30 JUNCTION STACK Z PIPE_STRAIGHT STACK_OFFSET —
Example CW pattern rows:
pattern_id=CW_TERMINAL_01 discipline=CW building_type=TERMINAL
seq from_node_type to_node_type direction_axis piece_type offset_rule
10 METER JUNCTION X PIPE_STRAIGHT DIRECT
20 JUNCTION FIXTURE Y PIPE_STRAIGHT DIRECT
30 JUNCTION FIXTURE Z PIPE_STRAIGHT DIRECT
Building type matching: RouteWalker selects patterns by building_type. RM is RESIDENTIAL.
TE is TERMINAL. If no exact match, fall back to the nearest pattern by element count similarity.
Pattern mining from TE is a one-time extraction step (00q-mine prompt).
3. RouteWalker — Pattern Application Within Envelope¶
RouteWalker takes:
- Anchor set from ad_mep_anchor (for the target building)
- Pattern from ad_mep_pattern (by discipline + building_type)
- Envelope from compile DB c_orderline WHERE Discipline='ARC' (walls, slabs, ceilings)
Algorithm:
1. Select anchors by storey
2. For each pattern step (seq order): connect nearest unconnected anchor pair matching (from_node_type → to_node_type)
3. Generate M_BOM_Line entries: piece_type from step row, dz from gradient rule, length from anchor distance
4. ARC envelope used for constraint: generated pipe must not penetrate ARC AABB (clash check)
5. Write to compile DB c_orderline with Discipline=discipline
RouteWalker operates entirely within DAGCompiler. It reads ERP.db (anchors + patterns) and the compile DB (ARC envelope from c_orderline). It writes to the compile DB. No extraction DB access.
RouteWalker is NOT a routing engine (no A* pathfinding, no graph search). It is a pattern applier: for each pattern step, find the nearest matching anchor pair, emit BOM lines. The pattern encodes all routing intelligence. The walker only matches and emits.
4. Witness Claims¶
W-PATTERN-CW — RouteWalker generates CW pipe network for RM:
For HospitalAuckland, RouteWalker with CW_TERMINAL_01 pattern applied to METER+FIXTURE anchors produces a connected CW network: all FIXTURE anchors reachable from at least one METER anchor, zero CW pipes intersecting ARC AABB (clash=0), all generated segments horizontal or vertical (no diagonal), pipe count within 20% of TE CW segment count scaled by floor area ratio.
W-PATTERN-SP — RouteWalker generates SP pipe network for RM:
For HospitalAuckland, RouteWalker with SP_TERMINAL_01 pattern applied to FIXTURE+STACK anchors produces a connected SP network: all FIXTURE anchors drain to at least one STACK anchor, all generated GRADIENT segments have dz/dx ≥ 0.025 (MS 1228 §5.3), zero SP pipes intersecting ARC AABB (clash=0), STACK anchor count ≥ 1 per storey.
GEO DRIFT scope: W-PATTERN-CW and W-PATTERN-SP use clash+gradient assertions, not centroid matching. Centroid DRIFT (±50mm) applies only to extracted elements (FP, ACMV in RM; all disciplines in TE). Generated CW/SP geometry for RM is not compared against IFC positions (none exist at discipline level) — it is validated structurally (clash, connectivity, gradient).
GEO Forensic Ceiling — G1 vs G2¶
emitGeoSummary() compares compiled placements against source extraction positions
by IFC GUID. RouteWalker-generated rows have no IFC GUID and are excluded.
| Class | Example | GEO scope | Discipline proof | Generated-route proof |
|---|---|---|---|---|
| G1 | TE (IFC4) | All extracted GUIDs | W-TE-DISC (elements_meta.discipline) | — |
| G2 | RM (IFC2x3) | Extracted GUIDs only; routes excluded | Typed classes only | W-PATTERN-CW/SP |
G2 invariant: For G2 buildings, pattern+connectivity proofs are the primary validation mechanism for generated pipe routes. GEO comparison covers extraction-origin elements only.
5. Phasing¶
| Phase | Scope | Prereq |
|---|---|---|
| 00q-schema | DDL: ad_mep_anchor + ad_mep_pattern in ERP.db migration |
G1 fix: add discipline to _import_joint_piece_types |
| 00q-mine | Mine CW+SP patterns from TE (Terminal_Extracted.db → ad_mep_pattern rows) | 00q-schema |
| 00q-anchor | IFCtoERP: extract anchor points from RM into ad_mep_anchor |
00q-schema |
| 00r-walker | DAGCompiler: RouteWalker class — pattern select + anchor match + BOM line emit | 00q-anchor |
| 00r-envelope | RouteWalker: ARC envelope clash check using compile DB c_orderline | 00r-walker |
| 00s-witness | W-PATTERN-CW + W-PATTERN-SP gate tests | 00r-envelope |
| 00t-g3fix | IFCtoERP.discFromClass(): read elements_meta.discipline first (G3 fix) | 00q-anchor |
6. What This Does Not Change¶
- §6.12.1 Compilation Isolation Invariant: unchanged. IFCtoERP extracts; DAGCompiler compiles.
- §6.12.2 shim+recipe architecture: unchanged for TE (all disciplines extracted, classified, recipe-walked).
- GEO DRIFT proof for TE: unchanged (extracted elements, centroid matching).
- RM FP, ACMV: continue on shim+recipe path (those IFC classes are typed).
- RouteWalker is additive — it supplements the shim+recipe walk for CW/SP in buildings with G2.
6.12.4 Space Identity — Room Type Bridges MEP to Furniture (S149b)¶
Problem: MEP recipes (MEP_RECIPE BOMs in ERP.db) know how to place pipes
geometrically but don't know which room they serve. Room SET BOMs (in *_BOM.db)
contain furniture but don't claim MEP terminals. A pipe run that ends at a sink
has no compiler-visible link to the KITCHEN room where the sink lives.
Why this matters: Without space identity, the compiler can place all 162 DX pipe runs at correct offsets but cannot answer: "does every BATHROOM have a waste pipe to a STACK?" or "does this KITCHEN's cold water run reach the sink?" The pipe ends geometrically near the sink but the compiler has no proof — only coincidence.
1. The Abstract Model¶
Space identity is the bridge between three existing data structures:
Room SET BOM (in *_BOM.db) ad_space_type_mep_bom (in ERP.db) MEP Recipe (in ERP.db)
┌─────────────────────────┐ ┌───────────────────────────┐ ┌──────────────────────┐
│ DX_A104_SET │ │ BATHROOM needs: │ │ D_CW_U_RUN_7 │
│ bom_type=SET │────────→│ TOILET → anchor=STACK │←──────────│ bom_type=MEP_RECIPE │
│ role=BATHROOM │ lookup │ SINK → anchor=RISER │ claimed │ target_space=BATHROOM│
│ children: furniture │ │ EXHAUST → anchor=PANEL │ by │ anchor_end=RISER │
└─────────────────────────┘ └───────────────────────────┘ └──────────────────────┘
Three data facts, no routing logic:
1. Room capability — inferred from fixture/pipe presence (PLUMBABLE, ELECTRIFIED, etc.)
2. MEP schedule — what each concrete room type needs (ad_space_type_mep_bom, 186 rows) — compliance only
3. Recipe claim — which capability a recipe serves (target_space_type_id on M_BOM)
The compiler never mentions KITCHEN or BATHROOM. It asks: "is this room PLUMBABLE?"
A CW pipe needs a PLUMBABLE room. An ELEC conduit needs an ELECTRIFIED room.
Concrete names (KITCHEN, BATHROOM) live in ad_space_type for compliance rules and
building code references. The crawler reads ad_discipline_capability to map
discipline → capability, then matches recipes to rooms by capability.
The compiler never routes pipes to rooms. It verifies that every room with a capability has at least one recipe claiming that capability. The geometry is already correct (§6.12.2 tack chain). Space identity adds the semantic proof.
2. Data Model — Abstract Capabilities¶
The Two Layers:
| Layer | Table | Contains | Used by |
|---|---|---|---|
| Abstract | ad_discipline_capability |
CW→PLUMBABLE, ELEC→ELECTRIFIED | Crawler (code) |
| Abstract | ad_space_type.is_plumbable etc. |
Boolean capability flags per room type | Crawler (code) |
| Concrete | ad_space_type.Value |
KITCHEN, BATHROOM, BEDROOM | Compliance rules |
| Concrete | ad_space_type_mep_bom |
BATHROOM→TOILET, KITCHEN→SINK | Building code |
The code path: recipe discipline (CW) → ad_discipline_capability → capability
(PLUMBABLE) → rooms where is_plumbable=1. No room name in the code.
Capabilities (inferred from fixture/pipe presence at extraction time):
| Capability | Inferred when | Discipline |
|---|---|---|
| PLUMBABLE | sink, WC, shower, floor trap, or CW/HW/WASTE pipes | CW, SP |
| ELECTRIFIED | outlet, light, switch, or conduit | ELEC |
| FIRE_PROTECTED | sprinkler, smoke detector | FP |
| VENTILATED | exhaust fan, diffuser, HVAC duct | ACMV |
| GAS_SERVED | gas range, water heater | LPG |
M_BOM — two columns on MEP recipes (DV040):
| Column | Type | Purpose |
|---|---|---|
target_space_type_id |
TEXT | Capability this recipe serves (PLUMBABLE, not KITCHEN) |
anchor_end |
TEXT | Infrastructure endpoint: RISER, STACK, or PANEL |
ad_discipline_capability (DV041):
| discipline | capability |
|---|---|
| CW | PLUMBABLE |
| SP | PLUMBABLE |
| ELEC | ELECTRIFIED |
| FP | FIRE_PROTECTED |
| ACMV | VENTILATED |
| LPG | GAS_SERVED |
Why this extends to any building: A hospital OPERATING_THEATER is PLUMBABLE +
ELECTRIFIED + VENTILATED + FIRE_PROTECTED. A warehouse STORAGE is ELECTRIFIED +
FIRE_PROTECTED. The capabilities are universal; the concrete names are domain-specific.
Adding a new building type means adding rows to ad_space_type — no code changes.
3. Extraction Flow (IFCtoERP)¶
IFC extraction DB
│
├── rel_contained_in_space → furniture in rooms → inferSpaceType()
│ → writes doc_sub_type on SET BOM in *_BOM.db
│
├── IfcFlowTerminal names → classifyFixtureName()
│ → maps terminal to mep_product_id (SINK, TOILET, etc.)
│
└── MEP chain detection → buildMepBomRecipes()
→ last piece in chain → nearest room AABB → room's space_type
→ writes target_space_type_id + anchor_end on M_BOM in ERP.db
GEO logging (black-box — inference):
[MEP-SPACE] room=A104 space_type=BATHROOM capabilities={PLUMBABLE,ELECTRIFIED,VENTILATED}
[MEP-SPACE] room=B103 space_type=KITCHEN capabilities={PLUMBABLE,ELECTRIFIED,GAS_SERVED}
[MEP-SPACE] room=A202 space_type=HABITABLE capabilities={ELECTRIFIED}
GEO logging (white-box — linkage):
[MEP-SPACE-LINK] recipe=D_CW_U_RUN_1 disc=CW capability=PLUMBABLE room=B103 concrete_type=KITCHEN anchor_end=RISER
[MEP-SPACE-LINK] recipe=D_CW_U_RUN_3 disc=CW capability=PLUMBABLE room=A104 concrete_type=BATHROOM anchor_end=RISER
[MEP-SPACE-LINK] Duplex: 12 linked, 150 no room, 0 no capability
Note: concrete_type is logged for human traceability but never stored on M_BOM.
The recipe carries PLUMBABLE, not KITCHEN.
4. Compile-Time Validation¶
At compile time, the walker reads target_space_type_id from the MEP recipe
and checks that the room it serves exists in the BOM hierarchy. No routing — just
a foreign key walk:
For each MEP_RECIPE M_BOM where target_space_type_id IS NOT NULL:
1. Find all SET BOMs where doc_sub_type = target_space_type_id
2. Verify at least one exists in the same building
3. Log: MEP-SPACE-AUDIT PASS/FAIL per recipe
For each Room SET BOM where doc_sub_type IS NOT NULL:
1. Look up ad_space_type_mep_bom WHERE space_type_id = doc_sub_type
2. For each scheduled (mep_product_id, anchor_end) pair:
Find a MEP_RECIPE with matching target_space_type_id + anchor_end
3. Log: MEP-SPACE-COVERAGE PASS/FAIL per room
5. Why This Is Abstract¶
The compiler never says KITCHEN. It says PLUMBABLE.
The model works for any building because:
- Capabilities are universal: PLUMBABLE, ELECTRIFIED, FIRE_PROTECTED, VENTILATED, GAS_SERVED
- Concrete names are domain-specific: KITCHEN, BATHROOM, OPERATING_THEATER — metadata only
- Discipline→capability mapping is data (ad_discipline_capability), not code
- Inference comes from fixture/pipe presence — IFC-universal, no domain knowledge in code
- Adding a new building type = new rows in ad_space_type + ad_space_type_mep_bom. Zero code changes.
A fridge is furniture. A fridge in a room with a sink makes it a KITCHEN (concrete name for compliance). But the compiler only sees: this room has plumbing fixtures → PLUMBABLE. A CW pipe recipe needs a PLUMBABLE room. Match.
A hospital OPERATING_THEATER is PLUMBABLE + ELECTRIFIED + VENTILATED + FIRE_PROTECTED. A warehouse STORAGE is ELECTRIFIED + FIRE_PROTECTED. The code path is identical.
What a newbie needs to know:
1. The crawler reads ad_discipline_capability — never hard-codes room names
2. Room capabilities are inferred from IFC fixtures, not from room names
3. Concrete names (KITCHEN, BATHROOM) are compliance metadata — they appear in
ad_space_type_mep_bom for building code rules, never in crawler logic
4. To add a new discipline: one row in ad_discipline_capability, one capability
flag on ad_space_type, one inference rule in inferCapabilities()
6. Rosetta Stone vs Compiled Output — What Goes Where¶
Critical distinction for newbies:
| Data | Where | Status | Used for |
|---|---|---|---|
| Sink position | extracted DB (Rosetta Stone) | Reference only | Convergence proof (§6 below) |
| Pipe recipe offsets | ERP.db (M_BOM) | Compiled | Walker placement |
| Room capability | inferred at extraction | Metadata | Linkage + gap analysis |
| Placement offsets | ad_placement_offset (DV042) | Metadata | Gap target computation |
| Fixture gap INSERTs | console output | Actionable | User/script seeds missing targets |
The sink in the extracted DB is a Rosetta Stone witness — it proves the recipe geometry matches reality. It is NOT a compiled product. The DX pipeline compiles ARC/STR only. MEP recipes are extracted into ERP.db for the walker to use.
The convergence proof compares recipe endpoints against Rosetta Stone terminal positions to verify the extraction is correct. It does NOT mean the walker placed the sink — the sink was already there in the IFC.
For generative buildings (no IFC source), the walker will place fixtures using
ad_placement_offset rules + room AABBs. The gap analysis tells the user what's
missing and where to put it.
7. How the Pipe Reaches the Sink¶
Q: How does the walker know where to put the sink?
The walker doesn't decide. The sink's position is already in the BOM — extracted from IFC. The pipe recipe's last piece offset converges on the sink's position because both were extracted from the same IFC model.
Extracted buildings (DX, SH, TE):
IFC file → extractIFCtoDB → element_transforms (sink at 2.97, -10.66, 0.93)
→ buildMepBomRecipes → chain detection → pipe recipe with offsets
→ shim origin = first pipe position (3.55, -17.42, 2.75)
→ last piece offset = cumulative from shim
→ absolute position = origin + offset → (6.73, -17.42, 2.74)
→ nearest PLUMBABLE terminal = Sink at 2.97m XY distance
Generative buildings (DM, future):
ad_space_type_mep_bom → KITCHEN needs SINK at WALL_SIDE
RouteWalker → generates pipe from RISER anchor to SINK position
SINK position → from placement_rule (WALL_SIDE) + room AABB
Convergence proof (forensic, zero speculation):
Every pipe recipe stores its shim's world position (origin_x/y/z on M_BOM).
At extraction time, linkRecipesToSpaces computes: absolute endpoint = origin +
last piece offset, then finds the nearest plumbing terminal. The result:
| Verdict | Distance | Meaning |
|---|---|---|
| CONVERGED | < 1m | Pipe serves this fixture directly |
| NEAR | 1–3m | Pipe in same room zone as fixture |
| FAR | > 3m | Pipe is a main run, not a terminal branch |
DX result: 133/162 recipes converged (< 3m to nearest terminal). 29 FAR = main ceiling runs between storeys, no terminal nearby.
GEO log format (self-documenting, no human interpretation needed):
[MEP-CONVERGE] recipe=D_CW_U_RUN_3 disc=CW endpoint=(8.42,-17.41,2.75)
terminal=PANELBOARD at (7.39,-17.41,1.80) dist_xy=1.03m → CONVERGED
The pipe is "in the wall" because the shim attaches to the wall surface.
CW_CEILING_SHIM has host_ifc_class=IfcCovering, mount=BOTTOM. The pipe hangs
from the ceiling. ELEC_WALL_SHIM has host_ifc_class=IfcWall, mount=SIDE. The
conduit runs along the wall. The shim IS the wall/ceiling attachment — no routing.
8. Order Qty → Room Coverage — How MEP Quantities Work¶
The YAML order (user input) has AD_Org=MEP with a qty. This qty is NOT
per-fixture — it's a coverage level that the walker resolves per room
using the schedule in ad_space_type_mep_bom.
| Order qty | Walker interpretation |
|---|---|
| 99 (or blank) | Use qty_normal for each fixture in each room (standard fit-out) |
| 0 | Fill to qty_max for all fixtures (maximum coverage — FP, ELEC, ACMV) |
| N (specific) | Cap total fixtures at N across the building (budget constraint) |
No separate qty per sub-discipline needed. The sub-discipline breakdown is implicit from room capabilities:
Order: AD_Org=MEP, qty=99
↓
Room A104 (BATHROOM, PLUMBABLE+ELECTRIFIED+FIRE_PROTECTED):
TOILET → qty_normal=1 (from schedule) → CW discipline, 1 pipe run
SINK → qty_normal=1 → CW discipline, 1 pipe run
OUTLET → qty_normal=1 → ELEC discipline, 1 conduit
LIGHT → qty_normal=1 → ELEC discipline, 1 circuit
SPRINKLER → qty_normal=1 → FP discipline, 1 drop
The schedule already says "1 SINK per KITCHEN (min=1, normal=1, max=2)." The order qty controls coverage level; the schedule controls per-room counts.
Area-based quantities (sprinklers, outlets):
When per_area_normal > 0, the qty is computed from room area:
Room area = AABB_width × AABB_depth (from room SET BOM)
FP sprinkler: per_area_normal = 0.07/m²
Room area = 12m² → 0.07 × 12 = 0.84 → round up → 1 sprinkler
Room area = 50m² → 0.07 × 50 = 3.5 → round up → 4 sprinklers
| Schedule column | When used | Example |
|---|---|---|
qty_min |
Minimum required by code (order qty irrelevant) | TOILET in BATHROOM: always ≥1 |
qty_normal |
Standard fit-out (order qty=99) | OUTLET in BEDROOM: 3 |
qty_max |
Maximum allowed / fill target (order qty=0) | OUTLET in BEDROOM: 4 |
per_area_normal |
Area-proportional (overrides qty when > 0) | SPRINKLER: 0.07/m² |
Wiring: YAML → C_Order → Walker
Convention gap (S190): The current code routes
mep_order_qtythroughad_sysconfigin BOM.db. This bypasses the iDempiere C_Order convention. Per-order values belong on C_Order (set by BomDropper from YAML, read by CompilationPipeline from compile DB).ad_sysconfigis system-wide configuration, not per-building order data. See BBC.md §2.1.8.
The coverage level should flow through C_Order (the YAML is the Order):
YAML: mep_order_qty: 99 ← user sets coverage level
↓ BomDropper
C_Order.mep_order_qty = 99 ← stored on the Order
↓ CompilationPipeline.CompileStage
PlacementCollectorVisitor.setMepOrderQty(99) ← walker reads from C_Order
↓ onSubAssembly (SET BOM)
SpaceScheduleDAO.resolveQty(99, entry, area) ← per-room resolution
Default: 99 (standard coverage).
Log channel: GENERATIVE SUMMARY orderQty=N traces the value used.
9. Fixture Gap Analysis — How Newbies Mark Targets¶
When the pipeline runs, emitFixtureGapAnalysis checks every room against
ad_space_type_mep_bom. For each fixture the schedule requires but the room
doesn't have, it:
- Reads
ad_placement_offset(DV042) for the placement rule's offsets - Computes a target position from room AABB + offsets (zero hardcoded distances)
- Emits an INSERT statement the user can apply
Example output:
[MEP-GAP] room=A103 type=KITCHEN fixture=FLOOR_TRAP rule=FLOOR_LOW host=FLOOR anchor=STACK
→ GAP target=(3.852,-11.491,0.000) source=ad_placement_offset
-- A103 needs FLOOR_TRAP at FLOOR_LOW (FLOOR surface, anchor=STACK)
INSERT INTO fixture_target (...) VALUES ('A103', 'FLOOR_TRAP', 'FLOOR_LOW', ...);
Modeller workflow: 1. Run the pipeline — read the GAP output 2. For each GAP: either (a) apply the INSERT to seed the target, or (b) use BonsaiBIMDesigner Outliner to drag the fixture to the correct position 3. Re-run the pipeline — GAP becomes SATISFIED
To customise placement offsets (e.g. different building codes):
-- Change sink height from 850mm to 900mm for commercial kitchens
UPDATE ad_placement_offset SET z_offset = 0.9 WHERE placement_rule = 'WALL_SIDE';
-- Add a new placement rule for hospital oxygen outlets
INSERT INTO ad_placement_offset VALUES ('WALL_BED_HEAD', 'Bed head wall, 1.4m',
0.15, 0, 'FLOOR', 1.4, 'MIN', 'MAX', 'AS/NZS 2896', 'Oxygen 1400mm above floor');
All placement offsets are data. The code reads ad_placement_offset and computes.
No recompilation needed to change where fixtures go.
10. Witnesses¶
| Witness | What it Proves | Test |
|---|---|---|
| W-SPACE-LINK | MEP recipes carry target_space_type_id from extraction | MepRouteGeometryTest S7 |
| W-SPACE-COVER | Every room's MEP schedule is satisfied by at least one recipe | MepRouteGeometryTest S8 |
| W-LOD-BRIDGE | Every generative product has source_element_ref → LOD geometry resolves | MepRouteGeometryTest S19 |
| W-SHIM-DEVICE | Generative devices attach via shim (not bare AABB offset) | MepRouteGeometryTest S20 |
| W-END-JOIN | Walker routes to generative fixture tack point (last piece converges) | MepRouteGeometryTest S21 |
| W-TACK-POINT | Every plumbed fixture has connector with non-zero position + diameter | MepRouteGeometryTest S22 |
| W-DISC-RESOLVE | Generative device discipline matches connects_to, not anchorEnd | MepRouteGeometryTest S23 |
11. LOD Geometry Bridge — source_element_ref (S152)¶
First Principle: Every M_Product that the compiler emits MUST resolve to LOD
geometry. A product without source_element_ref renders as a flat AABB box —
that is a first-principle failure, not a cosmetic issue.
The gap: Generative device products (TOILET, LIGHT, SINK, FRIDGE, etc.) are
created with extracted_from='SHARED_RECIPE' and source_element_ref=NULL.
Meanwhile, the IFC file contains real geometry for these same devices under
their Revit family names:
| Abstract Token | IFC Family Name (in component_library) |
|---|---|
| TOILET | M_Water Closet - Flush Tank:Private - 6.1 Lpf:Private - 6.1 Lpf |
| SINK | M_Sink - Island - Single:455 mmx455 mm - Private:455 mmx455 mm - Private |
| LIGHT | M_Pendant Light - Hemisphere:150W - 120V:150W - 120V |
| SWITCH | M_Lighting Switches:Single Pole:Single Pole |
| OUTLET | M_Duplex Receptacle:Duplex Receptacle:Duplex Receptacle |
| FRIDGE | M_Refrigerator:850 x 760mm:850 x 760mm |
The alias system (DV003_element_mep_alias.sql) already maps IFC names → abstract
tokens. But this mapping is used only at extraction time (IFC name → product_id).
It is never used in reverse (product_id → source_element_ref → geometry_hash).
Root cause: ProductRegistrar.ensureProductCatalog() creates products from
two paths: (A) ExtractionElement objects that carry elementRef → sets
source_element_ref, and (B) schedule/recipe products that carry only the
abstract token → source_element_ref stays NULL. Path B never looks up the
alias table to find a matching IFC family name.
Fix: When creating or updating a SHARED_RECIPE M_Product with NULL source_element_ref, reverse-lookup the alias table:
SELECT element_ref FROM ad_element_mep_alias
WHERE mep_product_id = 'TOILET' LIMIT 1
If found, set source_element_ref = element_ref. This closes the bridge:
M_Product.source_element_ref → I_Geometry_Map.element_ref → geometry_hash → mesh
Invariant (test-first): After IFCtoERP completes, every product referenced
in ad_space_type_mep_bom MUST have either:
- source_element_ref IS NOT NULL, OR
- A corresponding row in M_Product_Image
A NULL source_element_ref with no M_Product_Image is a test FAIL.
Test spec (W-LOD-BRIDGE, S19):
1. Run IFCtoERP for DX (or any building with MEP terminals)
2. For each mep_product_id in ad_space_type_mep_bom:
a. SELECT source_element_ref FROM M_Product WHERE product_id = mep_product_id
b. Assert source_element_ref IS NOT NULL
c. SELECT geometry_hash FROM I_Geometry_Map WHERE element_ref = source_element_ref
d. Assert at least one geometry_hash exists
3. FAIL message: "{product_id} has no LOD geometry bridge — source_element_ref missing"
12. Generative Device Shim Architecture — Place Then Route (S152)¶
First Principle: The compiler's output is always Walker-compiled. Input sources differ (IFC file, schedule data, YAML order) but the output is c_orderline with positions. Input = IFC + YAML + ERP.db rules. Output = compiled ARC/STR from BOM walk + MEP from Walker. No "extracted" vs "generative" distinction in the output.
Problem (three findings, one root cause):
| Finding | Symptom | Root cause |
|---|---|---|
| Toilet inside cupboard | No furniture collision check | PLACE_DEVICE ignores sibling furniture |
| Toilet not facing right | No rotation on generative devices | No shim → no wall-normal → no facing |
| Pipe doesn't reach toilet | No route to generative fixture | Fixture placed but no END-join route |
All three resolve from one architectural decision: generative devices must go through shims, not bare AABB placement.
12a. Shim-Based Device Placement
Current (wrong):
Walker enters SET BOM (BATHROOM)
→ MEPDevicePlacer computes position from room AABB + ad_placement_offset
→ Creates Placement directly (no shim, no rotation, no collision check)
→ Pipe recipes from ERP.db target original IFC positions, not generative positions
Correct:
Walker enters SET BOM (BATHROOM)
1. Read schedule: ad_space_type_mep_bom → BATHROOM needs TOILET at WALL_BACK
2. Select target wall from placement_rule (WALL_BACK → Y-MAX wall of room AABB)
3. Check wall zone for existing furniture (sibling LEAFs under same SET BOM)
→ If occupied, shift along wall or flag as CONFLICT
4. Create phantom SHIM on target wall:
→ host_ifc_class from placement_rule (WALL for WALL_BACK, CEILING for CEILING_CENTER)
→ mount = SIDE (wall) or BOTTOM (ceiling) or TOP (floor)
→ shim origin = wall surface point at placement offset
5. Attach device as child of SHIM:
→ offset = standoff distance (e.g. 5mm from wall for toilet)
→ facing = inherited from shim's wall normal (no rotation math needed)
6. Device now has world position = shim origin + child offset
→ same code path as existing MEP shim walk
12b. Fixture Tack Points — Where the Pipe Connects
A pipe's last piece must connect to a specific point on the fixture body, not to the fixture's AABB center. A toilet has a waste shank at the bottom- rear; a sink has a drain at the bottom-center and supply valves underneath. Without tack points, the pipe has nowhere to aim.
Existing infrastructure: ad_assembly_connector already has the right
schema (face, connector_type, position_x/y/z, diameter_mm, connects_to).
It has room-level entries (TOILET_BLOCK_FIXTURES → WASTE_OUT 100mm → STACK)
but not individual M_Product-level entries. Positions are (0,0,0) placeholders.
What's needed: Populate ad_assembly_connector for individual M_Products:
TOILET → WASTE_OUT face=BOTTOM pos=(0.0, -0.15, 0.05) dia=100mm → PLUMBING_STACK
TOILET → SUPPLY_IN face=BOTTOM pos=(-0.15, -0.15, 0.15) dia=15mm → WATER_RISER
SINK → WASTE_OUT face=BOTTOM pos=(0.0, 0.0, 0.0) dia=40mm → PLUMBING_STACK
SINK → SUPPLY_IN face=BOTTOM pos=(0.0, 0.0, 0.15) dia=15mm → WATER_RISER
LIGHT → SUPPLY_IN face=TOP pos=(0.0, 0.0, 0.0) dia=20mm → ELEC_CONDUIT
OUTLET → SUPPLY_IN face=BACK pos=(0.0, 0.0, 0.0) dia=20mm → ELEC_CONDUIT
Positions are relative to the fixture's local origin (same frame as
component_definitions local_min/max). The Walker reads the tack point
to compute the pipe's final segment offset.
Tack point resolution chain:
M_Product (TOILET) → ad_assembly_connector WHERE assembly_id = 'TOILET'
→ connector_type = 'WASTE_OUT'
→ tack point = fixture_world_position + connector.position (rotated by shim normal)
→ pipe last piece targets this tack point
Data source: For input buildings with IFC, tack points can be extracted
from IfcDistributionPort (if present) or inferred from fixture AABB +
connector face. For schedule-only buildings, tack points come from the
product catalog (seeded once, reused across buildings).
Invariant: Every M_Product referenced in ad_space_type_mep_bom that
has anchor_end (STACK, RISER, PANEL) MUST have at least one connector
in ad_assembly_connector with a non-zero position. Zero-position = test FAIL.
Test spec (W-TACK-POINT, S22):
1. For each mep_product_id in ad_space_type_mep_bom WHERE anchor_end IS NOT NULL:
a. SELECT * FROM ad_assembly_connector WHERE assembly_id = mep_product_id
b. Assert at least one connector exists
c. Assert position is non-zero (not placeholder 0,0,0)
d. Assert diameter_mm > 0
e. Assert connects_to matches anchor_end pattern (WASTE_OUT→STACK, SUPPLY_IN→RISER)
2. FAIL message: "{product_id} has no tack point — pipe cannot connect"
12c. END-Join Route — Walker Routes to Tack Point
After PLACE_DEVICE creates the fixture at its shim-anchored position, the Walker must generate a pipe/conduit route from infrastructure to the fixture's tack-to point (not its center).
Every fixture is a mini BOM with tack metadata:
TOILET (mini BOM):
├── shim → WALL_BACK (solves position + facing)
├── tack-FROM: shim origin (where it sits)
└── tack-TO: WASTE_OUT at (0, -0.15, 0.05) — shank connection
SUPPLY_IN at (-0.15, -0.15, 0.15) — valve connection
↑ incoming pipes look for these points
LIGHT (mini BOM):
├── shim → CEILING_CENTER (hangs from ceiling)
└── tack-TO: SUPPLY_IN at (0, 0, 0) on TOP — junction box
↑ conduit from panel END-joins here
The tack-to points live in ad_assembly_connector at the M_Product level
(not room assembly level). Each fixture declares its connection points:
| assembly_id | face | connector_type | position (local) | dia_mm | connects_to |
|---|---|---|---|---|---|
| TOILET | BOTTOM | WASTE_OUT | (0, -0.15, 0.05) | 100 | PLUMBING_STACK |
| TOILET | BOTTOM | SUPPLY_IN | (-0.15, -0.15, 0.15) | 15 | WATER_RISER |
| SINK | BOTTOM | WASTE_OUT | (0, 0, -0.05) | 40 | PLUMBING_STACK |
| SINK | BOTTOM | SUPPLY_IN | (-0.1, 0, 0.15) | 15 | WATER_RISER |
| LIGHT | TOP | SUPPLY_IN | (0, 0, 0) | 20 | ELEC_CONDUIT |
| OUTLET | BACK | SUPPLY_IN | (0, 0, 0) | 20 | ELEC_CONDUIT |
connects_to tells the Walker which infrastructure to route FROM: PLUMBING_STACK
→ find nearest stack anchor, WATER_RISER → find nearest riser, ELEC_CONDUIT →
find nearest panel.
The route sequence:
Walker has placed TOILET at position T = (3.2, 8.1, 0.2) via shim
TOILET.WASTE_OUT tack-to = T + rotated(0.0, -0.15, 0.05) = (3.2, 7.95, 0.25)
1. Read fixture's tack-to from ad_assembly_connector:
→ assembly_id='TOILET', connector_type='WASTE_OUT'
→ tack-to world pos P = fixture_origin + rotated(connector.position)
2. Find nearest infrastructure anchor:
→ connects_to = PLUMBING_STACK → find nearest STACK in ad_mep_anchor
→ anchor world pos A = (3.5, 5.0, 2.75)
3. Generate route segments from A toward P:
→ Horizontal run along ceiling from A
→ Vertical drop toward P height
→ Standard-length pieces from joint vocabulary (PIPE_STRAIGHT, PIPE_ELBOW)
Step 4 — Halt and Recalculate (last mile):
The Walker MUST NOT blindly extend the last segment. Standard pieces have fixed lengths from the joint vocabulary. The gap between the penultimate piece's endpoint and the tack-to point P is almost never an exact multiple of a standard piece length. Without a halt, the pipe overshoots past P.
4. Halt before overshoot:
→ After each segment, compute remaining_distance to P
→ When remaining_distance < next_standard_piece_length:
a. STOP generating standard pieces
b. Create a VARIABLE-length terminal piece:
→ c_uom_id = MM, qty_type = VARIABLE, qty = remaining_distance_mm
→ InterimWorkshop recomputes primitive to exact length (§6)
c. Terminal piece endpoint = P (the tack-to point, exactly)
5. Convergence proof:
→ Assert: terminal piece endpoint == P within 1mm
→ Same CONVERGED proof as §7 but with 1mm tolerance (not 1m)
→ This is a JOIN, not a proximity check — pipe meets fixture
Why InterimWorkshop (§6): The terminal piece is a VARIABLE-length
pipe straight, same as a contractor cutting pipe from stock to fit the
remaining gap. c_uom_id=MM + qty_type=VARIABLE triggers InterimWorkshop
— no CUT verb, no special code path. The UOM is the signal.
Overshoot detection (test invariant): If any route's terminal piece
endpoint exceeds P by more than 1mm on any axis, the test FAILs with
OVERSHOOT: pipe extends {N}mm past fixture tack-to point. This is not
a warning — overshoot means the pipe goes through the wall or into the
next room.
The sequence is: place fixture → read tack-to → route toward it → halt at last mile → trim terminal piece to exact length → join.
12d. Discipline Resolution — connects_to, Not anchorEnd
resolveDeviceDiscipline() (PlacementCollectorVisitor:1411) maps discipline
from anchorEnd (PANEL/RISER/STACK). This is wrong — anchorEnd is an
infrastructure endpoint type, not a discipline. SPRINKLER connects to a
FP panel, not an electrical panel. All PANEL devices default to ELEC.
Fix: Read connects_to from ad_assembly_connector (seeded by DV047):
| connects_to | Discipline |
|---|---|
| ELEC_CONDUIT | ELEC |
| WATER_RISER | CW |
| PLUMBING_STACK | SP |
| FP_MAIN | FP |
| ACMV_DUCT | ACMV |
The resolver should query ad_assembly_connector WHERE assembly_id = deviceId
and map connects_to → discipline. Fallback to ELEC only if no connector row.
Test spec (W-DISC-RESOLVE, S23):
1. For each generative device in SH/DX pipeline output:
a. Read Discipline from c_orderline
b. Read connects_to from ad_assembly_connector WHERE assembly_id = productId
c. Assert discipline matches connects_to mapping:
SPRINKLER → FP (not ELEC)
EXHAUST_FAN → ACMV (not ELEC)
SUPPLY_DIFFUSER → ACMV (not ELEC)
LIGHT → ELEC
OUTLET → ELEC
2. FAIL: "{device} discipline={actual} but connects_to={infra} → expected {correct}"
12e. Furniture Collision Avoidance
When selecting a wall zone for device placement (step 3 above), the placer must check for existing furniture occupying that zone.
Room SET BOM children (already walked):
CUPBOARD at (2.8, 7.9, 0.0) AABB 0.6×0.4×2.0m ← occupies WALL_BACK zone
PLACE_DEVICE wants TOILET at WALL_BACK:
1. Compute candidate position from ad_placement_offset
2. Check: does candidate AABB overlap any sibling furniture AABB?
3. If overlap:
a. Shift along wall (try next available segment)
b. If no space on target wall: try adjacent wall with same orientation
c. If no wall available: emit CONFLICT warning (do not invent position)
4. Log: GENERATIVE COLLISION_CHECK {device} zone={wall} siblings={N} result={OK|SHIFT|CONFLICT}
Invariant: A generative device MUST NOT overlap with any existing furniture LEAF. Overlap = test FAIL.
12f. Test Specs
W-SHIM-DEVICE (S20): Generative devices use shim architecture
1. Walk DX_BOM.db with erpConn (same as S16)
2. For each generative placement:
a. Assert it has a parent shim in the placement hierarchy
b. Assert shim has host_ifc_class (WALL, CEILING, or SLAB)
c. Assert device offset from shim is small (<0.5m) — standoff, not room-scale
d. Assert device facing direction matches shim wall normal
3. For each room with generative devices:
a. Assert no generative device AABB overlaps any furniture LEAF AABB
b. Log any SHIFT events (device moved to avoid furniture)
W-END-JOIN (S21): Walker routes to generative fixture tack-to points
1. Walk DX_BOM.db with erpConn and route generation enabled
2. For each generative PLUMBABLE fixture (TOILET, SINK):
a. Read tack-to from ad_assembly_connector (WASTE_OUT or SUPPLY_IN)
b. Compute tack-to world pos P = fixture_origin + rotated(connector.position)
c. Assert a pipe route exists from infrastructure anchor to P
d. Assert terminal piece is VARIABLE (c_uom_id=MM, qty_type=VARIABLE)
e. Assert terminal piece endpoint == P within 1mm (exact join, not proximity)
f. Assert NO OVERSHOOT: terminal endpoint must not exceed P on any axis by >1mm
3. For each generative ELECTRIFIED fixture (OUTLET, LIGHT, SWITCH):
a. Read tack-to from ad_assembly_connector (SUPPLY_IN)
b. Assert conduit route from PANEL anchor to tack-to point
c. Assert terminal piece endpoint == tack-to within 1mm
d. Assert no overshoot
4. FAIL if any fixture has no route (gap = test failure, not a warning)
5. FAIL if any route overshoots: "OVERSHOOT: pipe extends {N}mm past tack-to"
W-TACK-POINT (S22): Fixture tack points exist and are non-placeholder
1. For each mep_product_id in ad_space_type_mep_bom WHERE anchor_end IS NOT NULL:
a. SELECT * FROM ad_assembly_connector WHERE assembly_id = mep_product_id
b. Assert at least one connector row exists
c. Assert position is non-zero (not placeholder 0,0,0)
d. Assert diameter_mm > 0
e. Assert connects_to matches discipline pattern:
WASTE_OUT → PLUMBING_STACK, SUPPLY_IN → WATER_RISER/ELEC_CONDUIT
2. For TOILET specifically:
a. Assert WASTE_OUT connector exists (shank)
b. Assert SUPPLY_IN connector exists (valve)
c. Assert positions are physically plausible (within fixture AABB)
3. FAIL message: "{product_id} has no tack point — pipe cannot connect"
12g. Gap Analysis — Six Spec Gaps Found (S153 Pre-Flight)
The following gaps were identified by cross-referencing §12a-§12f against the
actual code (PlacementCollectorVisitor, MEPDevicePlacer, SpaceScheduleDAO,
ShimMatcher, RouteWalker) and the DX extracted database. Each gap would cause
incorrect placement if not addressed before coding.
GAP-1: Walk Ordering — MEP Devices Placed Before Furniture Children
PlacementCollectorVisitor.onSubAssembly() fires generative device placement
(line 406: MEPDevicePlacer.placeDevices()) BEFORE BOMWalker.walkChildren()
processes the SET BOM's LEAF children. Furniture placements are collected in
onLeaf(), which fires during walkChildren(). Therefore furniture positions
do NOT exist when §12e collision check executes.
Fix: Move generative MEP placement from onSubAssembly() to
onSubAssemblyComplete() for SET BOMs. At that point, all furniture LEAFs
have been walked and their Placement records exist in the placements list.
The collision check (§12e) can then iterate over already-collected furniture
AABBs for the current room.
Ordering after fix:
Room SET BOM ENTER (onSubAssembly)
→ Walk children: furniture LEAFs placed (onLeaf per child)
Room SET BOM EXIT (onSubAssemblyComplete)
→ Read room context from stacks (anchor, rotation, AABB — still available)
→ Collect furniture AABBs from placements added during child walk
→ MEPDevicePlacer.placeDevices() — with furniture collision data
→ Create shim + device placements
Invariant (test-first): S20 collision check MUST have furniture AABBs available. A test that places a device overlapping furniture MUST FAIL.
GAP-2: Ceiling Z Uses Metadata Default, Not Extracted ARC Surface
SpaceScheduleDAO.getCeilingHeightM() returns ad_space_type.default_ceiling_height_mm
(2700mm for residential). DX extracted data shows:
| Surface | Actual Z (metres) |
|---|---|
| L1 Gypsum ceiling (IfcCovering) | 2.629m |
| L1 Structural slab (IfcSlab wood joist) | 2.948m |
| Metadata default | 2.700m |
A CEILING device placed at 2.700 - 0.050 = 2.650m floats 21mm above
the actual gypsum board at 2.629m. This is the "floating elements" symptom.
Fix: After ARC walk completes (guaranteed by GAP-1 reorder), query compiled ARC output for the actual ceiling surface Z in the room's column:
SELECT MIN(dz) FROM c_orderline
WHERE Discipline = 'ARC'
AND family_ref LIKE '%Ceiling%' OR family_ref LIKE '%Covering%'
AND dz > room_floor_z
AND dz < room_floor_z + 5.0
Resolution chain:
1. Primary: Query compiled IfcCovering (gypsum board) in same storey → use its Z
2. Secondary: Query compiled IfcSlab above room floor Z → use bottom face
3. Fallback: Use metadata default_ceiling_height_mm (current behaviour) + log WARNING
Store resolved ceiling Z on the room context so all devices in the same room use the same value. Do NOT re-query per device.
The same pattern applies to FLOOR (use actual slab top Z) and WALL (use actual wall face position). For DX, floor finish surfaces exist at Z=0.007m (ceramic) and Z=0.010m (wood) — not exactly 0.000m.
GAP-3: ShimMatcher Not Used for Generative Placement
ShimMatcher.matchHost() exists and works for extracted MEP (matches shim
to nearest ARC host by IFC class + proximity). But MEPDevicePlacer never
calls it. Generative devices compute positions from room AABB + metadata
offsets only — no ARC host snapping.
Fix: After computing candidate position from ad_placement_offset,
pass the position through ShimMatcher.matchHost() to snap the shim
origin to the nearest actual ARC surface. ShimMatcher already handles
mount-aware Z adjustment (BOTTOM/TOP/SIDE).
Flow:
1. SpaceScheduleDAO.computePosition(roomAabb, entry) → candidate position
2. ShimMatcher.matchHost(shimProduct, candX, candY, candZ, compileDb) → snapped position
3. If SHIM_MISS: use candidate + log WARNING (no ARC host found)
4. If SHIM_MATCH: use adjusted position (Z snapped to actual surface)
This eliminates the 21mm float because ShimMatcher will find the gypsum board at 2.629m and adjust Z accordingly.
GAP-4: Facing Direction Computation Unspecified
§12a step 5 says "facing = inherited from shim's wall normal (no rotation math needed)" but the spec never defines how wall normal is computed from ARC host data. ShimMatcher returns a matched ARC host position but not its orientation.
Fix: For WALL mounts, compute facing normal from the wall's AABB orientation. DX walls are axis-aligned, so: - Wall along X-axis (width >> depth): normal = ±Y - Wall along Y-axis (depth >> width): normal = ±X - Sign = direction from wall center to device center
For CEILING/FLOOR mounts, facing is always -Z (pendant) or +Z (upright).
Store the facing vector on the shim BOM line or on DevicePlacement. The device inherits it — no per-device rotation computation.
For DX/SH (axis-aligned rooms): Wall normal is always ±X or ±Y. No arbitrary angles needed. The placement_rule already encodes which wall (WALL_BACK=Y-MAX, WALL_SIDE=X-MAX, etc.) — the normal is implicit.
| Placement Rule | Wall | Normal |
|---|---|---|
| WALL_BACK | Y-MAX face | (0, -1, 0) — faces into room |
| WALL_ENTRY | Y-MIN face | (0, +1, 0) |
| WALL_SIDE | X-MAX face | (-1, 0, 0) |
| WALL_SINK | X-MIN face | (+1, 0, 0) |
| CEILING_* | — | (0, 0, -1) pendant |
| FLOOR_* | — | (0, 0, +1) upright |
GAP-5: Infrastructure Anchor Discovery for END-Join Routing
§12c step 2 says "find nearest infrastructure anchor → find nearest STACK
in ad_mep_anchor". But ad_mep_anchor is populated by IFCtoERP extraction
for specific buildings. The spec doesn't define:
a) Which anchors serve which rooms — a PLUMBING_STACK at (5, 10, 2.9) could be in any room. The END-join route needs the nearest anchor that is reachable from the fixture's room (not just geometrically nearest — an anchor in a different room behind a wall is not reachable).
b) Anchor Z for vertical drops — a ceiling pipe must drop vertically to reach a floor-level fixture. The anchor is at ceiling Z, the fixture tack-to is at floor Z. The route needs a vertical segment. The spec describes this in §8c (risers) but not for per-room vertical drops.
c) DX/SH anchor availability — DX has 358 IfcFlowFitting + 427
IfcFlowSegment + 105 IfcFlowTerminal elements in the extracted DB.
Are these already loaded into ad_mep_anchor? Or must the END-join
route discover anchors from compiled c_orderline WHERE Discipline='CW'?
Fix: Define anchor resolution as a two-step process:
-
Room-scoped anchor search: For each fixture's
connects_totype, find anchors whose XY position falls within the room AABB (or the room's parent storey). This eliminates cross-room false matches. -
Fallback to storey-scoped: If no room-level anchor found, search storey-wide (e.g., shared risers, common stacks). Log STOREY_FALLBACK.
-
Fallback to synthetic anchor: If no extracted anchor exists (pure generative building), create a synthetic anchor at a canonical position (e.g., PLUMBING_STACK at room corner nearest to wet wall). Log SYNTHETIC.
For DX/SH: extracted infrastructure positions already exist in compiled output. Route from compiled MEP anchor to generative fixture tack-to.
GAP-6: Room Context Loss at onSubAssemblyComplete
If generative placement moves to onSubAssemblyComplete (GAP-1 fix), the
room's coordinate context (anchor position, rotation, AABB) must still be
available. Currently onSubAssemblyComplete pops all stacks:
anchorStack.pop();
rotationStack.pop();
parentAABBStack.pop();
If MEP placement fires AFTER these pops, the room context is lost.
Fix: Place MEP devices BEFORE the stack pops, but after child walk
completes. Sequence in onSubAssemblyComplete for SET BOMs:
1. [DO NOT pop yet]
2. Detect SET BOM with space type (same check as current onSubAssembly)
3. Collect furniture AABBs from placements (children just walked)
4. Run MEPDevicePlacer.placeDevices() with room context from stacks
5. Create shim + device placements
6. THEN pop stacks (existing cleanup)
Alternatively, capture room context (anchor, AABB, rotation) into local
variables at onSubAssembly and pass them through to onSubAssemblyComplete
via a field (e.g., pendingGenerativeRoom). Cleaner: avoids relying on
stack pop ordering.
GAP-7: Bathroom SET BOM AABB Is Furniture Extent, Not Room Footprint
DX bathroom SET BOMs (A104, B104, A204, B204) have AABB width=475mm, depth=450mm. This is the furniture bounding box (one vanity/WC unit), not the actual room footprint (~2×2m). When the collision check tries to shift a TOILET away from furniture, there's nowhere to go — the room AABB IS the furniture.
The collision check must detect this case: when room AABB width OR depth is below a minimum threshold (1.0m), the SET BOM AABB represents furniture extent, not room geometry. In this case:
- Log
ROOM_NARROWwith dimensions — flags for BOM data review - Skip furniture collision check (the room footprint is unknown)
- Place devices at schedule positions regardless (best guess with wrong data)
- Do NOT count as COLLISION_CONFLICT (not a placement algorithm failure)
The proper fix (deferred) is to extract room footprint from the IFC spatial
container and store it on the SET BOM, separate from the furniture AABB. This
requires IFCtoERP to read IfcSpace geometry bounds and write them as
room_width_mm / room_depth_mm on the SET BOM header.
GAP-8: FLOOR_TRAP 3mm Z Breach — Snap Tolerance
When findFloorZ snaps to a finish floor slab, the slab top may be 1-3mm
below the room anchor Z (due to different accumulation paths: room anchor
through BOM tack vs slab through structural BOM). The breach check flags
this as Z=OUT but it's a rounding/path difference, not a real breach.
Fix: Add a 5mm tolerance to the Z containment check for floor-level devices. If device Z is within 5mm of room minZ, treat as IN.
Summary: Gap Impact on Floating Elements
| Gap | Floating symptom | Severity |
|---|---|---|
| GAP-1 (walk ordering) | Devices ignore furniture → overlap | HIGH — collision broken |
| GAP-2 (ceiling Z) | Devices at 2.65m, ceiling at 2.63m → 21mm float | HIGH — visible |
| GAP-3 (no ShimMatcher) | No snap to ARC surfaces → systematic offset | HIGH — root cause |
| GAP-4 (facing direction) | Devices face wrong way → visual defect | MEDIUM |
| GAP-5 (anchor discovery) | Routes can't find targets → no END-join | HIGH — Phase 3 blocked |
| GAP-6 (context loss) | Fix for GAP-1 breaks if stacks pop first | HIGH — implementation trap |
6.13 IFC-Driven Extraction¶
Status: DONE (S100-p125, commit 3e056227). SH IFC-driven, FK scope box fallback.
The finding¶
The extraction pipeline (ScopeBomBuilder) assigns elements to SET BOMs
using YAML-authored scope boxes (origin_m, aabb_mm). This is manual —
the human defines rectangular containment volumes for each room zone.
But the IFC file already carries this information:
spatial_structure:
IfcBuilding
IfcBuildingStorey "Ground Floor"
IfcSpace "1 - Living room" ← 12 elements contained
IfcSpace "2 - Bedroom" ← 2 elements contained
IfcSpace "3 - Entrance hall" ← 0 elements
IfcBuildingStorey "Roof"
IfcSpace "4 - Roof" ← 0 elements
rel_contained_in_space: element_guid → space_guid (14 assignments)
rel_fills_host: element_guid → host_guid (7 door/window → wall)
Dry run on SH (58 elements): 14 elements assigned to spaces by IFC, 44 orphans (structural: walls, slabs, ceilings, curtain wall). The orphans are correctly structural — not in any room.
Extraction flow¶
IFC spatial containment (S100-p125):
Read rel_contained_in_space from extracted.db
→ "1 - Living room" contains 12 elements
→ "2 - Bedroom" contains 2 elements
YAML maps: ifc_space "1 - Living room" → template SH_LIVING_SET
ifc_space "2 - Bedroom" → template SH_BED_SET
VerbDetector groups within each IFC space
YAML format¶
floor_rooms:
Ground Floor:
bom_id: FLOOR_SH_GF_STD
product_category: GF
spaces:
- { ifc_space: "1 - Living room", template_bom: SH_LIVING_SET, role: LIVING, seq: 10 }
- { ifc_space: "2 - Bedroom", template_bom: SH_BED_SET, role: MASTER, seq: 30 }
No origin_m, no aabb_mm. IFC spatial containment is the sole source
during extraction. YAML maps space names to BOM templates. Scope boxes are
an Order processing concern — the BIM Designer GUI and BOM Drop use
scope boxes when the user defines sub-room zones at order time (e.g.,
splitting a Living room into dining + seating zones).
For buildings without IfcSpace data, extraction groups by storey only
(existing StructuralBomBuilder behaviour). Sub-room grouping is deferred
to order time.
Impact on CLUSTER¶
IFC-driven extraction doesn't eliminate CLUSTER directly — the 6 dining chairs are still 6 identical products in one space. But it changes the extraction architecture from "sort by manual box" to "sort by IFC containment" which:
- Removes human coordinate authoring errors (wrong scope box origin)
- Uses the architect's spatial intent (they modelled the IfcSpaces)
- Enables IFC
rel_fills_hostfor door/window→wall BOM nesting - Reduces YAML from ~15 lines per room to ~2 lines per room
Structural orphans¶
44 elements not in any IfcSpace are structural: walls (5), slabs (2), ceilings (3), curtain wall (26), doors (3), windows (4), roof (1).
Doors and windows have rel_fills_host → they belong to their host wall.
Walls and slabs are floor-level structural → StructuralBomBuilder handles
them (unchanged).
The current extraction already handles orphans correctly —
StructuralBomBuilder picks up everything not assigned to a SET.
IfcRelAggregates extraction (S100-p126)¶
Status: DONE (48d14537)
New extraction table rel_aggregates captures IFC parent-child assembly
decomposition:
CREATE TABLE rel_aggregates (
parent_guid TEXT NOT NULL,
child_guid TEXT NOT NULL,
PRIMARY KEY (parent_guid, child_guid)
);
Populated from IfcRelAggregates relationships in the IFC file. Results:
- SH: 34 rows. 2 assemblies with 13 children each (curtain wall halves), 1 with 3, 2 with 2, 2 singletons.
- DX: 38 rows. 2 assemblies with 10 children each (stair assemblies), 2 with 5, 1 with 4, 3 singletons.
Phantom parents: Parent GUIDs have no entry in elements_meta because
IfcCurtainWall and IfcStair are not in the extraction class list. Assembly
structure is visible only via rel_aggregates join to elements_meta on
child_guid.
Java pipeline unchanged — rel_aggregates is read-only context for P129
(assembly BOMs).
Spatial container auto-discovery (S100-p127)¶
Status: DONE (7745affd)
StoreyConfig renamed to SpatialContainerConfig — abstract naming that
works for buildings (storeys) and infrastructure (segments).
Auto-discovery: When YAML storeys: section is empty, containers are
auto-discovered from storeyElements keys:
- Sort by min Z of their elements (seq: 1010, 1020, ...)
code= generic abbreviation (first letter of each word, uppercase)- No hardcoded name→code mapping — algorithm works for any building
Results:
- SH: 3 containers — Ground Floor→GF, Roof→RO, Unknown→UN
- DX: 5 containers — T/FDN→TF, Level 1→L1, Unknown→UN, Level 2→L2, Roof→RO
YAML storeys: kept as Order override (backward compat). Empty YAML =
auto-discover. BOM IDs change with auto-derived codes (e.g. SH_ROOF_STR →
SH_RO_STR) — opaque keys, no functional impact.
Scope excludes for CompositionBomBuilder (S100-p128)¶
Status: DONE (f807bb3c)
Elements assigned to SET BOMs by ScopeBomBuilder are excluded from CompositionBomBuilder mirror partition. Fixes DX reconciliation delta +50→0 (furniture was double-counted in both SET BOMs and half-unit).
DX YAML floor_rooms removed — dead code since P125.
IFC assembly BOMs (S100-p129)¶
Status: DONE (1153671c)
StructuralBomBuilder reads rel_aggregates from extraction DB. Groups children
by parent GUID, creates ASSEMBLY BOMs for groups with 2+ children. MAKE lines
link FLOOR → ASSEMBLY. Elements not in any assembly stay as flat leaves.
- SH: 2 curtain wall assemblies (13 children each, factorized to 4 BOM lines)
- DX: 2 stair assemblies (3 children each)
- FK/IN: No rel_aggregates matches — zero regression
Phantom parents (IfcCurtainWall, IfcStair) have no elements_meta entry —
assembly structure visible only via rel_aggregates child_guid join.
References: DISC_VALIDATE_SRS.md §9 (5-table LOD chain) | DocAction_SRS.md §1.3 (processIt DocEvent) | CALIBRATION_SRS.md (DocEvent vs Terminal) | G4_SRS.md §2 (output.db pattern)
§11 — DISC BOM Single Source of Truth — Audit Findings (S104)¶
Audit scope: Read-only forensics. Two test stones: HospitalAuckland (RM) + Terminal (TE).
DBs queried: library/ERP.db, DAGCompiler/lib/input/HospitalAuckland_extracted.db, DAGCompiler/lib/input/Terminal_Extracted.db.
§11.1 — CW/SP Disambiguation Rule¶
TE (Terminal) — RESOLVED¶
elements_meta.discipline carries the sub-discipline per element row in the extraction DB.
This column is populated at IFC extraction time and is the authoritative source.
Evidence (Terminal_Extracted.db):
ifc_class discipline count
─────────────────────────────────────────────
IfcPipeSegment FP 2672
IfcPipeSegment CW 619
IfcPipeSegment SP 455
IfcPipeSegment LPG 75
IfcPipeFitting FP 3146
IfcPipeFitting CW 638
IfcPipeFitting SP 372
IfcPipeFitting LPG 87
IfcFlowController FP 14
IfcFlowController CW 7
IfcFlowTerminal SP 150
IfcFlowTerminal CW 106
IfcFireSuppressionTerminal FP 909
CW/SP rule for TE: read elements_meta.discipline → map to AD_Org_ID (CW=6, SP=7, FP=3, LPG=8).
No keyword heuristic needed. No geometry needed.
RM (HospitalAuckland) — G2: CW/SP_UNRESOLVABLE¶
RM uses IFC2x3 generic classes. All pipe/fitting elements have discipline='MEP' (flat, no sub-type).
mep_systemstable: 0 rows (system membership not extracted)system_nodestable: 0 rowselement_properties: onlyReferencewith values (FITTING, FRAME, CURTAIN_PANEL) — no system names- No
PredefinedType,ObjectType,FlowDirection,SystemType, orServiceTypeproperties on any pipe element - Element names:
Pipe Types:Standardcovers 491 of 1060 IfcFlowSegment/IfcFlowFitting instances — no system info
Partial name-based discrimination exists: - "DWV" / "Sanitary" in element_name → SP - "Conduit" → ELEC - Light fixture names (Recessed/Sconce/Pendant Light) → ELEC
But the dominant generic Pipe Types:Standard class (~46% of flow elements) has no IFC attribute that
distinguishes CW from SP.
G2: CW/SP_UNRESOLVABLE for RM.
Resolution path: the RM IFC model requires re-export from Revit with IfcSystem assignments
(IfcDistributionSystem.SystemType) populated per piping network. This is a model quality gap,
not a compiler gap.
§11.2 — Piece-Type to Discipline Map (RM + TE)¶
Column headers: → discipline = AD_Org_ID, source attribute = what drives the classification.
piece_type → discipline source attribute
──────────────────────────────────────────────────────────────────────
DUCT_STRAIGHT → ACMV (5) IfcDuctSegment (class, unambiguous)
DUCT_TEE → ACMV (5) IfcDuctFitting (class, unambiguous)
DUCT_ELBOW → ACMV (5) IfcDuctFitting (class, unambiguous)
DUCT_TRANSITION → ACMV (5) IfcDuctFitting (class, unambiguous)
DUCT_CROSS → ACMV (5) IfcDuctFitting (class, unambiguous)
DUCT_TAKEOFF → ACMV (5) IfcDuctFitting (class, unambiguous)
DUCT_WYE → ACMV (5) IfcDuctFitting (class, unambiguous)
DUCT_FITTING → ACMV (5) IfcDuctFitting (class, unambiguous)
AIR_TERMINAL → ACMV (5) IfcAirTerminal (class, unambiguous)
AIR_DIFFUSER → ACMV (5) IfcAirTerminal (class, unambiguous)
AIR_GRILLE → ACMV (5) IfcAirTerminal (class, unambiguous)
SPRINKLER_HEAD → FP (3) IfcFireSuppressionTerminal (class, unambiguous)
HOSE_REEL → FP (3) IfcFireSuppressionTerminal (class, unambiguous)
SMOKE_DETECTOR → FP (3) IfcFireSuppressionTerminal (class, unambiguous)
LIGHT_FIXTURE → ELEC (4) IfcLightFixture (class, unambiguous)
TOILET → SP (7) element_type keyword (toilet/wc)
URINAL → SP (7) element_type keyword (urinal)
FLOOR_TRAP → SP (7) element_type keyword (trap)
BIDET_SPRAY → SP (7) element_type keyword (bidet)
INSPECTION_CHAMBER → SP (7) element_type keyword (inspection)
GREASE_TRAP → SP (7) element_type keyword (interceptor/grease)
── AMBIGUOUS — needs elements_meta.discipline ──────────────────────
PIPE_STRAIGHT → FP/CW/SP/LPG elements_meta.discipline (TE: resolved)
RM: UNRESOLVABLE (G2)
PIPE_ELBOW → FP/CW/SP/LPG elements_meta.discipline (TE: resolved)
RM: UNRESOLVABLE (G2)
PIPE_TEE → FP/CW/SP/LPG elements_meta.discipline (TE: resolved)
RM: UNRESOLVABLE (G2)
PIPE_REDUCER → FP/CW/SP/LPG elements_meta.discipline (TE: resolved)
RM: UNRESOLVABLE (G2)
PIPE_CROSS → FP/CW/SP/LPG elements_meta.discipline (TE: resolved)
RM: UNRESOLVABLE (G2)
PIPE_COUPLING → FP/CW/SP/LPG elements_meta.discipline (TE: resolved)
RM: UNRESOLVABLE (G2)
PIPE_FLANGE → FP/CW/SP/LPG elements_meta.discipline (TE: resolved)
PIPE_FITTING → FP/CW/SP/LPG elements_meta.discipline (TE: resolved)
VALVE → FP/CW elements_meta.discipline (TE: FP=14, CW=7)
SINK → CW or SP elements_meta.discipline (TE: CW=106 via FlowTerminal)
SHOWER → CW or SP elements_meta.discipline
TAP → CW elements_meta.discipline (TE: CW FlowTerminal)
PLUMBING_FIXTURE → CW or SP elements_meta.discipline (ambiguous by keyword alone)
── RM IFC2x3 generics — AMBIGUOUS (G2 applies) ────────────────────
FLOW_SEGMENT → MEP (undivided) IfcFlowSegment, RM only, discipline='MEP'
FLOW_FITTING → MEP (undivided) IfcFlowFitting, RM only, discipline='MEP'
AMBIGUOUS count: 14 pipe/fitting/valve/fixture piece types require elements_meta.discipline
to resolve. For TE this is available. For RM the 14 types remain unresolvable (G2).
§11.3 — FP Archetype Pattern + ELEC/ACMV/CW/SP equivalents¶
DISC BOM (abstract layer) — FP_SYSTEM in ERP.db¶
bom_id seq child_product_id verb_ref
──────────────────────────────────────────────
FP_SYSTEM 10 FP_RISER ROUTE
FP_SYSTEM 20 FP_SPRINKLER_LAYOUT ROUTE
FP_SYSTEM 30 FP_PUMP_LINK ROUTE
ELEC_SYSTEM, ACMV_SYSTEM, CW_SYSTEM, SP_SYSTEM, LPG_SYSTEM: 0 ROUTE lines (empty shells).
MEP_RECIPE BOMs (spatial layer) — current state¶
FP recipes confirmed in library/ERP.db:
- RM (routing topology): RM_FP_L2_RUN_1 = FP_CEILING_SHIM(seq=10) + SPRINKLER_HEAD_15MM(seq=20–70)
→ maps to FP_SPRINKLER_LAYOUT only. No riser or pump recipe.
- ST (coverage topology): 41 archetypes ST_FP_ARCH_* = FP_CEILING_SHIM(seq=10) + SPRINKLER_HEAD_30MM(seq=20, dz=standoff)
→ all map to FP_SPRINKLER_LAYOUT archetype (coverage, no routing chain).
FP_RISER and FP_PUMP_LINK are not yet represented in any MEP_RECIPE BOM. TE has 2672 FP IfcPipeSegment elements (the riser network) but they are not extracted into recipes.
FP → ELEC/ACMV/CW/SP archetype equivalents¶
| DISC BOM verb | FP piece types | ACMV equivalent | CW/SP equivalent | ELEC equivalent |
|---|---|---|---|---|
| FP_RISER | PIPE_STRAIGHT (FP) | DUCT_STRAIGHT (ACMV) | PIPE_STRAIGHT (CW/SP) | FLOW_SEGMENT (ELEC) |
| FP_SPRINKLER_LAYOUT | SPRINKLER_HEAD | AIR_DIFFUSER/GRILLE | TOILET/SINK/TAP | LIGHT_FIXTURE |
| FP_PUMP_LINK | VALVE (FP) | — | VALVE (CW) | — |
Recipe count by discipline (library/ERP.db, bom_type=MEP_RECIPE):
building FP ACMV CW SP ELEC
─────────────────────────────────────
RM 1 441 341 0 0
ST (TE) 41 169 582 1 45
CE (clinic) 0 0 434 0 0
CH 0 0 588 0 0
CP 0 0 1005 0 0
Note: CE/CH/CP CW recipe explosion (434/588/1005) from misclassified IfcFlowTerminal light fixtures → see §11.4.
§11.4 — IFCtoERP Discipline Assignment (current state)¶
Method: IFCtoERP.discFromClass(String ifcClass, String elementType) (line 698, IFCtoERP.java).
case "IfcFireSuppressionTerminal" -> "FP" // CORRECT — class is unique
case "IfcDuctSegment", "IfcDuctFitting",
"IfcAirTerminal" -> "ACMV" // CORRECT — classes are unique
case "IfcLightFixture" -> "ELEC" // CORRECT — class is unique
case "IfcFlowTerminal" -> keyword check: // PARTIAL
drain/waste/soil/inspection/interceptor → SP
else → CW // BUG: light fixtures in RM are IfcFlowTerminal
case "IfcPipeSegment", "IfcPipeFitting",
"IfcFlowController" -> keyword check: // PARTIAL
soil/drain/sp_ → SP
else → CW // OK for TE, misses LPG
default -> CW // BUG: IfcFlowSegment/IfcFlowFitting → forced CW
G3: IFCtoERP_DISCIPLINE_BLIND
discFromClass() does not read elements_meta.discipline. It re-derives discipline from IFC class
and element_type keyword heuristics. Two concrete failures:
-
RM IfcFlowTerminal → CW (wrong): RM contains light fixtures as
IfcFlowTerminal(e.g.M_Plain Recessed Lighting Fixture,M_Pendant Light). These have no "drain/waste" keyword so they fall through to CW. Effect: CE/CH/CP produce hundreds of CW MEP_RECIPE BOMs for light fixtures. -
RM IfcFlowSegment/IfcFlowFitting → CW (default, wrong): These generic IFC2x3 classes cover all pipe types (CW+SP+FP conduit). Default=CW silently classifies SP drains and electrical conduit as CW.
Fix scope: IFCtoERP only. Strategy:
- In readMepElementsWithPositions(), read elements_meta.discipline alongside ifc_class.
- If discipline is a known sub-discipline code (FP/CW/SP/ACMV/ELEC/LPG), use it directly.
- Fall back to discFromClass() keyword heuristic only when discipline is null or 'MEP'.
- Add keyword: IfcFlowTerminal with "light"/"fixture"/"lamp" → ELEC (catches RM light fixture misclassification).
§11.5 — Gaps Blocking 00q–00t¶
| Gap | Description | Scope | Resolution |
|---|---|---|---|
| G1 | _import_joint_piece_types has no discipline/AD_Org_ID column |
Schema | Add column in 00q SQL migration; populate from elements_meta.discipline at extract time |
| G2 | RM CW/SP UNRESOLVABLE — IFC2x3 model lacks system membership, no sub-discipline attribute for generic pipe elements | IFC model (Revit) | RESOLVED via RouteWalker pattern approach (00r/00s). RM Rosetta Stone 8/8 PASS. |
| G3 | IFCtoERP discFromClass() ignores elements_meta.discipline; defaults IfcFlowTerminal light fixtures to CW; defaults all IfcFlowSegment/FlowFitting to CW |
IFCtoERP.java | Fix: read discipline column first; keyword fallback only if null/'MEP'; add light-fixture keyword for IfcFlowTerminal |
G1 RESOLVED (00q): _import_joint_piece_types.discipline column added.
G2 RESOLVED (00r/00s): RouteWalker pattern approach for G2 buildings. RM 8/8.
G3 TARGET (00t): Routing topology branch at IFCtoERP.java line 799 still calls 2-arg discFromClass. Fix: pass e.discipline. Affects TE (CW/SP/FP/LPG correctly separated) and RM (light fixtures stopped from routing as CW).
W-TE-DISC — IFCtoERP correctly assigns discipline from elements_meta for TE:
After 00t fix,
_import_joint_piece_types.disciplinebreakdown for Terminal matcheselements_metasource counts: IfcPipeSegment CW≥619, SP≥455, FP≥2672, LPG≥75. No IfcFlowTerminal rows assigned CW when element_type contains "light"/"lamp"/"fixture". TE routing topology groups split correctly by discipline (not all collapsed to CW).
00q safe to proceed?
- For TE: YES.
elements_meta.disciplineresolves CW/SP/FP/LPG. G3 fix required to populateAD_Org_IDcorrectly in_import_joint_piece_types. G1 schema extension required. - For RM: PARTIAL. FP, ACMV, ELEC, SPRINKLER_HEAD resolved. CW/SP pipes blocked by G2. RM can proceed for typed-class disciplines (FP=6 elements, ACMV=2081 elements) but CW/SP pipe recipes will be inaccurate until G2 is resolved at the source model level.
00q prerequisite: G1 (schema) + G3 (discipline read) must be fixed before writing ROUTE lines for ELEC/ACMV/CW/SP/GAS. Otherwise the staging table cannot carry AD_Org_ID per row and the single-source-of-truth architecture is broken.
12h. C_BPartner Catalog Segregation — RE vs CO
C_BPartner gates which product catalog is visible per building category. The segregation is at the building product_category level (RE / CO), not per individual building.
Two catalog tiers:
| Building category | C_BPartner reference | Buildings now | Buildings future |
|---|---|---|---|
| RE (Residential) | Duplex |
SH, DX, FK, IN… | any RE harvest |
| CO (Commercial) | HospitalAuckland (seed) |
— | TE, Hospital when onboarded |
| Universal | NULL | SPRINKLER, LIGHT… | any product valid in both |
Rule: Every RE building compilation ONLY places products where
C_BPartner_ID = Duplex OR C_BPartner_ID IS NULL.
Every CO compilation ONLY places products where
C_BPartner_ID = (CO reference) OR C_BPartner_ID IS NULL.
DX is the reference catalog for RE — products seeded from DX IFC carry C_BPartner_ID = Duplex.
When TE and Hospital are harvested and onboarded as CO, their products carry C_BPartner_ID = HospitalAuckland
(or a new consolidated CO BPartner to be decided at onboarding time).
Schema — already live (DV038):
-- C_BPartner table: Value = 'Duplex', 'HospitalAuckland', 'Terminal', 'SampleHouse'
-- M_Product.C_BPartner_ID INTEGER REFERENCES C_BPartner(C_BPartner_ID)
-- NULL = universal (placed by any building category)
Schedule lookup with BPartner filter (to be implemented in SpaceScheduleDAO):
-- :building_bpartner_id resolved from building product_category:
-- RE → C_BPartner.Value='Duplex' → ID=1
-- CO → C_BPartner.Value='HospitalAuckland' → ID=2
SELECT s.mep_product_id, s.placement_rule, s.host_surface, ...
FROM ad_space_type_mep_bom s
JOIN M_Product p ON p.product_id = s.mep_product_id
WHERE s.space_type_id = :space_type
AND (p.C_BPartner_ID = :building_bpartner_id OR p.C_BPartner_ID IS NULL)
Migration (DONE S160):
DV051_m_product_bpartner.sql— UPDATE M_Product.C_BPartner_ID (applied):- RE generative fixtures from DX IFC →
Duplex(ID=1): FRIDGE, OUTLET_20A, OUTLET_GFCI, OUTLET, CEILING_FAN, EXHAUST_FAN, FLOOR_TRAP, SINK, SWITCH, TOILET, WASHING_TAP, AIRCON_POINT - Universal (both RE and CO) → NULL: SPRINKLER, LIGHT, SUPPLY_DIFFUSER, DATA_POINT, EMERGENCY_LIGHT
- CO-specific →
HospitalAuckland(ID=2): (none yet — populated at TE/Hospital onboarding)
See DuplexAnalysis.md §C_BPartner for catalog table and RE/CO tier definitions.
Test spec — Witness W-BPARTNER-RE:
Test class: BPartnerCatalogTest (DAGCompiler contract tests)
Witness: W-BPARTNER-RE
Claim: Every generative element in an RE building output has
C_BPartner_ID = Duplex OR C_BPartner_ID IS NULL.
No CO-only product appears in SH or DX compilation.
Method:
1. Open samplehouse.db and duplex.db output.
2. For each GENERATIVE guid (guid LIKE '%_MD_%'), join to M_Product via element_ref.
3. Assert C_BPartner_ID IN (1 /*Duplex*/, NULL) for every row.
4. Assert count(generative) > 0 (schedule ran).
Counterpart W-BPARTNER-CO: same logic for CO buildings once onboarded.
Status (S160 DONE): DV051 applied. BPartnerCatalogTest 4/4 PASS. SH 9/9, DX 9/9. SpaceScheduleDAO BPartner filter: next step when CO buildings (TE/Hospital) are onboarded.
§12 — RTree Viewer Strategy: Impact on DAGCompiler / ERPtoDB Pipeline (S185)¶
S197 Update: The viewer has shifted to Direct DB Streaming (S195-S197). Baking, library.blend linking, and the Stingy Mesh Loader described below are superseded. The geometry_hash_redirect table schema remains valid but its application path (library.blend linking) is replaced by direct BLOB tessellation from component_library.db. See RTree.md §Direct Stream. DAGCompiler and ERPtoDB remain unaffected — the compilation pipeline is geometry-agnostic (§6.12.1 Compilation Isolation Invariant).
§12.1 Context¶
The RTree viewer (see RTree.md) replaces the GN-based Blender loader as the primary viewer for federation-scale models. It introduces runtime geometry resolution patterns that interact with the existing pipeline at two points:
- Geometry Hash Redirect —
component_library.db → geometry_hash_redirectswaps deprecated mesh hashes for canonical ones at viewer load time, with optional rotation correction. - Stingy Mesh Loader — links meshes from
library.blendon demand, scoped to the active building and viewport, never loading the full model.
§12.2 What Does NOT Change¶
| Pipeline stage | Impact |
|---|---|
IFC extraction (IFCtoBOM) |
None. Extracted DBs are read-only sources of truth. Geometry hashes and rotations are faithful to the source IFC. |
| DAGCompiler (BOM compilation) | None. DAGCompiler reads ERP.db and component_library.db for BOM rules. It never reads elements_rtree or geometry_hash_redirect. The compilation pipeline is geometry-agnostic. |
| ERPtoDB / WriteStage | None. output.db is compiled from BOM data, not from viewer state. The RTree viewer reads output.db — it never writes to it. |
component_library.db schema |
Additive only. geometry_hash_redirect table added (3 new columns: rotation_x/y/z_correction). Existing tables (component_geometries, surface_styles) untouched. |
library.blend |
Untouched. The redirect table points to meshes already in library.blend. No re-bake needed for redirect changes. |
§12.3 What DOES Change — Viewer-Only¶
| Change | Scope | Description |
|---|---|---|
| Hash redirect resolution | Viewer runtime | Stingy loader checks geometry_hash_redirect before linking mesh from library.blend. Maps deprecated → canonical hash. |
| Rotation correction | Viewer runtime | When a mesh was redirected and the source IFC stored a compensating rotation (e.g. Revit sprinklers modeled inverted with rotation_x = π), the loader applies a correction from the redirect table. Guard: only applied when the element's stored rotation matches the expected deprecated value (within 0.01 rad). |
| Outliner organisation | Viewer runtime | Loaded meshes grouped into per-discipline collections (Loaded_{building}_{DISC}) instead of per-batch. |
| Auto clip | Viewer runtime | clip_end auto-set proportional to model extent on preview load. |
§12.4 The Boundary¶
IFC → extraction → extracted.db → sandbox builder → sandbox.db
↓
component_library.db ──→ RTree viewer (Blender)
(geometry_hash_redirect) ↑
library.blend
DAGCompiler → ERP.db → output.db ─────────────────→ RTree viewer (read-only)
The redirect table lives in component_library.db — the same DB that stores
component_geometries (mesh BLOBs) and surface_styles. It is a library
concern, not a compilation concern. DAGCompiler never reads it.
The viewer reads from two sources:
- *_extracted.db or sandbox.db — elements, transforms, rtree index
- component_library.db — redirect rules, mesh BLOBs (for full load path)
- library.blend — pre-baked meshes (for stingy load path)
No viewer action writes back to any pipeline DB.
§12.5 Risk Assessment¶
| Risk | Likelihood | Mitigation |
|---|---|---|
| Redirect table has wrong correction values | Low | geometry_redirect.py auto-computes corrections by comparing dominant rotations between deprecated and canonical hashes. Dry-run mode shows planned corrections before commit. |
| Re-extraction overwrites hash in extracted DB | None | Extracted DBs are never modified by the viewer. Re-extraction produces fresh data from source IFC. The redirect table handles discrepancies at runtime. |
| DAGCompiler reads redirect table | None | DAGCompiler connects to ERP.db and component_library.db for component_geometries only. It does not query geometry_hash_redirect. |
| Rotation correction applied to wrong element | Very low | Guard: correction only fires when abs(element_rotation - expected_deprecated_rotation) < 0.01 rad. Elements already at correct orientation pass through untouched. |
§12.6 Admin Tool¶
tools/geometry_redirect.py — back-office CLI for managing redirects.
- Scans all
*_extracted.dbto discover duplicate hashes per element type - Shows vertex count, instance count, dominant rotation per hash
- Auto-computes rotation correction when canonical and deprecated hashes differ
- Writes redirect + correction to
component_library.db - Supports
--list,--remove,--undofor housekeeping - Requires
--confirmfor mutations; default is dry-run
See DATA_MODEL.md §4 (BlendMeshResolver) for the redirect table schema and diagnostic procedure.
Copyright (c) 2025-2026 Redhuan D. Oon. MIT Licensed.