Skip to content

fix: place IFC geometry in world coordinates and export GLB in metres, +Y up - #35

Merged
mahach666 merged 3 commits into
mainfrom
fix/ifc-world-placement
Nov 12, 2026
Merged

mahach666 merged 3 commits into
mainfrom
fix/ifc-world-placement

Conversation

@mahach666

Copy link
Copy Markdown
Contributor

Why

IfcModel.Open never applied ObjectPlacement, so every element was read at its local origin. On a real MEP model, GLB/OBJ exports showed a heap of fragments around 0,0,0 plus long strips fanning out in every direction (pipes extruded along global Z because the rotation of the extrusion Position was dropped). GLB was also written in millimetres and Z-up, while glTF requires metres and +Y up. IFC2x3 pipes and fittings (IfcFlowSegment, IfcFlowFitting, ...) were not read at all.

What changed

Reader (IfcModel.Open)

  • Resolves IfcLocalPlacement chains (PlacementRelTo) iteratively, relative to the WorldCoordinateSystem of the 3D Model context.
  • Applies IfcMappedItem: MappingTarget * MappingOrigin (IfcCartesianTransformationOperator 2D/3D, non-uniform scale, mirroring per IfcSecondProjAxis).
  • Applies the full IfcAxis2Placement3D of extruded/revolved solids, rotated profile positions and non-+Z extrusion directions; the first operand of boolean/clipping results honours a rotated Position (kept as FirstMesh).
  • Merges all items of the Body representation; reads IfcFaceBasedSurfaceModel and IfcShellBasedSurfaceModel.
  • Reads the length unit from IfcUnitAssignment (any SI prefix, conversion-based units). Units without an IfcLengthUnit value (e.g. cm) are converted to metres for geometry and storey elevations.
  • Reads IFC2x3 MEP flow elements and IFC4 IfcPipeFitting / IfcDuctFitting.
  • IfcDistributionPort position/direction come from its world placement (were always origin / +Z).
  • Placement or mapped-item cycles, and mapping nested deeper than 64 levels, throw InvalidDataException.

GLB export: metres, +Y up ((x, y, z) -> (x, z, -y)). Vertices are stored relative to the model centre, which is the root node translation, so georeferenced models keep millimetre precision in float32 (about 8 mm is lost at 100 km otherwise).

OBJ export: code unchanged; model units and Z-up, now with world-placed geometry.

Public API is unchanged (IfcTransform is internal). Behaviour changes are listed in CHANGELOG.md under [Unreleased].

Notes for reviewers

  • IfcElement.Solid / RevolvedSolid / BooleanResult keep their previous meaning (first body item in its own coordinates); only Mesh is world-placed. Writing a read model back therefore still places parametric solids without the element placement, as before.
  • When a swept solid's Position is translation-only, its mesh comes from the parametric solid's own ToMesh, so files written by IfcIO read back unchanged. This differs from strict IFC for non-+Z extrusion directions and off-origin revolution axes (marked shortcut: in code).
  • Plane-angle units are not applied: IfcModel's writer declares DEGREE but writes revolved angles in radians, so angles are still read as radians. Known existing issues, not addressed here: the writer emits Foot/Inch as METRE; the built-in HTML viewer does not recentre and uses 16-bit indices.
  • Not done here: sharing one glTF mesh per IfcRepresentationMap (instancing). Planned as a follow-up.

Testing

  • dotnet build IfcIO.slnx -c Release (warnings as errors): 0 warnings, 0 errors.
  • dotnet test tests/IfcIO.Tests/IfcIO.Tests.csproj -c Release: 146 passed.
  • New PlacementGeometryTests (14) use a synthetic IFC2X3 fixture: nested placements with rotation and offset, rotated extrusion + rotated profile, a representation map used three times (one mirrored), a clipped beam, WCS offset, ports, a placement cycle, mm/m/ft/cm units, and world vertex coordinates checked in GLB and OBJ output. All 13 tests present before the last review round fail on main.
  • Manually checked on a large private MEP model: element centres no longer cluster at the origin, overall extent is site-sized (hundreds of metres), and GLB and OBJ both render as a connected pipe network in IOViewer. Pipe heights were cross-checked against an independent computation from the raw IFC.

…, +Y up

IfcModel.Open ignored object placements, so every element was read at its
local origin and GLB/OBJ exports collapsed around 0,0,0.

Reader:
- resolve IfcLocalPlacement chains (PlacementRelTo) relative to the
  WorldCoordinateSystem of the 3D Model context
- apply IfcMappedItem MappingOrigin and MappingTarget
  (IfcCartesianTransformationOperator 2D/3D, non-uniform scale, mirroring)
- apply the full Position of extruded/revolved solids and rotated profile
  positions, including the first operand of boolean/clipping results
- merge all Body items; read IfcFaceBasedSurfaceModel and
  IfcShellBasedSurfaceModel
- read the length unit from IfcUnitAssignment (SI prefixes,
  conversion-based units)
- read IFC2x3 MEP flow elements and IFC4 pipe/duct fittings
- resolve IfcDistributionPort world position and direction
- throw InvalidDataException on placement or mapped item cycles

GLB export writes metres with +Y up, vertices relative to the model centre
carried as the root node translation to keep float precision at
georeferenced offsets. OBJ keeps model units and Z up.
Nested IfcMappedItems multiply geometry: a small file with a few levels of
representation maps, each holding many instances of the previous one,
expands into exponentially many vertices and exhausts memory. The depth
limit alone does not prevent this.

IfcModel.Open now charges every materialised representation item against
a budget of 1,000,000 + 256 vertices per STEP entity and throws
InvalidDataException when it is exceeded, so expansion stays linear in
the input size.
- Files whose length unit has no IfcLengthUnit value (e.g. centimetres)
  are read in metres, but parametric solids stayed in file units and the
  writer prefers them, so Open -> Save -> Open rescaled those elements.
  Parametric solids are no longer kept when geometry is rescaled; the
  metre mesh is written instead.
- With no reference direction, IfcFirstProjAxis defaults to (1,0,0), or
  (0,1,0) when the Z axis is (1,0,0). The fallback used (0,0,1) there,
  rotating and mirroring placements and mapped items with Z = +X.
@mahach666
mahach666 merged commit 8e63836 into main Nov 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant