Duplex Mirror Analysis — IFC2x3_Duplex Forensics¶
Foundation: BBC · DATA_MODEL · BIM_COBOL · MANIFESTO · TestArchitecture
Spec alignment (2026-03-18): DX BOM uses centroid-floorMin offsets — same
tack convention drift as SH and TE. Must implement BOMBasedCompilation.md §4
(parent LBD to child LBD, BUFFER lines, SUM invariant). Code changes spec in
ACTION_ROADMAP.md §Pre-Code Specs.
Building Geometry¶
| Axis | Min | Max | Extent | Half |
|---|---|---|---|---|
| X | -0.390 | 8.825 | 9.215m | 4.607 |
| Y | -22.183 | 4.383 | 26.565m | 13.283 |
| Z | -1.250 | 6.635 | 7.885m | 3.943 |
Party wall: Basic Wall:Party Wall - CMU Residential Unit Dimising Wall
spans X = [4.125, 4.675], center X = 4.400.
The mirror plane is at X = 4.4 (world coords), running along Y (depth). Elements are side-by-side in X, not Y.
Partition Algorithm¶
Three-tier partition based on element AABB vs mirror line:
A-side │ party wall │ B-side
◄───── max_x ≤ 4.4 ─── │ SPANS 4.4 │ ──── min_x ≥ 4.4 ─────►
553 elements │ 55 shared │ 491 elements
Tier 1 — SPANNING: Element AABB crosses the mirror line
(min_x < mirror_x AND max_x > mirror_x).
These are shared infrastructure: party walls, full-width exterior walls,
cross-unit plumbing risers, conduit runs.
Tier 2 — PAIRED: For elements entirely on one side, group by
(M_Product_ID, storey). Per group, min(A_count, B_count) elements
are paired — they have counterparts on the other side.
Tier 3 — EXCESS: Per group, |A_count - B_count| elements have
no mirror counterpart. These are unique plumbing fixtures, extra
fittings from asymmetric routing. They go into SHARED.
Partition Results¶
| Category | Count | What |
|---|---|---|
| Half-unit (paired per side) | 485 | min(A, B) per product per storey |
| Spanning (crosses mirror) | 55 | Party walls, exterior walls, risers |
| Excess (unmatched) | 74 | Extra elbows, pipes, PVC bends |
| SHARED total | 129 | Spanning + Excess |
Verification: 485 × 2 + 129 = 1099 ✓
Stored: 485 (half-unit) + 129 (shared) = 614 lines in DX_BOM.db Produced: 485 (A) + 485 (mirror) + 129 (shared) = 1099 elements
Per-Storey Breakdown¶
| Storey | Paired/side | Excess | Spanning |
|---|---|---|---|
| Level 1 | 237 | 59 | (in total) |
| Level 2 | 231 | 14 | |
| Roof | 4 | 1 | |
| T/FDN | 1 | 0 | |
| Unknown | 12 | 0 | |
| Total | 485 | 74 | 55 |
(Note: per-product-per-storey grouping gives 485 paired vs 487 per-product-only. The 2 difference is from products appearing in multiple storeys with different balance.)
Structural Symmetry (ARC) — Perfect Mirror¶
| Class | L1 A-ctr-B | L2 A-ctr-B |
|---|---|---|
| IfcWall | 8-4-9 | 10-4-11 |
| IfcDoor | 3-0-3 | 4-0-4 |
| IfcWindow | 2-0-2 | 9-0-9 |
| IfcFurnishingElement | 18-2-21 | 10-0-10 |
| IfcSlab | 3-4-3 | 5-0-5 |
Structural elements have near-perfect count symmetry. Center elements are party wall / full-width elements.
MEP Symmetry — Paired With Trunk¶
| Class | L1 A-ctr-B | L2 A-ctr-B |
|---|---|---|
| IfcFlowFitting | 83-57-120 | 31-17-46 |
| IfcFlowSegment | 68-30-65 | 96-41-126 |
| IfcFlowTerminal | 29-3-30 | 18-3-22 |
| IfcFlowController | 7-0-7 (Unk) |
MEP has a large center cluster (party wall trunk — shared risers and drain stacks). The slight L/R count differences (e.g. 83 vs 120 fittings) are from asymmetric plumbing routing in the IFC model.
Excess Element Inventory¶
| Product | A-side | B-side | Excess | Type |
|---|---|---|---|---|
| FITTING_ELBOW_GENERIC | 115 | 105 | 10 | MEP |
| PIPE_MECHANICAL_33MM | 89 | 80 | 9 | MEP |
| PIPE_PVC_33MM | 11 | 2 | 9 | MEP |
| FITTING_BEND_PVC_DWV | 10 | 2 | 8 | MEP |
| PIPE_COLD_WATER_25MM | 22 | 15 | 7 | MEP |
| CONDUIT_EMT_30MM | 7 | 2 | 5 | MEP |
| PIPE_WASTE_48MM | 32 | 27 | 5 | MEP |
| FITTING_TEE_GENERIC | 41 | 37 | 4 | MEP |
| CONDUIT_ELBOW_STEEL | 6 | 2 | 4 | MEP |
| PIPE_HOT_WATER_13MM | 16 | 13 | 3 | MEP |
| BEAM_W310X60 | 2 | 0 | 2 | STR |
| SLAB_FINISH_WOOD | 3 | 5 | 2 | STR |
| Others (< 2 each) | 6 | mixed | ||
| Total excess | 74 |
97% of excess is MEP (plumbing fittings and pipe segments).
Algorithm (Abstract)¶
partition(elements, mirror_x):
spanning = [e for e where e.min_x < mirror_x AND e.max_x > mirror_x]
a_side = [e for e where e.max_x <= mirror_x]
b_side = [e for e where e.min_x >= mirror_x]
half_unit = []
shared_excess = []
for each (product_id, storey) group:
a_group = a_side elements in this group
b_group = b_side elements in this group
paired = min(|a_group|, |b_group|)
# Take 'paired' elements from A-side → half-unit
half_unit += a_group[:paired]
# Excess from either side → shared
if |a_group| > paired:
shared_excess += a_group[paired:]
if |b_group| > paired:
shared_excess += b_group[paired:]
return half_unit, spanning + shared_excess
YAML carries the concrete: mirror_center, mirror_axis.
Java applies the abstract: partition → half-unit → pair.
Abstract Mirror Model — Industry-Wide¶
The mirror is defined by a plane (axis + position). The algorithm is axis-agnostic: works for X-mirror (DX party wall), Y-mirror (row houses), Z-mirror (stacked units), or rotated planes.
YAML Convention¶
composition:
type: MIRRORED_PAIR
pair_bom_id: DUPLEX_SET_STD
half_unit_bom_id: DUPLEX_SINGLE_UNIT_STD
mirror:
axis: X # partition axis: X, Y, or Z
position: 4.4 # world-coord position on that axis
rotation: 3.14159 # radians rotation applied to B-side (pi = 180°)
axis — which world axis the mirror plane is perpendicular to.
- X: party wall runs along Y (DX duplex, side-by-side units)
- Y: party wall runs along X (row houses, front-to-back mirror)
- Z: floor plate mirror (stacked inverted units — rare)
position — the partition coordinate on that axis (world coords).
Derived from the party wall center, not building AABB half-dims.
rotation — how the B-side is produced from A-side.
For simple mirror: π radians about the mirror axis.
For L-shaped or angled: could be π/2 (90°) or other values.
Future: L-Shaped and Multi-Axis¶
For L-shaped buildings with a 90° rotation:
composition:
type: ROTATED_PAIR
mirror:
axis: Z # rotate about Z (vertical)
position: [4.4, 11.0] # pivot point (X, Y world coords)
rotation: 1.5708 # pi/2 = 90°
For quad-plex (4 units from 1 master):
composition:
type: QUAD_MIRROR
mirrors:
- { axis: X, position: 4.4 }
- { axis: Y, position: 11.0 }
# Produces: original + X-mirror + Y-mirror + XY-mirror
BOM Model¶
BUILDING_DX_STD (BUILDING)
├── ... shared structural (MAKE/LEAF children) ...
└── DUPLEX_SET_STD (has children → recurses, category=PR)
├── UNIT_A → DUPLEX_SINGLE_UNIT_STD (LEAF, rot=0, dx=huA_offset)
└── UNIT_B → DUPLEX_SINGLE_UNIT_STD (LEAF, rot=π, dx=huB_offset)
Both reference the SAME half-unit BOM.
Walker recurses into it. Rotation applied to child offsets.
The pair container (DUPLEX_SET_STD) is a SET BOM with two LEAF
children pointing to the same half-unit BOM ID. The walker recurses
into the half-unit for each child, applying the rotation_rule from
the BOM line. Same BOM, different placement = mirror.
Code Design (Abstract)¶
// CompositionBomBuilder — generic, axis-agnostic
interface MirrorPartitioner {
/** Classify element: A_SIDE, B_SIDE, or SHARED */
Side classify(ExtractionElement e);
}
// PlaneMirrorPartitioner — partition by plane perpendicular to axis
class PlaneMirrorPartitioner implements MirrorPartitioner {
final String axis; // "X", "Y", or "Z"
final double position; // mirror plane position
Side classify(ExtractionElement e) {
double eMin = axisMin(e, axis); // e.minX, e.minY, or e.minZ
double eMax = axisMax(e, axis);
if (eMin < position && eMax > position) return SHARED; // spans
if (eMax <= position) return A_SIDE;
return B_SIDE;
}
}
Then the pairing (per product per storey, min(A,B) = paired, excess → shared) is completely independent of the axis.
Recompilation — IFC-Driven (S100-p128, 2026-03-30)¶
IFC-driven extraction (P125) + spatial container auto-discovery (P127) +
scope excludes (P128). BOM walk via CompileStage. 5/7, C9 WARN.
| Metric | Value |
|---|---|
| Elements | 215 (IFC-driven extraction) |
| Root BOM | BUILDING_DX_STD, origin=(-0.044, -35.392, -1.250) |
| Containers | 5 auto-discovered: TF, L1, UN, L2, RO (P127) |
| IFC Spaces | 11 IfcSpaces → 61 furniture elements in SET BOMs |
| Assemblies | 2 stair assemblies (3 children each, P129) |
| Reconciliation | delta=+0 (161 LEAFs + 54 paired = 215 vs 215 extracted) |
| C9 | 50 axis mismatches (was 111 before P128 scope fix) |
P128 scope excludes fix: Elements assigned to SET BOMs by ScopeBomBuilder are now excluded from CompositionBomBuilder mirror partition. This fixed the reconciliation delta (+50→0) — furniture was being double-counted in both SET BOMs and the half-unit.
GEO: SUMMARY 107 elements, 5671 pairs, worst=0.000mm, DRIFT=0
LMP Drift Check: 6 pass, 0 fail, 2 deferred
| § | Check | Verdict |
|---|---|---|
| §1 | Input=Output | PASS |
| §2 | LOD400 | PASS |
| §3 | Compiler Only | PASS |
| §6 | Output Path | PASS |
| §7 | Separate From Input | PASS |
| §8 | Visual Fidelity | PASS (GEO DRIFT=0) |
| §4, §9 | Openings, Orientation | deferred (no proof aggregate) |
C9 WARN (50 mismatches): Rank-match artifact documented in §Resolved #5 below. Reduced from 89→50 after P128 removed furniture from half-unit pairing.
BOM Compilation Model (S147, 2026-04-05)¶
The duplex is compiled entirely from ERP BOM primitives — m_bom + m_bom_line
(iDempiere Bill of Materials). No intermediate geometry representation. The BOM
IS the spatial model.
BUILDING_DX_STD (BUILDING) ← the order: "build this duplex"
├─ DX_TF_STR (FLOOR) 14 children ← foundation: footings, ground slab
├─ DX_L1_STR (FLOOR) 28 children ← Level 1 structure: walls, slabs, beams
├─ DX_L2_STR (FLOOR) 5 children ← Level 2 structure
├─ DX_RO_STR (FLOOR) 4 children ← Roof structure
├─ DX_ROOM_L1 (FLOOR) 5 rooms ← Room index: points to SET BOMs
│ ├─ DX_A102_SET (SET) 5 → 5 instances ← A-side living: sofa, tables, lamp
│ ├─ DX_A103_SET (SET) 5 → 15 instances ← A-side kitchen: 8 base + 4 upper cabinets + counter
│ ├─ DX_A104_SET (SET) 1 → 1 instance ← A-side pantry: vanity cabinet
│ ├─ DX_B102_SET (SET) 5 → 5 instances ← B-side living (rot=π of A)
│ └─ DX_B103_SET (SET) 5 → 15 instances ← B-side kitchen (rot=π of A)
├─ DX_ROOM_L2 (FLOOR) 6 rooms ← Level 2 rooms: bedrooms, bathroom
├─ DUPLEX_SINGLE_UNIT_STD (FLOOR) 51 children ← half-unit: all per-unit elements
│ └─ DUPLEX_SET_STD (SET) 2 → 2 instances ← stair assembly (stringers + railings)
├─ FLOOR_SLAB_GF (ASSEMBLY) ← ground floor slab (empty stub)
├─ FLOOR_SLAB_L2 (ASSEMBLY) ← Level 2 slab (empty stub)
└─ ROOF_ASSEMBLY (ASSEMBLY) ← roof (empty stub)
25 BOMs, 169 lines, 189 instances. The gap between lines and instances is
the verb expansion — LINE:X:0.012,1.772,2.772,3.772 (4 explicit positions
for 4 upper cabinets) and LINE_MULTI:X:0.76,1.76,2.76,3.76;X:0.00,1.00,2.00,3.76
(8 base cabinets in two rows). VerbFactorizer compresses 15 elements into 5 lines.
Half-unit walk: DUPLEX_SINGLE_UNIT_STD is walked twice — once with
IDENTITY (A-side) and once with MIRROR:X (B-side, rot=π). All 51 children
inherit the transform. The B-side kitchen cabinets, stair, walls all get their
positions from the same BOM data — only the sign flips.
Discipline separation: 904 MEP elements (IfcFlowSegment, IfcFlowTerminal,
IfcFlowFitting, IfcFlowController) excluded from BOM by §6.12.1. Confirmed by
SPATIAL-REPORT: missing_summary: disc_excluded=904 not_in_bom=0. These belong
in the DISC path (IFCtoERP), not the ARC BOM.
Logging proof: grep BOM-SUMMARY logs/*ifctobom*.log shows this tree.
grep SPATIAL-REPORT logs/pipeline_DX*.log shows 0 wall outliers, 15 furniture
GUID-order cosmetic mismatches. grep LOD-ROTATE logs/pipeline_DX*.log confirms
51 B-side meshes rotated 180°.
Rotation Center Proof (S145, 2026-04-05)¶
The B-side half-unit is a 180° rotation of the A-side, NOT a mirror reflection. A duplex rotated 180° looks the same — front becomes back, left becomes right.
Establishing the center¶
The rotation center equals the building geometric center, which equals the MEP core centroid. All three independently confirm Y = -8.9 (IFC coords).
| Source | X | Y | Method |
|---|---|---|---|
| Building AABB center | 4.33 | -8.9 | (min + max) / 2 |
| MEP IfcFlowFitting centroid (220 elbows) | 4.34 | -8.93 | AVG(centroid) |
| Paired ARC window midpoints | 4.40 | -8.9 | (A_y + B_y) / 2 |
Proof: paired element midpoints¶
Four IfcWindow 2800x2410mm instances form two rotation pairs:
Pair 1: A guid=1l0GAJtRTFv8$zmKJOH4$e Y=-17.383
B guid=1l0GAJtRTFv8$zmKJOH4pU Y= -0.417
Midpoint Y = (-17.383 + -0.417) / 2 = -8.900 ← EXACT
Pair 2: A guid=1hOSvn6df7F8_7GcBWlSXO Y= -0.417
B guid=1hOSvn6df7F8_7GcBWlS_W Y=-17.383
Midpoint Y = (-0.417 + -17.383) / 2 = -8.900 ← EXACT
The IFC model is geometrically clean. Initial analysis reported 7m "slop" but this was a mis-pairing artifact: matching A1↔B2 instead of A1↔B1. Correct proximity-based pairing shows zero slop. The modelling is professional.
Pairing trap¶
When N elements of the same type exist per side, naive pairing by
(product_type, storey) can mis-pair. Correct pairing requires cross-axis
proximity: for X-axis rotation, sort candidates by Y distance from the
expected mirror position 2 * center_y - A_y.
Rotation formula¶
For rot=π about center (Cx, Cy):
B_world = 2 * C - A_world (component-wise)
In the BOM walker, offsets are relative to the half-unit LBD corner.
The CompositionBomBuilder compensates by shifting UNIT_B's anchor:
anchor_B = mirror_position(anchor_A) + 2 * half_unit_offset_center
Where half_unit_offset_center = max_leaf_offset / 2 (from BOM line dx/dy range,
NOT from element AABB maxX/maxY which includes element width).
GUID issue with factored LEAFs¶
Factored leaves (qty > 1 with verb expansion) produce multiple instances from a single BOM line. The GUID for each instance is generated from the line's ordinal + verb index. When the same BOM is walked for both UNIT_A and UNIT_B, the A_/B_ prefix distinguishes them, but the verb-expanded instance indices must be stable across both walks. This is verified by the existing unit prefix stack mechanism.
Hybrid symmetry (S145 finding)¶
The IFC model uses mixed placement — not a single global transform:
| Element class | Behaviour | Evidence |
|---|---|---|
| Exterior walls | Static — same Y on both sides | seq60: A_y=-17.38, B_y=-17.80 (midpoint=-17.59, not center) |
| Interior walls, doors | Rotated about center (-8.9) | seq70: A_y=-6.25, B_y=-11.67 (midpoint=-8.96) |
| Ceilings, slabs | Near-center with functional offset | seq10: midpoint=-9.99 (~1m from center = party wall gap) |
Root cause: Exterior walls are pinned to site coordinates (cladding must face out). Interior elements rotate around the MEP core. The ~700mm offset between the theoretical center (-8.9) and some element midpoints accounts for the party wall thickness / MEP chase.
Compiler consequence: Neither pure rot=π nor pure MIRROR:X handles all elements
correctly. Current approach uses MIRROR:X (negate mirror-axis offset only) as the
least-wrong single transform — keeps all elements inside the building envelope.
Per-element rotation would require classifying each BOM line as "rotates" vs "static",
which is a future enhancement requiring IFC placement analysis.
MEP walker implications¶
The shared half-unit BOM contains MEP elements (pipes, fittings, terminals).
When the walker applies rot=π to MEP leaf offsets, the shim anchor resolution
(§6.12.2) must also rotate — a shim host surface at (x, y) on A-side maps to
(2*Cx - x, 2*Cy - y) on B-side. Current shim matching uses extraction-DB
positions which are side-specific, so A-side shims resolve correctly but B-side
shims would need rotated host lookup. This is not a correctness objective now —
it establishes a compiler truth for the DISC engine to validate against ERP.db.
ARC/STR elements are the priority. MEP walk correctness is a robustness probe.
Resolved Issues¶
1. Element Count Gap: 1093 vs 1099 — FIXED (2026-03-14)¶
Pipeline produced 1093 elements instead of expected 1099. Gap = 6 elements.
Root cause: CompositionBomBuilder excluded ALL B-side elements (491),
but only 485 had A-side mirror partners. The 6 B-side excess elements were
excluded from structural BOMs but had no A-side counterpart to be mirrored from.
Fix: Changed B-side exclusion loop to only exclude paired B elements
(for i < paired instead of iterating all B). B-side excess now flows to
structural as shared, matching A-side excess behavior.
Result: 485×2 + 129 structural = 1099 ✓ (enbloc=walkthru, delta=0).
2. GUID Uniqueness — FIXED (2026-03-14)¶
PlacementCollectorVisitor: unit prefix stack ("A_", "B_") prepended toelementRefand auto-incrementing ordinal for mirrored elements.BuildingWriter: GUID suffix ("_A", "_B") based on elementRef prefix.
3. SH Regression — VERIFIED (2026-03-14)¶
SH 7/7 PASS, 58 enbloc=walkthru, 0 delta, 0 geometry divergences.
4. Walker Rotation — VERIFIED (2026-03-14)¶
DX full delta test: 13 IFC classes, all enbloc=walkthru counts match, 0 geometry divergences, Rule 8 PASS (all coordinates parent-relative).
5. C9 Axis Dimension — Matching Artifact (Not a Geometry Error)¶
C9 reports 89 wall/slab axis mismatches. Root cause: C9 matches elements by
position-sorted rank (ROW_NUMBER partitioned by ifc_class), not by GUID.
For mirrored buildings, elements near the party wall (X ≈ 4.4) have similar
positions, causing rank shuffles that pair different element types together.
Evidence: element counts match exactly (1099 ref = 1099 out), walker rotation
verified (§Resolved #4: 0 divergences), and the "mismatched" pairs show different
element names (e.g., ref Exterior Brick vs output Interior Partition).
Status: Non-issue. C9 matching needs GUID-based pairing to work correctly for MIRRORED_PAIR buildings. Filed as future enhancement — does not affect compilation correctness.
IFC-Driven Extraction (S100-p125 → p128)¶
Status: DONE. DX is fully IFC-driven since P125/P127/P128:
- Spatial containment: 11 IfcSpaces with 61 furniture elements (P125)
- Storey auto-discovery: 5 containers from extraction, no YAML storeys (P127)
- Scope excludes: SET BOM elements excluded from mirror partition (P128)
- Assembly BOMs: 2 stair assemblies from
rel_aggregates(P129) - YAML floor_rooms removed: Dead code since P125 (removed in P128)
Stair Assemblies (P129)¶
DX has 2 stair assemblies discovered from rel_aggregates:
- DX_UN_ASM_1, DX_UN_ASM_2: each 3 children (2 IfcMember stringers + 1 IfcStairFlight)
- Land on "Unknown" storey — correct IFC semantics (stairs span storeys)
- Railings excluded by composition pairing (in half-unit, not structural)
S145 Learning Points — Mirror vs Rotation (2026-04-05)¶
1. Duplex is rot=π, not mirror¶
MIRROR:X (negate X only) was wrong. The IFC model rotates the B-side 180° about the party wall axis — this negates both X and Y offsets. For AABBs, rot=π about center (cx, cy) is equivalent to mirror X about cx + mirror Y about cy. A pure axis mirror keeps the cross-axis unchanged, which placed interior elements at wrong Y positions (up to 17m drift on elements far from building center Y).
Fix: Walker negates both X and Y offsets under MIRROR:X. BOM builder reflects UNIT_B anchor on both axes (X about party wall, Y about building center). Half-extent signs flip on both axes for centroid computation.
2. Proximity pairing eliminates mis-pairing¶
Sort-and-zip pairing (sort A and B by cross-axis, zip by index) fails when multiple elements of the same product cluster at different positions. Example: 3 instances of WALL_INT_EW_124x2900, sorted by Y — A[0]↔B[0] was correct but A[1]↔B[2] and A[2]↔B[1] were swapped, causing 1.6–6m GUID drift.
Fix: ProximityMirrorPairer computes each A element's expected B
position under rot=π, then greedily assigns the nearest unmatched B element.
Interface MirrorPairer keeps the algorithm pluggable.
Result: 15/18 B walls at zero drift. 4 thin walls at 15mm (cluster rounding). The 3 exterior walls at 208mm were resolved in S146 — moved to SHARED (building BOM), not half-unit. See §3 below.
3. Envelope walls are building infrastructure, not half-unit (S146)¶
The IFC model uses three placement strategies: - Static envelope: exterior walls at same Y both sides (don't rotate) - Rotation about center: interior elements rotate 180° - Spanning: party wall, EW walls, slabs — shared across both units
S145 residual (208mm on 3 walls) was a partition error, not an irreducible residual. Envelope NS walls (417mm thick, spanning >80% of the building wall footprint) were incorrectly classified as A/B-side and paired into the half-unit. Under rot=π they shifted by half their thickness.
Fix (S146): CompositionBomBuilder now detects envelope walls after
Tier 1 classification: any IfcWall whose cross-axis extent exceeds 80% of
the spanning walls' footprint is reclassified A/B → SHARED. Result: 8
envelope walls (4 products × 2 sides) moved to building BOM with IDENTITY
placement. Zero drift. DX 8/8, SH 8/8.
⚠ WARNING — Historical regression pattern:
The shared + 2 half-units architecture was established in 514ee302 (DX-1,
2026-03-14) but never had envelope detection. The original 3-tier
partition only checked if an element's AABB spans the mirror line.
Envelope NS walls sit entirely on one side (X=[0,0.417] or X=[8.383,8.800])
so they were always classified A/B and paired. The BOM model was too large —
not abstract enough, not cascade-aware. Each session peeled another layer:
| Session | What broke | Root cause |
|---|---|---|
DX-1 514ee302 |
B-side excess excluded | Only paired B should be excluded |
S138 e40e705a |
B-side rooms not under pair container | Rotation didn't cascade |
S142 a14e5f6f |
MEP in composition BOM | MEP belongs to DISC path (IFCtoERP) |
S145 bf6cb1ee |
MIRROR:X was wrong | Duplex is rot=π (negate both axes) |
| S146 (this) | Envelope walls in half-unit | Site-pinned walls don't rotate |
The pattern: every fix assumed one uniform transform for all elements. The partition must separate what rotates from what doesn't before applying any transform.
4. Rotation confirmed: indistinguishable sides (S146)¶
The rot=π reconstruction produces output that is geometrically identical when the building is turned 180°. Stairs, stringers, railings, stairwell walls, and adjacent rooms all land at the correct positions. The two sides are indistinguishable — you cannot tell which side you are looking at.
This is the correct result: a duplex rotated 180° IS the same building.
The IFC may use different meshes per side (different geometry_hash for
A vs B stair flights), but the compiler reconstructs from BOM + rotation,
not from IFC meshes. The AABB positions and midpoints confirm exact
symmetry about center (4.4, -8.9).
Log evidence (TACK output): - Stair flight midpoint: X=4.400, Y=-8.900 — exact rotation center - Stairwell wall midpoint: X=4.400, Y=-8.900 — exact - A/B stair extents: 3.805m each — identical - All stair assembly elements correctly placed under MIRROR:X
Lesson: Do not use visual inference. The logs are the proof.
5. Log-first debugging¶
The mis-pairing was invisible to gate tests (8/8 PASS) and envelope checks
(all inside building AABB). Only element-by-element LEAF logging with actual
AABB coordinates (X=[min,max] Y=[min,max]) and comparison to IFC reference
revealed the 6m drift. The improved TACK log now shows:
- Transform state: IDENTITY vs MIRROR:X
- Half-extent sign: sign=+1 or sign=-1
- Actual AABB: X=[min,max] Y=[min,max] Z=[min,max]
- Mirror-aware containment check (eliminated 55 false OVERSHOOTs)
6. Zigzag partition axis — rot=π holds for non-straight party walls (S147)¶
The duplex party wall is not a straight line. Between the stair wells, the
partition zigzags inward on each side to provide stair clearance — a common
duplex design pattern. The A-side notches in at X=4.420 (wall 36_A), the
B-side notches in at X=5.160 (wall 87_B).
Proof: rot=π about building center (4.790, 13.283) verified: - A zigzag center: X=4.496, Y=13.253 - B zigzag center: X=5.084, Y=13.313 - (A + B) / 2 = (4.790, 13.283) — exact building center
The rotation axis holds despite the non-straight partition because rot=π is a global transform about the building center, not about the party wall itself. Each element is positioned independently via BOM tack offsets; the rotation negates both X and Y offsets under MIRROR:X (S145 fix). The zigzag walls are ordinary children of the half-unit BOM — no special-case geometry.
Code path: PlacementCollectorVisitor.onLeaf() reads mirrorAxisStack,
negates offsets via xySign = -1.0. CompositionBomBuilder.buildComposition()
classifies walls as A/B/SHARED. Zigzag walls are correctly classified A/B
(they sit entirely on one side of the building center X).
LOD rotation (S147): B-side LOD meshes now receive rot=π via
MeshBinder.bind() (rotate around mesh center, then translate to AABB).
Confirmed by LOD-ROTATE log: 51 B-side elements at rotZ=3.1416rad.
Stair flight, stringers, railings all rotated — visual orientation correct.
Logging: SPATIAL-REPORT (grep pipeline log) confirms 0 wall outliers
after identity-based matching. BOM-SUMMARY (grep IFCtoBOM log) shows the
BOM tree structure including the half-unit's 51 children.
S150/S151 Generative MEP Placement¶
What it does: The walker reads ad_space_type_mep_bom + ad_placement_offset
and synthesizes MEP devices (sprinklers, outlets, fridge, etc.) in rooms with
matching capabilities. Devices are NOT from the original IFC — they are compiled
from building code rules. Output has 329 elements (215 extracted + 114 generative).
Room classification (S151): ScopeBomBuilder.inferRoleFromContent() classifies
rooms from furniture names when IfcSpace name is a room number (A102, B203):
cabinet+counter→KITCHEN, vanity→BATHROOM, bed→BEDROOM, sofa→LIVING.
Generative device counts: 20 OUTLET, 15 SWITCH, 13 SPRINKLER, 11 LIGHT, 8 CEILING_FAN, 8 SUPPLY_DIFFUSER, 7 OUTLET_GFCI, 6 AIRCON_POINT, 6 OUTLET_20A, 5 EXHAUST_FAN, 5 FLOOR_TRAP, 5 SINK, 3 TOILET, 2 FRIDGE.
Logging Channels¶
| Channel | Grep pattern | What it shows |
|---|---|---|
| GENERATIVE ROOM | GENERATIVE.*ROOM |
Per-room: BOM ID, space type, anchor, AABB, device count |
| GENERATIVE PLACE | GENERATIVE.*PLACE |
Per-device: rule, anchor, discipline, position, IN/OUT verdict |
| GENERATIVE BREACH | GENERATIVE.*BREACH |
Devices outside room AABB — axis-by-axis diagnosis |
| GENERATIVE SUMMARY | GENERATIVE.*SUMMARY |
Total devices, rooms, breaches |
Known Findings (S151 — RESOLVED e09294f8)¶
Finding 1: Z-axis breach — FIXED. 13/114 breaches → 0. DV044 adds
default_ceiling_height_mm to ad_space_type (2700mm residential).
SpaceScheduleDAO.getCeilingHeightM() reads it; walker overrides rh when
bomAABB height < 2400mm. Logged as GENERATIVE CEILING_OVERRIDE.
Finding 2: LOD geometry — FIXED. SpaceScheduleDAO.getProductDimensions()
reads M_Product width/depth/height; walker uses half-extents for Placement AABB.
FRIDGE: 0.70×0.70×1.80m (was 0.10×0.10×0.10m). Logged as GENERATIVE AABB.
Geometry stubs: Products with no extraction geometry (SWITCH, OUTLET, FRIDGE,
AIRCON_POINT, CEILING_FAN, EXHAUST_FAN, DATA_POINT, EMERGENCY_LIGHT, OUTLET_20A,
OUTLET_GFCI) use box stub f7051d6c5f17ad77 (8 vertices) in component_library.db.
Products with extraction geometry (SPRINKLER, SINK, TOILET, LIGHT, FLOOR_TRAP,
SUPPLY_DIFFUSER) resolve via LIKE match to real IFC meshes.
Witness: MepRouteGeometryTest S15 — ceiling override + product AABB proof. 15/15 PASS. S14: 0 gaps across 27 space types.