From 7be98306b1b45b9703c96582f1d2bffd2e2717d0 Mon Sep 17 00:00:00 2001 From: mie Date: Thu, 12 Nov 2026 03:50:35 +0300 Subject: [PATCH 1/3] fix: place IFC geometry in world coordinates and export GLB in metres, +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. --- CHANGELOG.md | 20 + src/IfcIO/Exporters/IfcGlbExporter.cs | 71 ++- src/IfcIO/Geometry/IfcTransform.cs | 64 +++ src/IfcIO/IfcModel.cs | 582 ++++++++++++++++---- src/IfcIO/Schema/IfcEnums.cs | 13 + tests/IfcIO.Tests/PlacementGeometryTests.cs | 373 +++++++++++++ 6 files changed, 996 insertions(+), 127 deletions(-) create mode 100644 src/IfcIO/Geometry/IfcTransform.cs create mode 100644 tests/IfcIO.Tests/PlacementGeometryTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 131c52b..d7d5e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to the `IfcIO` library are documented in this file. +## [Unreleased] + +### Fixed +- **World placement of geometry read from IFC (`IfcModel.Open`)**: element meshes are now in world coordinates. Previously object placements were ignored and every element sat at its local origin. + - `IfcLocalPlacement` chains (`PlacementRelTo`) are resolved up to the root, relative to the `WorldCoordinateSystem` of the 3D `Model` representation context. + - `IfcMappedItem` applies `MappingOrigin` and `MappingTarget` (`IfcCartesianTransformationOperator` 2D/3D, uniform and non-uniform scale, mirroring). + - The full `IfcAxis2Placement3D` of extruded and revolved solids is applied (it was location only), as are rotated `IfcAxis2Placement2D` profile positions of extrusions and non-vertical extrusion directions. `IfcAxis1Placement` locations of revolution axes are read (they were always zero). + - The first operand of `IfcBooleanResult` / `IfcBooleanClippingResult` honours a rotated `Position`; such an operand is kept as `FirstMesh` instead of a misoriented `FirstSolid`. + - All items of the `Body` representation are merged (only the first item was read). `IfcFaceBasedSurfaceModel` and `IfcShellBasedSurfaceModel` are read. + - `IfcDistributionPort.Position` and `Direction` are the port's world placement (they were always the origin and +Z). +- **Length unit read from IFC**: `IfcModel.LengthUnit` comes from the project's `IfcUnitAssignment` (`IfcSIUnit` with any prefix, `IfcConversionBasedUnit`). It was always `Millimetre`. Units with no `IfcLengthUnit` value (for example centimetres) are converted to metres while reading, together with storey elevations. +- **MEP elements are read**: IFC2x3 `IfcFlowSegment`, `IfcFlowFitting`, `IfcFlowTerminal`, `IfcFlowController`, `IfcFlowMovingDevice`, `IfcFlowStorageDevice`, `IfcFlowTreatmentDevice`, `IfcEnergyConversionDevice`, `IfcDistributionChamberElement`, `IfcDistributionControlElement` and IFC4 `IfcPipeFitting`, `IfcDuctFitting` were skipped. + +### Changed +- **GLB export follows glTF 2.0 units and axes**: positions are written in metres (converted from `IfcModel.LengthUnit`) with +Y up (IFC `(x, y, z)` becomes `(x, z, -y)`). Vertices are stored relative to the centre of the model, and the root node carries that centre as `translation`, so georeferenced models keep millimetre precision in 32-bit floats. GLB files of millimetre models were previously 1000 times too large and lying on their side. +- OBJ export is unchanged: model length units, Z up, now with world-placed geometry. +- `IfcElement.Solid`, `RevolvedSolid` and `BooleanResult` of read elements still describe the first body item in its own coordinates (only the `Mesh` is world-placed). Writing a read model back therefore still places those solids without the element placement, as before. +- Malformed placement data (a `PlacementRelTo` cycle, an `IfcMappedItem` cycle or mapping nested deeper than 64 levels) makes `IfcModel.Open` throw `InvalidDataException`. +- For length units without an `IfcLengthUnit` value, only geometry and storey elevations are converted to metres; quantities and property values stay in file units. + ## [0.10.0] - 2026-11-11 ### Major Additions & Capabilities diff --git a/src/IfcIO/Exporters/IfcGlbExporter.cs b/src/IfcIO/Exporters/IfcGlbExporter.cs index 4f7e2c1..1d65e01 100644 --- a/src/IfcIO/Exporters/IfcGlbExporter.cs +++ b/src/IfcIO/Exporters/IfcGlbExporter.cs @@ -3,6 +3,7 @@ using System.Text.Json; using IfcIO.Geometry; using IfcIO.Model; +using IfcIO.Schema; namespace IfcIO.Exporters; @@ -69,18 +70,26 @@ public static void Write(IfcModel model, Stream output) } var rootChildNodeIndices = new List(); + double metresPerUnit = model.LengthUnit.MetresPerUnit(); - foreach (var storey in model.Storeys) + var storeyMeshes = model.Storeys + .Select(storey => (Storey: storey, Items: storey.Elements + .Select(elem => (Element: elem, Mesh: elem.GetEffectiveMesh())) + .Where(item => item.Mesh is { Vertices.Count: > 0, Indices.Count: > 0 }) + .Select(item => (item.Element, Mesh: item.Mesh!)) + .ToList())) + .ToList(); + + // Vertices are stored relative to the model centre, which becomes the root node translation: + // float32 positions at georeferenced offsets lose millimetres (about 8 mm at 100 km). + var origin = ComputeCentre(storeyMeshes.SelectMany(s => s.Items).Select(item => item.Mesh)); + + foreach (var (storey, items) in storeyMeshes) { var storeyChildNodes = new List(); - foreach (var elem in storey.Elements) + foreach (var (elem, mesh) in items) { - var mesh = elem.GetEffectiveMesh(); - if (mesh is null || mesh.Vertices.Count == 0 || mesh.Indices.Count == 0) - { - continue; - } int? matId = null; if (elem.Material is not null) @@ -104,7 +113,7 @@ public static void Write(IfcModel model, Stream output) matId = existingMat; } - int meshIndex = PackMesh(mesh, matId, binMs, bufferViews, accessors, gltfMeshes); + int meshIndex = PackMesh(mesh, origin, metresPerUnit, matId, binMs, bufferViews, accessors, gltfMeshes); int elemNodeIndex = gltfNodes.Count; gltfNodes.Add(new NodeDef @@ -133,6 +142,7 @@ public static void Write(IfcModel model, Stream output) { Name = model.ProjectName, Children = rootChildNodeIndices, + Translation = origin == IfcVector3.Zero ? null : ToGltf(origin, metresPerUnit), }); // Align BIN chunk to 4-byte boundary @@ -165,6 +175,7 @@ public static void Write(IfcModel model, Stream output) { var dict = new Dictionary { ["name"] = n.Name }; if (n.Mesh.HasValue) dict["mesh"] = n.Mesh.Value; + if (n.Translation is not null) dict["translation"] = n.Translation; if (n.Children is not null && n.Children.Count > 0) dict["children"] = n.Children; return dict; }).ToArray(), @@ -254,13 +265,20 @@ public static void Write(IfcModel model, Stream output) private static int PackMesh( IfcMesh mesh, + IfcVector3 origin, + double metresPerUnit, int? materialIndex, MemoryStream binMs, List bufferViews, List accessors, List gltfMeshes) { - var vertices = mesh.Vertices; + var vertices = new IfcVector3[mesh.Vertices.Count]; + for (int v = 0; v < vertices.Length; v++) + { + var p = mesh.Vertices[v] - origin; + vertices[v] = new IfcVector3(p.X * metresPerUnit, p.Z * metresPerUnit, -p.Y * metresPerUnit); + } var indices = mesh.Indices; // 1. Pack Positions (VEC3 float) @@ -270,7 +288,7 @@ private static int PackMesh( float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue; float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue; - var normals = new IfcVector3[vertices.Count]; + var normals = new IfcVector3[vertices.Length]; // Precompute face normals for (int i = 0; i <= indices.Count - 3; i += 3) @@ -278,7 +296,7 @@ private static int PackMesh( int i0 = indices[i]; int i1 = indices[i + 1]; int i2 = indices[i + 2]; - if (i0 < vertices.Count && i1 < vertices.Count && i2 < vertices.Count) + if (i0 < vertices.Length && i1 < vertices.Length && i2 < vertices.Length) { var v0 = vertices[i0]; var v1 = vertices[i1]; @@ -291,7 +309,7 @@ private static int PackMesh( } Span floatBytes = stackalloc byte[4]; - for (int v = 0; v < vertices.Count; v++) + for (int v = 0; v < vertices.Length; v++) { var p = vertices[v]; float px = (float)p.X; @@ -320,7 +338,7 @@ private static int PackMesh( BufferView = posBvIdx, ByteOffset = 0, ComponentType = ComponentTypeFloat, - Count = vertices.Count, + Count = vertices.Length, Type = "VEC3", Min = [minX, minY, minZ], Max = [maxX, maxY, maxZ] @@ -330,7 +348,7 @@ private static int PackMesh( AlignStream(binMs, 4); int normOffset = (int)binMs.Length; - for (int v = 0; v < vertices.Count; v++) + for (int v = 0; v < vertices.Length; v++) { var n = normals[v].Normalized(); BinaryPrimitives.WriteSingleLittleEndian(floatBytes, (float)n.X); @@ -351,14 +369,14 @@ private static int PackMesh( BufferView = normBvIdx, ByteOffset = 0, ComponentType = ComponentTypeFloat, - Count = vertices.Count, + Count = vertices.Length, Type = "VEC3" }); // 3. Pack Indices (uint16 or uint32) AlignStream(binMs, 4); int idxOffset = (int)binMs.Length; - bool useShort = vertices.Count < 65535; + bool useShort = vertices.Length < 65535; if (useShort) { @@ -411,6 +429,26 @@ private static int PackMesh( return meshIdx; } + /// IFC is Z-up in model length units; glTF is Y-up in metres: (x, y, z) -> (x, z, -y). + private static double[] ToGltf(IfcVector3 p, double metresPerUnit) => + [p.X * metresPerUnit, p.Z * metresPerUnit, -p.Y * metresPerUnit]; + + /// Centre of the bounding box of all vertices, in model coordinates (zero when there are none). + private static IfcVector3 ComputeCentre(IEnumerable meshes) + { + var min = new IfcVector3(double.PositiveInfinity, double.PositiveInfinity, double.PositiveInfinity); + var max = new IfcVector3(double.NegativeInfinity, double.NegativeInfinity, double.NegativeInfinity); + foreach (var mesh in meshes) + { + foreach (var p in mesh.Vertices) + { + min = new IfcVector3(Math.Min(min.X, p.X), Math.Min(min.Y, p.Y), Math.Min(min.Z, p.Z)); + max = new IfcVector3(Math.Max(max.X, p.X), Math.Max(max.Y, p.Y), Math.Max(max.Z, p.Z)); + } + } + return min.X <= max.X ? (min + max) * 0.5 : IfcVector3.Zero; + } + private static void AlignStream(MemoryStream ms, int alignment) { int rem = (int)(ms.Length % alignment); @@ -458,6 +496,7 @@ private sealed class NodeDef public required string Name { get; init; } public int? Mesh { get; init; } public List? Children { get; init; } + public double[]? Translation { get; init; } } private sealed class MaterialDef diff --git a/src/IfcIO/Geometry/IfcTransform.cs b/src/IfcIO/Geometry/IfcTransform.cs new file mode 100644 index 0000000..4079db3 --- /dev/null +++ b/src/IfcIO/Geometry/IfcTransform.cs @@ -0,0 +1,64 @@ +namespace IfcIO.Geometry; + +/// +/// Affine 3D transform: p' = X * p.X + Y * p.Y + Z * p.Z + Origin. +/// The axis columns may be scaled, sheared or mirrored (IFC placements, mapping operators, extrusion shear). +/// +internal readonly record struct IfcTransform(IfcVector3 X, IfcVector3 Y, IfcVector3 Z, IfcVector3 Origin) +{ + private const double Tolerance = 1e-9; + + public static readonly IfcTransform Identity = new(IfcVector3.UnitX, IfcVector3.UnitY, IfcVector3.UnitZ, IfcVector3.Zero); + + public static IfcTransform Scale(double factor) => + new(IfcVector3.UnitX * factor, IfcVector3.UnitY * factor, IfcVector3.UnitZ * factor, IfcVector3.Zero); + + public static IfcTransform Translation(IfcVector3 offset) => + new(IfcVector3.UnitX, IfcVector3.UnitY, IfcVector3.UnitZ, offset); + + public double Determinant => IfcVector3.Dot(X, IfcVector3.Cross(Y, Z)); + + /// True when the linear part is the identity (the transform is at most a translation). + public bool IsTranslationOnly => Near(X, IfcVector3.UnitX) && Near(Y, IfcVector3.UnitY) && Near(Z, IfcVector3.UnitZ); + + public bool IsIdentity => IsTranslationOnly && Near(Origin, IfcVector3.Zero); + + public IfcVector3 Apply(IfcVector3 p) => (X * p.X) + (Y * p.Y) + (Z * p.Z) + Origin; + + public IfcVector3 ApplyVector(IfcVector3 v) => (X * v.X) + (Y * v.Y) + (Z * v.Z); + + /// Returns the transform that applies first and then this transform. + public IfcTransform Multiply(IfcTransform inner) => + new(ApplyVector(inner.X), ApplyVector(inner.Y), ApplyVector(inner.Z), Apply(inner.Origin)); + + /// + /// Transforms the mesh vertices. Triangle winding is reversed when the transform mirrors, + /// so faces keep pointing outwards. + /// + public IfcMesh ApplyTo(IfcMesh mesh) + { + if (IsIdentity) return mesh; + + var vertices = new IfcVector3[mesh.Vertices.Count]; + for (int i = 0; i < vertices.Length; i++) + { + vertices[i] = Apply(mesh.Vertices[i]); + } + + IReadOnlyList indices = mesh.Indices; + if (Determinant < 0) + { + var flipped = indices.ToArray(); + for (int i = 0; i + 2 < flipped.Length; i += 3) + { + (flipped[i + 1], flipped[i + 2]) = (flipped[i + 2], flipped[i + 1]); + } + indices = flipped; + } + + return IfcMesh.Create(mesh.Id, mesh.Name, vertices, indices); + } + + private static bool Near(IfcVector3 a, IfcVector3 b) => + Math.Abs(a.X - b.X) <= Tolerance && Math.Abs(a.Y - b.Y) <= Tolerance && Math.Abs(a.Z - b.Z) <= Tolerance; +} diff --git a/src/IfcIO/IfcModel.cs b/src/IfcIO/IfcModel.cs index d274d8b..291927b 100644 --- a/src/IfcIO/IfcModel.cs +++ b/src/IfcIO/IfcModel.cs @@ -1627,6 +1627,17 @@ public static IfcModel Open(TextReader reader, string? modelName = null) model.BuildingName = buildingEntity.GetString(2) ?? model.BuildingName; } + // 1b. Units: geometry is kept in model length units. A unit without an IfcLengthUnit + // counterpart (e.g. centimetres) is converted to metres while reading. + double? fileMetresPerUnit = ParseLengthUnit(step, projectEntity); + double geometryScale = 1.0; + if (fileMetresPerUnit.HasValue) + { + var knownUnit = ToLengthUnit(fileMetresPerUnit.Value); + model.LengthUnit = knownUnit ?? IfcLengthUnit.Metre; + if (knownUnit is null) geometryScale = fileMetresPerUnit.Value; + } + // 2. Parse Storeys var storeyMap = new Dictionary(); foreach (var sEnt in step.OfType("IFCBUILDINGSTOREY")) @@ -1634,7 +1645,7 @@ public static IfcModel Open(TextReader reader, string? modelName = null) var storey = new IfcBuildingStorey { Name = sEnt.GetString(2) ?? $"Level {sEnt.Id}", - Elevation = sEnt.GetReal(9) ?? 0.0, + Elevation = (sEnt.GetReal(9) ?? 0.0) * geometryScale, GlobalId = sEnt.GetString(0), }; model.Storeys.Add(storey); @@ -1808,7 +1819,11 @@ public static IfcModel Open(TextReader reader, string? modelName = null) } } - // 6. Parse Elements & Meshes + // 6. Parse Elements & Meshes (meshes are resolved into world coordinates) + var geometry = new GeometryReadContext( + step, + geomToMaterialMap, + IfcTransform.Scale(geometryScale).Multiply(ParseWorldCoordinateSystem(step))); var defaultStorey = model.Storeys[0]; var allElementsMap = new Dictionary(); @@ -1897,7 +1912,8 @@ public static IfcModel Open(TextReader reader, string? modelName = null) int? shapeDefId = entity.GetRef(6); if (shapeDefId.HasValue) { - var mesh = ParseGeometry(step, shapeDefId.Value, name, geomToMaterialMap, out var mat, out var solid, out var revSolid, out var boolResult); + var placement = ResolveObjectPlacement(geometry, entity.GetRef(5)); + var mesh = ParseGeometry(geometry, shapeDefId.Value, name, placement, out var mat, out var solid, out var revSolid, out var boolResult); if (mesh is not null) { element.Mesh = mesh; @@ -2484,18 +2500,10 @@ openElem is IfcOpeningElement opening && string portName = portEnt.GetString(2) ?? "Port"; string? desc = portEnt.GetString(3); string? sysType = portEnt.GetString(4); - int? plId = portEnt.GetRef(5); - var pos = ParsePlacement3D(step, plId); - var dir = IfcVector3.UnitZ; - if (plId.HasValue) - { - var plEnt = step.Find(plId.Value); - if (plEnt is not null && plEnt.Type.Equals("IFCAXIS2PLACEMENT3D", StringComparison.OrdinalIgnoreCase)) - { - int? dirId = plEnt.GetRef(1); - if (dirId.HasValue) dir = ParseDirection(step, dirId); - } - } + var portPlacement = ResolveObjectPlacement(geometry, portEnt.GetRef(5)); + var pos = portPlacement.Origin; + var dir = portPlacement.Z.Normalized(); + if (dir == IfcVector3.Zero) dir = IfcVector3.UnitZ; string? rawFlow = portEnt.GetRaw(7); var flow = IfcFlowDirection.NotDefined; @@ -2771,11 +2779,33 @@ private static int WriteRevolvedAreaSolid(StepWriter w, IfcRevolvedAreaSolid sol return w.Add("IFCREVOLVEDAREASOLID", $"{StepWriter.Ref(profileId)},{StepWriter.Ref(solidPlacementId)},{StepWriter.Ref(axisPlacementId)},{StepWriter.Real(solid.Angle)}"); } + /// State shared while reading product geometry: caches for placements and mapped representations. + private sealed class GeometryReadContext(StepFile step, Dictionary geomToMaterialMap, IfcTransform root) + { + public StepFile Step { get; } = step; + public Dictionary GeomToMaterialMap { get; } = geomToMaterialMap; + + /// Unit scale applied after the world coordinate system of the model context. + public IfcTransform Root { get; } = root; + public Dictionary Placements { get; } = []; + public Dictionary MappedRepresentations { get; } = []; + public HashSet MapsInProgress { get; } = []; + } + + private const int MaxMappingDepth = 64; + + /// Parametric form of the first body item, in the item's own coordinates. + private readonly record struct ParametricBody(IfcExtrudedAreaSolid? Solid, IfcRevolvedAreaSolid? Revolved, IfcBooleanResult? Boolean); + + /// + /// Reads the product shape into one world-space mesh (all items of the body representation merged). + /// Parametric outputs describe the first item in its own coordinates, as before (no placement applied). + /// private static IfcMesh? ParseGeometry( - StepFile step, + GeometryReadContext ctx, int shapeDefId, string meshName, - Dictionary geomToMaterialMap, + IfcTransform placement, out IfcMaterial? material, out IfcExtrudedAreaSolid? solid, out IfcRevolvedAreaSolid? revolvedSolid, @@ -2785,103 +2815,433 @@ private static int WriteRevolvedAreaSolid(StepWriter w, IfcRevolvedAreaSolid sol solid = null; revolvedSolid = null; booleanResult = null; - var shapeDef = step.Find(shapeDefId); + var shapeDef = ctx.Step.Find(shapeDefId); if (shapeDef is null) return null; - var repIds = shapeDef.GetRefList(2); - foreach (int repId in repIds) + var reps = shapeDef.GetRefList(2) + .Select(id => ctx.Step.Find(id)) + .OfType() + .Where(r => r.Type.Equals("IFCSHAPEREPRESENTATION", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // 'Body' first; other representations (Axis, Box, FootPrint...) only when no body geometry is readable. + static bool IsBody(StepEntity rep) => string.Equals(rep.GetString(1), "Body", StringComparison.OrdinalIgnoreCase); + foreach (var rep in reps.Where(IsBody).Concat(reps.Where(r => !IsBody(r)))) { - var repEnt = step.Find(repId); - if (repEnt is null || !repEnt.Type.Equals("IFCSHAPEREPRESENTATION", StringComparison.OrdinalIgnoreCase)) - continue; + IfcMaterial? repMaterial = null; + var mesh = ParseRepresentationItems(ctx, rep, meshName, ref repMaterial, out var parametric); + if (mesh is null) continue; - var itemIds = repEnt.GetRefList(3); - foreach (int itemId in itemIds) + material = repMaterial; + if (parametric is { } body) { - var itemEnt = step.Find(itemId); - if (itemEnt is null) continue; + solid = body.Solid; + revolvedSolid = body.Revolved; + booleanResult = body.Boolean; + } - if (geomToMaterialMap.TryGetValue(itemId, out var mat)) - { - material = mat; - } + var world = placement.ApplyTo(mesh); + return IfcMesh.Create(world.Id, meshName, world.Vertices, world.Indices); + } - // A/B. Meshes (Brep / TriangulatedFaceSet) - var mesh = ParseMeshEntity(step, itemEnt, meshName); - if (mesh is not null) - { - return mesh; - } + return null; + } - // C. IFCEXTRUDEDAREASOLID - if (itemEnt.Type.Equals("IFCEXTRUDEDAREASOLID", StringComparison.OrdinalIgnoreCase)) - { - int? profileId = itemEnt.GetRef(0); - int? posPlacementId = itemEnt.GetRef(1); - int? dirId = itemEnt.GetRef(2); - double depth = itemEnt.GetReal(3) ?? 0.0; + /// Merges all readable items of a shape representation into one mesh in the representation's coordinates. + private static IfcMesh? ParseRepresentationItems( + GeometryReadContext ctx, + StepEntity? repEnt, + string meshName, + ref IfcMaterial? material, + out ParametricBody? parametric) + { + parametric = null; + if (repEnt is null) return null; - var profile = ParseProfile(step, profileId); - if (profile is not null) - { - var pos3d = ParsePlacement3D(step, posPlacementId); - var dir3d = ParseDirection(step, dirId); - var extrudedSolid = new IfcExtrudedAreaSolid(profile, depth, pos3d, dir3d); - solid = extrudedSolid; - return extrudedSolid.ToMesh(16, meshName); - } - } + var itemIds = repEnt.GetRefList(3); + var parts = new List(itemIds.Count); + foreach (int itemId in itemIds) + { + var itemEnt = ctx.Step.Find(itemId); + if (itemEnt is null) continue; - // D. IFCREVOLVEDAREASOLID - if (itemEnt.Type.Equals("IFCREVOLVEDAREASOLID", StringComparison.OrdinalIgnoreCase)) - { - int? profileId = itemEnt.GetRef(0); - int? posPlacementId = itemEnt.GetRef(1); - int? axisId = itemEnt.GetRef(2); - double angle = itemEnt.GetReal(3) ?? (2.0 * Math.PI); + if (material is null && ctx.GeomToMaterialMap.TryGetValue(itemId, out var mat)) + { + material = mat; + } - var profile = ParseProfile(step, profileId); - if (profile is not null) - { - var pos3d = ParsePlacement3D(step, posPlacementId); - var axis1 = ParseAxis1Placement(step, axisId); - var revSolid = new IfcRevolvedAreaSolid(profile, angle, pos3d, axis1); - revolvedSolid = revSolid; - return revSolid.ToMesh(16, 24, meshName); - } - } + var mesh = ParseRepresentationItem(ctx, itemEnt, meshName, ref material, out var itemParametric); + if (mesh is null) continue; + if (parts.Count == 0) parametric = itemParametric; + parts.Add(mesh); + } - // E. IFCBOOLEANRESULT / IFCBOOLEANCLIPPINGRESULT - if (itemEnt.Type.Equals("IFCBOOLEANRESULT", StringComparison.OrdinalIgnoreCase) || - itemEnt.Type.Equals("IFCBOOLEANCLIPPINGRESULT", StringComparison.OrdinalIgnoreCase)) - { - var boolRes = ParseBooleanResult(step, itemEnt); - if (boolRes is not null) - { - booleanResult = boolRes; - return boolRes.ToMesh(meshName); - } - } + if (parts.Count == 0) return null; + if (parts.Count == 1) return parts[0]; + + var vertices = new List(parts.Sum(p => p.Vertices.Count)); + var indices = new List(parts.Sum(p => p.Indices.Count)); + foreach (var part in parts) + { + int offset = vertices.Count; + vertices.AddRange(part.Vertices); + foreach (int index in part.Indices) indices.Add(index + offset); + } + return IfcMesh.Create(parts[0].Id, meshName, vertices, indices); + } + + /// Reads one representation item into a mesh in the coordinates of its representation. + private static IfcMesh? ParseRepresentationItem( + GeometryReadContext ctx, + StepEntity itemEnt, + string meshName, + ref IfcMaterial? material, + out ParametricBody? parametric) + { + parametric = null; + var step = ctx.Step; + + // A/B. Meshes (Brep / TriangulatedFaceSet / surface models) + var mesh = ParseMeshEntity(step, itemEnt, meshName); + if (mesh is not null) return mesh; + + // C/D. IFCEXTRUDEDAREASOLID / IFCREVOLVEDAREASOLID + var swept = ParseSweptSolid(step, itemEnt, meshName, out var extruded, out var revolved, out _); + if (swept is not null) + { + parametric = new ParametricBody(extruded, revolved, null); + return swept; + } + + // E. IFCBOOLEANRESULT / IFCBOOLEANCLIPPINGRESULT + if (itemEnt.Type.Equals("IFCBOOLEANRESULT", StringComparison.OrdinalIgnoreCase) || + itemEnt.Type.Equals("IFCBOOLEANCLIPPINGRESULT", StringComparison.OrdinalIgnoreCase)) + { + var boolRes = ParseBooleanResult(step, itemEnt); + if (boolRes is null) return null; + parametric = new ParametricBody(null, null, boolRes); + return boolRes.ToMesh(meshName); + } + + // F. IFCMAPPEDITEM: MappingTarget * MappingOrigin * mapped representation. + if (itemEnt.Type.Equals("IFCMAPPEDITEM", StringComparison.OrdinalIgnoreCase)) + { + int? mapId = itemEnt.GetRef(0); + if (!mapId.HasValue) return null; + + var (mapMesh, mapMaterial) = ParseMappedRepresentation(ctx, mapId.Value, meshName); + material ??= mapMaterial; + if (mapMesh is null) return null; + return ParseCartesianTransformationOperator(step, itemEnt.GetRef(1)).ApplyTo(mapMesh); + } + + return null; + } + + /// + /// Meshes an IfcExtrudedAreaSolid or IfcRevolvedAreaSolid in the coordinates of its representation and returns + /// its parametric form, which keeps only the Position location (as IfcModel stores and writes it). + /// + /// True when Position or the profile Position rotates, so the parametric form does not describe the mesh. + private static IfcMesh? ParseSweptSolid( + StepFile step, + StepEntity ent, + string meshName, + out IfcExtrudedAreaSolid? extruded, + out IfcRevolvedAreaSolid? revolved, + out bool fullPlacement) + { + extruded = null; + revolved = null; + fullPlacement = false; + bool isExtruded = ent.Type.Equals("IFCEXTRUDEDAREASOLID", StringComparison.OrdinalIgnoreCase); + if (!isExtruded && !ent.Type.Equals("IFCREVOLVEDAREASOLID", StringComparison.OrdinalIgnoreCase)) return null; + + var profile = ParseProfile(step, ent.GetRef(0)); + if (profile is null) return null; + + var position = ParseAxis2Placement(step, ent.GetRef(1)); + var profileRotation = ParseProfileRotation(step, ent.GetRef(0)); + fullPlacement = !position.IsTranslationOnly || !profileRotation.IsIdentity; + + // shortcut: with a location-only Position the parametric solid's own meshing is used, so files written by + // IfcModel read back unchanged; it differs from IFC for non-+Z extrusion directions (profile frame) and + // for revolution axes away from the origin (Axis taken as absolute). Use the IFC path below for both if needed. + if (isExtruded) + { + double depth = ent.GetReal(3) ?? 0.0; + extruded = new IfcExtrudedAreaSolid(profile, depth, position.Origin, ParseDirection(step, ent.GetRef(2))); + if (!fullPlacement) return extruded.ToMesh(16, meshName); + + // IFC: profile in the XY plane of Position, swept along ExtrudedDirection (Position coordinates): + // extrude along local Z, then shear Z onto the direction. + var direction = ParseCoordinates(step.Find(ent.GetRef(2))).Normalized(); + if (direction == IfcVector3.Zero) direction = IfcVector3.UnitZ; + var shear = new IfcTransform(IfcVector3.UnitX, IfcVector3.UnitY, direction, IfcVector3.Zero); + var local = new IfcExtrudedAreaSolid(profile, depth).ToMesh(16, meshName); + return position.Multiply(shear).Multiply(profileRotation).ApplyTo(local); + } + + // Angle is read as radians, matching what IfcModel writes. + double angle = ent.GetReal(3) ?? (2.0 * Math.PI); + var axis = ParseAxis1Placement(step, ent.GetRef(2)); + revolved = new IfcRevolvedAreaSolid(profile, angle, position.Origin, axis); + if (!fullPlacement) return revolved.ToMesh(16, 24, meshName); + + // shortcut: a rotated profile Position is ignored for revolved solids (rare); rotate the profile points first if a model needs it. + return position.ApplyTo(new IfcRevolvedAreaSolid(profile, angle, IfcVector3.Zero, axis).ToMesh(16, 24, meshName)); + } + + /// Reads an IfcRepresentationMap once, in the coordinates of the mapping (MappingOrigin applied). + private static (IfcMesh? Mesh, IfcMaterial? Material) ParseMappedRepresentation(GeometryReadContext ctx, int mapId, string meshName) + { + if (ctx.MappedRepresentations.TryGetValue(mapId, out var cached)) return cached; + + var mapEnt = ctx.Step.Find(mapId); + if (mapEnt is null || !mapEnt.Type.Equals("IFCREPRESENTATIONMAP", StringComparison.OrdinalIgnoreCase)) + { + return (null, null); + } + + if (ctx.MapsInProgress.Count >= MaxMappingDepth) + { + throw new InvalidDataException($"IFC representation map #{mapId} is nested more than {MaxMappingDepth} levels deep."); + } + + if (!ctx.MapsInProgress.Add(mapId)) + { + throw new InvalidDataException($"IFC representation map #{mapId} maps itself (IfcMappedItem cycle)."); + } + + IfcMaterial? material = null; + var mesh = ParseRepresentationItems(ctx, ctx.Step.Find(mapEnt.GetRef(1)), meshName, ref material, out _); + var result = (mesh is null ? null : ParseAxis2Placement(ctx.Step, mapEnt.GetRef(0)).ApplyTo(mesh), material); + + ctx.MapsInProgress.Remove(mapId); + ctx.MappedRepresentations[mapId] = result; + return result; + } + + /// Resolves an IfcObjectPlacement (PlacementRelTo chain) into a world transform in model length units. + private static IfcTransform ResolveObjectPlacement(GeometryReadContext ctx, int? placementId) + { + // Walk up to a resolved or root placement, then compose downwards (iterative: chains may be deep). + var chain = new List(); + var visited = new HashSet(); + var world = ctx.Root; + for (int? id = placementId; id.HasValue;) + { + if (ctx.Placements.TryGetValue(id.Value, out var cached)) + { + world = cached; + break; + } + + var ent = ctx.Step.Find(id.Value); + // shortcut: IfcGridPlacement (and any non-local placement) resolves to the world origin; add grid intersection support when a model needs it. + if (ent is null || !ent.Type.Equals("IFCLOCALPLACEMENT", StringComparison.OrdinalIgnoreCase)) break; + if (!visited.Add(id.Value)) + { + throw new InvalidDataException($"IFC placement #{id.Value} is relative to itself (PlacementRelTo cycle)."); } + + chain.Add(ent); + id = ent.GetRef(0); } + for (int i = chain.Count - 1; i >= 0; i--) + { + world = world.Multiply(ParseAxis2Placement(ctx.Step, chain[i].GetRef(1))); + ctx.Placements[chain[i].Id] = world; + } + return world; + } + + /// WorldCoordinateSystem of the 3D 'Model' context: absolute placements are relative to it. + private static IfcTransform ParseWorldCoordinateSystem(StepFile step) + { + var contexts = step.OfType("IFCGEOMETRICREPRESENTATIONCONTEXT").ToList(); + var model = contexts.FirstOrDefault(c => string.Equals(c.GetString(1), "Model", StringComparison.OrdinalIgnoreCase)) + ?? contexts.FirstOrDefault(c => c.GetInt(2) == 3); + return model is null ? IfcTransform.Identity : ParseAxis2Placement(step, model.GetRef(4)); + } + + /// IfcAxis2Placement3D / IfcAxis2Placement2D as a rigid transform (identity when absent). + private static IfcTransform ParseAxis2Placement(StepFile step, int? placementId) + { + var ent = step.Find(placementId); + if (ent is null) return IfcTransform.Identity; + + var location = ParseCoordinates(step.Find(ent.GetRef(0))); + if (ent.Type.Equals("IFCAXIS2PLACEMENT2D", StringComparison.OrdinalIgnoreCase)) + { + var x2 = FirstProjAxis(IfcVector3.UnitZ, ParseOptionalVector(step, ent.GetRef(1))); + return new IfcTransform(x2, IfcVector3.Cross(IfcVector3.UnitZ, x2), IfcVector3.UnitZ, location); + } + + if (!ent.Type.Equals("IFCAXIS2PLACEMENT3D", StringComparison.OrdinalIgnoreCase)) return IfcTransform.Identity; + + var z = ParseOptionalVector(step, ent.GetRef(1))?.Normalized() ?? IfcVector3.UnitZ; + if (z == IfcVector3.Zero) z = IfcVector3.UnitZ; + var x = FirstProjAxis(z, ParseOptionalVector(step, ent.GetRef(2))); + return new IfcTransform(x, IfcVector3.Cross(z, x), z, location); + } + + /// + /// IfcCartesianTransformationOperator (2D/3D, uniform and non-uniform) as an affine transform. + /// Y follows Z x X and is reversed when Axis2 points the other way (mirroring). + /// + private static IfcTransform ParseCartesianTransformationOperator(StepFile step, int? operatorId) + { + var ent = step.Find(operatorId); + if (ent is null) return IfcTransform.Identity; + + bool is3D = ent.Type.Contains("3D", StringComparison.OrdinalIgnoreCase); + bool nonUniform = ent.Type.EndsWith("NONUNIFORM", StringComparison.OrdinalIgnoreCase); + var axis1 = ParseOptionalVector(step, ent.GetRef(0)); + var axis2 = ParseOptionalVector(step, ent.GetRef(1)); + var origin = ParseCoordinates(step.Find(ent.GetRef(2))); + double scale = ent.GetReal(3) ?? 1.0; + + var z = IfcVector3.UnitZ; + double scaleY = scale, scaleZ = scale; + if (is3D) + { + z = ParseOptionalVector(step, ent.GetRef(4))?.Normalized() ?? IfcVector3.UnitZ; + if (z == IfcVector3.Zero) z = IfcVector3.UnitZ; + if (nonUniform) + { + scaleY = ent.GetReal(5) ?? scale; + scaleZ = ent.GetReal(6) ?? scale; + } + } + else if (nonUniform) + { + scaleY = ent.GetReal(4) ?? scale; + } + + var x = FirstProjAxis(z, axis1); + var y = IfcVector3.Cross(z, x); + // IfcSecondProjAxis: in 3D an omitted Axis2 is (0,1,0); the side of Z x X it lies on decides mirroring. + var yReference = axis2 ?? (is3D ? IfcVector3.UnitY : null); + if (yReference.HasValue && IfcVector3.Dot(yReference.Value, y) < 0) y = -y; + return new IfcTransform(x * scale, y * scaleY, z * scaleZ, origin); + } + + /// IFC IfcFirstProjAxis: the reference direction projected onto the plane normal to . + private static IfcVector3 FirstProjAxis(IfcVector3 z, IfcVector3? reference) + { + foreach (var candidate in new[] { reference ?? IfcVector3.UnitX, IfcVector3.UnitX, IfcVector3.UnitZ }) + { + var x = (candidate - (z * IfcVector3.Dot(candidate, z))).Normalized(); + if (x != IfcVector3.Zero) return x; + } + return IfcVector3.UnitY; + } + + /// Rotation of a parameterized profile about its Position (ParseProfile already applies the Position offset). + private static IfcTransform ParseProfileRotation(StepFile step, int? profileId) + { + var profEnt = step.Find(profileId); + if (profEnt is null) return IfcTransform.Identity; + var placement = ParseAxis2Placement(step, profEnt.GetRef(2)); + return placement.Multiply(IfcTransform.Translation(-placement.Origin)); + } + + private static IfcVector3? ParseOptionalVector(StepFile step, int? id) + { + var ent = step.Find(id); + return ent is null ? null : ParseCoordinates(ent); + } + + /// Coordinates of an IfcCartesianPoint or IfcDirection; missing components are 0. + private static IfcVector3 ParseCoordinates(StepEntity? ent) + { + if (ent is null) return IfcVector3.Zero; + var parts = StepEntity.ParseArguments((ent.GetRaw(0) ?? string.Empty).Trim('(', ')')); + double Component(int i) => + i < parts.Count && double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out double v) ? v : 0.0; + return new IfcVector3(Component(0), Component(1), Component(2)); + } + + /// + /// Metres per length unit from the project's IfcUnitAssignment (SI or conversion-based unit). + /// Null when absent or unrecognised: LengthUnit then keeps its default, as before. + /// + private static double? ParseLengthUnit(StepFile step, StepEntity? project) + { + var assignment = step.Find(project?.GetRef(8)) ?? step.OfType("IFCUNITASSIGNMENT").FirstOrDefault(); + var unit = assignment?.GetRefList(0) + .Select(id => step.Find(id)) + .FirstOrDefault(u => string.Equals(u?.GetRaw(1), ".LENGTHUNIT.", StringComparison.OrdinalIgnoreCase)); + return ParseUnitFactor(step, unit, 0); + } + + /// Factor of a named unit relative to its SI base unit. + private static double? ParseUnitFactor(StepFile step, StepEntity? unit, int depth) + { + if (unit is null || depth > 8) return null; + + if (unit.Type.Equals("IFCSIUNIT", StringComparison.OrdinalIgnoreCase)) + { + return unit.GetRaw(2)?.Trim('.').ToUpperInvariant() switch + { + null => 1.0, + "EXA" => 1e18, + "PETA" => 1e15, + "TERA" => 1e12, + "GIGA" => 1e9, + "MEGA" => 1e6, + "KILO" => 1e3, + "HECTO" => 1e2, + "DECA" => 1e1, + "DECI" => 1e-1, + "CENTI" => 1e-2, + "MILLI" => 1e-3, + "MICRO" => 1e-6, + "NANO" => 1e-9, + "PICO" => 1e-12, + "FEMTO" => 1e-15, + "ATTO" => 1e-18, + _ => null, + }; + } + + if (unit.Type.StartsWith("IFCCONVERSIONBASEDUNIT", StringComparison.OrdinalIgnoreCase)) + { + var measure = step.Find(unit.GetRef(3)); + if (measure is null) return null; + if (!double.TryParse(ExtractNominalValue(measure.GetRaw(0)), NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) return null; + return value * ParseUnitFactor(step, step.Find(measure.GetRef(1)), depth + 1); + } + + return null; + } + + private static IfcLengthUnit? ToLengthUnit(double metresPerUnit) + { + foreach (var unit in new[] { IfcLengthUnit.Millimetre, IfcLengthUnit.Metre, IfcLengthUnit.Foot, IfcLengthUnit.Inch }) + { + if (Math.Abs(metresPerUnit - unit.MetresPerUnit()) <= 1e-9 * unit.MetresPerUnit()) return unit; + } return null; } private static IfcMesh? ParseMeshEntity(StepFile step, StepEntity itemEnt, string meshName = "Mesh") { - // IFCFACETEDBREP - if (itemEnt.Type.Equals("IFCFACETEDBREP", StringComparison.OrdinalIgnoreCase)) + // IFCFACETEDBREP (one closed shell), IFCFACEBASEDSURFACEMODEL (connected face sets), IFCSHELLBASEDSURFACEMODEL (shells) + bool isBrep = itemEnt.Type.Equals("IFCFACETEDBREP", StringComparison.OrdinalIgnoreCase); + if (isBrep || + itemEnt.Type.Equals("IFCFACEBASEDSURFACEMODEL", StringComparison.OrdinalIgnoreCase) || + itemEnt.Type.Equals("IFCSHELLBASEDSURFACEMODEL", StringComparison.OrdinalIgnoreCase)) { - int? shellId = itemEnt.GetRef(0); - var shellEnt = step.Find(shellId); - if (shellEnt is null) return null; + var shellIds = isBrep && itemEnt.GetRef(0) is int shellId ? [shellId] : itemEnt.GetRefList(0); var vertices = new List(); var indices = new List(); var pointIndexMap = new Dictionary(); - var faceIds = shellEnt.GetRefList(0); + var faceIds = shellIds.SelectMany(id => step.Find(id)?.GetRefList(0) ?? []); foreach (int faceId in faceIds) { var faceEnt = step.Find(faceId); @@ -3003,33 +3363,20 @@ private static int WriteRevolvedAreaSolid(StepWriter w, IfcRevolvedAreaSolid sol var firstEnt = step.Find(firstOpId.Value); if (firstEnt is not null) { - if (firstEnt.Type.Equals("IFCEXTRUDEDAREASOLID", StringComparison.OrdinalIgnoreCase)) + var sweptMesh = ParseSweptSolid(step, firstEnt, "BooleanOperand", out var extruded, out var revolved, out bool fullPlacement); + if (sweptMesh is null) { - int? profId = firstEnt.GetRef(0); - var prof = ParseProfile(step, profId); - if (prof is not null) - { - var pos = ParsePlacement3D(step, firstEnt.GetRef(1)); - var dir = ParseDirection(step, firstEnt.GetRef(2)); - double depth = firstEnt.GetReal(3) ?? 0.0; - boolResult.FirstSolid = new IfcExtrudedAreaSolid(prof, depth, pos, dir); - } + boolResult.FirstMesh = ParseMeshEntity(step, firstEnt); } - else if (firstEnt.Type.Equals("IFCREVOLVEDAREASOLID", StringComparison.OrdinalIgnoreCase)) + else if (fullPlacement) { - int? profId = firstEnt.GetRef(0); - var prof = ParseProfile(step, profId); - if (prof is not null) - { - var pos = ParsePlacement3D(step, firstEnt.GetRef(1)); - var axis = ParseAxis1Placement(step, firstEnt.GetRef(2)); - double angle = firstEnt.GetReal(3) ?? (2.0 * Math.PI); - boolResult.FirstRevolved = new IfcRevolvedAreaSolid(prof, angle, pos, axis); - } + // The parametric operand cannot hold a rotated Position: keep the placed mesh (also written back as such). + boolResult.FirstMesh = sweptMesh; } else { - boolResult.FirstMesh = ParseMeshEntity(step, firstEnt); + boolResult.FirstSolid = extruded; + boolResult.FirstRevolved = revolved; } } } @@ -3173,7 +3520,7 @@ private static IfcAxis1Placement ParseAxis1Placement(StepFile step, int? axisPla int? locId = ent.GetRef(0); int? dirId = ent.GetRef(1); - var loc = ParsePlacement3D(step, locId); + var loc = ParseCoordinates(step.Find(locId)); var dir = ParseDirection(step, dirId); return new IfcAxis1Placement(loc, dir); } @@ -3295,7 +3642,20 @@ private static bool IsBimProductType(string type) || type.StartsWith("IFCFURNISHINGELEMENT", StringComparison.OrdinalIgnoreCase) || type.StartsWith("IFCOPENINGELEMENT", StringComparison.OrdinalIgnoreCase) || type.Equals("IFCSPACE", StringComparison.OrdinalIgnoreCase) - || type.Equals("IFCBUILDINGELEMENTPROXY", StringComparison.OrdinalIgnoreCase); + || type.Equals("IFCBUILDINGELEMENTPROXY", StringComparison.OrdinalIgnoreCase) + // MEP distribution elements: IFC2x3 generic flow classes and IFC4 fittings + || type.Equals("IFCFLOWSEGMENT", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCFLOWFITTING", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCFLOWTERMINAL", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCFLOWCONTROLLER", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCFLOWMOVINGDEVICE", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCFLOWSTORAGEDEVICE", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCFLOWTREATMENTDEVICE", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCENERGYCONVERSIONDEVICE", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCDISTRIBUTIONCHAMBERELEMENT", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCDISTRIBUTIONCONTROLELEMENT", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCPIPEFITTING", StringComparison.OrdinalIgnoreCase) + || type.Equals("IFCDUCTFITTING", StringComparison.OrdinalIgnoreCase); } private static int WriteMeshTriangulatedFaceSet(StepWriter w, IReadOnlyList vertices, IReadOnlyList indices) diff --git a/src/IfcIO/Schema/IfcEnums.cs b/src/IfcIO/Schema/IfcEnums.cs index de75114..ab2b202 100644 --- a/src/IfcIO/Schema/IfcEnums.cs +++ b/src/IfcIO/Schema/IfcEnums.cs @@ -30,6 +30,19 @@ public enum IfcLengthUnit Inch, } +internal static class IfcLengthUnitExtensions +{ + /// Length of one model unit in metres. + internal static double MetresPerUnit(this IfcLengthUnit unit) => unit switch + { + IfcLengthUnit.Millimetre => 0.001, + IfcLengthUnit.Metre => 1.0, + IfcLengthUnit.Foot => 0.3048, + IfcLengthUnit.Inch => 0.0254, + _ => throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unknown length unit."), + }; +} + /// /// Plane angle units supported in IFC project unit assignment. /// diff --git a/tests/IfcIO.Tests/PlacementGeometryTests.cs b/tests/IfcIO.Tests/PlacementGeometryTests.cs new file mode 100644 index 0000000..7a4967f --- /dev/null +++ b/tests/IfcIO.Tests/PlacementGeometryTests.cs @@ -0,0 +1,373 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text; +using System.Text.Json; +using IfcIO.Exporters; +using IfcIO.Geometry; +using IfcIO.Model; +using IfcIO.Schema; +using Xunit; + +namespace IfcIO.Tests; + +/// +/// World placement, mapped items and length units of geometry read from IFC, checked through +/// the model meshes and the GLB/OBJ exports. +/// +public class PlacementGeometryTests +{ + private const string Millimetre = "#11=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);"; + private const string Metre = "#11=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);"; + private const string Centimetre = "#11=IFCSIUNIT(*,.LENGTHUNIT.,.CENTI.,.METRE.);"; + private const string Foot = """ + #11=IFCCONVERSIONBASEDUNIT(#12,.LENGTHUNIT.,'FOOT',#13); + #12=IFCDIMENSIONALEXPONENTS(1,0,0,0,0,0,0); + #13=IFCMEASUREWITHUNIT(IFCLENGTHMEASURE(0.3048),#14); + #14=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); + """; + + // Site at (1000,2000,0); building rotated 90 degrees about Z and moved (100,0,0); storey raised 3000. + // Panel: triangle (0,0,0),(1,0,0),(0,1,0) at (10,20,0) in the storey (IfcFaceBasedSurfaceModel). + // Pipe: 20x10 rectangle (profile rotated 90 degrees) extruded 1000 along the element X axis. + // Fittings: one representation map (origin (0,0,100), triangle (0,0,0),(10,0,0),(0,10,0)) used three times; + // Fitting C mirrors it in X (Axis1 = -X with Axis2 omitted). Beam: an extrusion along world X inside a clipping result. + private static string BuildIfc(string lengthUnit, string wcsOrigin = "0.,0.,0.") => $""" + ISO-10303-21; + HEADER; + FILE_DESCRIPTION(('ViewDefinition [CoordinationView_V2.0]'),'2;1'); + FILE_NAME('placement.ifc','2026-01-01T00:00:00',(''),(''),'','',''); + FILE_SCHEMA(('IFC2X3')); + ENDSEC; + DATA; + #1=IFCPROJECT('0YvctVUKr0kugbFTf53O9L',$,'Placement',$,$,$,$,(#20),#10); + #10=IFCUNITASSIGNMENT((#11)); + {lengthUnit} + #20=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#21,$); + #21=IFCAXIS2PLACEMENT3D(#22,$,$); + #22=IFCCARTESIANPOINT(({wcsOrigin})); + #23=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#20,$,.MODEL_VIEW.,$); + #30=IFCDIRECTION((0.,0.,1.)); + #31=IFCDIRECTION((0.,1.,0.)); + #32=IFCDIRECTION((1.,0.,0.)); + #33=IFCDIRECTION((0.,1.)); + #34=IFCDIRECTION((-1.,0.,0.)); + #40=IFCSITE('2YvctVUKr0kugbFTf53O9L',$,'Site',$,$,#41,$,$,.ELEMENT.,$,$,$,$,$); + #41=IFCLOCALPLACEMENT($,#42); + #42=IFCAXIS2PLACEMENT3D(#43,$,$); + #43=IFCCARTESIANPOINT((1000.,2000.,0.)); + #50=IFCBUILDING('3YvctVUKr0kugbFTf53O9L',$,'Building',$,$,#51,$,$,.ELEMENT.,$,$,$); + #51=IFCLOCALPLACEMENT(#41,#52); + #52=IFCAXIS2PLACEMENT3D(#53,#30,#31); + #53=IFCCARTESIANPOINT((100.,0.,0.)); + #60=IFCBUILDINGSTOREY('4YvctVUKr0kugbFTf53O9L',$,'Level 1',$,$,#61,$,$,.ELEMENT.,3000.); + #61=IFCLOCALPLACEMENT(#51,#62); + #62=IFCAXIS2PLACEMENT3D(#63,$,$); + #63=IFCCARTESIANPOINT((0.,0.,3000.)); + #100=IFCBUILDINGELEMENTPROXY('1YvctVUKr0kugbFTf53O9L',$,'Panel',$,$,#101,#103,$,$); + #101=IFCLOCALPLACEMENT(#61,#102); + #102=IFCAXIS2PLACEMENT3D(#104,$,$); + #104=IFCCARTESIANPOINT((10.,20.,0.)); + #103=IFCPRODUCTDEFINITIONSHAPE($,$,(#105)); + #105=IFCSHAPEREPRESENTATION(#23,'Body','SurfaceModel',(#106)); + #106=IFCFACEBASEDSURFACEMODEL((#107)); + #107=IFCCONNECTEDFACESET((#108)); + #108=IFCFACE((#109)); + #109=IFCFACEOUTERBOUND(#110,.T.); + #110=IFCPOLYLOOP((#111,#112,#113)); + #111=IFCCARTESIANPOINT((0.,0.,0.)); + #112=IFCCARTESIANPOINT((1.,0.,0.)); + #113=IFCCARTESIANPOINT((0.,1.,0.)); + #200=IFCFLOWSEGMENT('5YvctVUKr0kugbFTf53O9L',$,'Pipe',$,$,#201,#203,$); + #201=IFCLOCALPLACEMENT(#61,#202); + #202=IFCAXIS2PLACEMENT3D(#204,$,$); + #204=IFCCARTESIANPOINT((0.,0.,0.)); + #203=IFCPRODUCTDEFINITIONSHAPE($,$,(#205)); + #205=IFCSHAPEREPRESENTATION(#23,'Body','SweptSolid',(#206)); + #206=IFCEXTRUDEDAREASOLID(#207,#209,#30,1000.); + #207=IFCRECTANGLEPROFILEDEF(.AREA.,$,#208,20.,10.); + #208=IFCAXIS2PLACEMENT2D(#210,#33); + #210=IFCCARTESIANPOINT((0.,0.)); + #209=IFCAXIS2PLACEMENT3D(#204,#32,#31); + #300=IFCREPRESENTATIONMAP(#301,#303); + #301=IFCAXIS2PLACEMENT3D(#302,$,$); + #302=IFCCARTESIANPOINT((0.,0.,100.)); + #303=IFCSHAPEREPRESENTATION(#23,'Body','Brep',(#304)); + #304=IFCFACETEDBREP(#305); + #305=IFCCLOSEDSHELL((#306)); + #306=IFCFACE((#307)); + #307=IFCFACEOUTERBOUND(#308,.T.); + #308=IFCPOLYLOOP((#111,#309,#310)); + #309=IFCCARTESIANPOINT((10.,0.,0.)); + #310=IFCCARTESIANPOINT((0.,10.,0.)); + #320=IFCFLOWFITTING('6YvctVUKr0kugbFTf53O9L',$,'Fitting A',$,$,#321,#323,$); + #321=IFCLOCALPLACEMENT($,#322); + #322=IFCAXIS2PLACEMENT3D(#204,$,$); + #323=IFCPRODUCTDEFINITIONSHAPE($,$,(#324)); + #324=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#325)); + #325=IFCMAPPEDITEM(#300,#326); + #326=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#31,#34,#327,2.,$); + #327=IFCCARTESIANPOINT((500.,0.,0.)); + #330=IFCFLOWFITTING('7YvctVUKr0kugbFTf53O9L',$,'Fitting B',$,$,#331,#333,$); + #331=IFCLOCALPLACEMENT($,#332); + #332=IFCAXIS2PLACEMENT3D(#334,$,$); + #334=IFCCARTESIANPOINT((0.,0.,50.)); + #333=IFCPRODUCTDEFINITIONSHAPE($,$,(#335)); + #335=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#336)); + #336=IFCMAPPEDITEM(#300,#337); + #337=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#204,$,$); + #340=IFCFLOWFITTING('BYvctVUKr0kugbFTf53O9L',$,'Fitting C',$,$,#321,#343,$); + #343=IFCPRODUCTDEFINITIONSHAPE($,$,(#345)); + #345=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#346)); + #346=IFCMAPPEDITEM(#300,#347); + #347=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#34,$,#204,$,$); + #600=IFCBEAM('CYvctVUKr0kugbFTf53O9L',$,'Beam',$,$,#321,#603,$); + #603=IFCPRODUCTDEFINITIONSHAPE($,$,(#605)); + #605=IFCSHAPEREPRESENTATION(#23,'Body','Clipping',(#606)); + #606=IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#607,#610); + #607=IFCEXTRUDEDAREASOLID(#608,#209,#30,1000.); + #608=IFCRECTANGLEPROFILEDEF(.AREA.,$,#609,20.,10.); + #609=IFCAXIS2PLACEMENT2D(#210,$); + #610=IFCHALFSPACESOLID(#611,.F.); + #611=IFCPLANE(#612); + #612=IFCAXIS2PLACEMENT3D(#613,$,$); + #613=IFCCARTESIANPOINT((0.,0.,5000.)); + #400=IFCDISTRIBUTIONPORT('8YvctVUKr0kugbFTf53O9L',$,'Pipe end',$,$,#401,$,.SOURCE.); + #401=IFCLOCALPLACEMENT(#201,#402); + #402=IFCAXIS2PLACEMENT3D(#403,#32,$); + #403=IFCCARTESIANPOINT((1000.,0.,0.)); + #410=IFCRELCONNECTSPORTTOELEMENT('9YvctVUKr0kugbFTf53O9L',$,$,$,#400,#200); + #500=IFCRELCONTAINEDINSPATIALSTRUCTURE('AYvctVUKr0kugbFTf53O9L',$,$,$,(#100,#200,#320,#330,#340,#600),#60); + ENDSEC; + END-ISO-10303-21; + """; + + private static readonly IfcVector3[] PanelWorld = [new(1080, 2010, 3000), new(1080, 2011, 3000), new(1079, 2010, 3000)]; + private static readonly IfcVector3[] FittingAWorld = [new(500, 0, 200), new(500, 20, 200), new(480, 0, 200)]; + private static readonly IfcVector3[] FittingBWorld = [new(0, 0, 150), new(10, 0, 150), new(0, 10, 150)]; + + private static IfcModel Open(string ifc) + { + using var reader = new StringReader(ifc); + return IfcModel.Open(reader); + } + + private static IfcElement Element(IfcModel model, string name) => Assert.Single(model.AllElements, e => e.Name == name); + + private static void AssertPoints(IfcVector3[] expected, IReadOnlyList actual, double tolerance = 1e-9) + { + Assert.Equal(expected.Length, actual.Count); + for (int i = 0; i < expected.Length; i++) + { + Assert.True( + (expected[i] - actual[i]).Length <= tolerance, + $"Vertex {i}: expected {expected[i]}, actual {actual[i]}"); + } + } + + [Fact] + public void Open_NestedLocalPlacements_PutsVerticesInWorldCoordinates() + { + var model = Open(BuildIfc(Millimetre)); + + var panel = Element(model, "Panel"); + Assert.NotNull(panel.Mesh); + AssertPoints(PanelWorld, panel.Mesh.Vertices); + } + + [Fact] + public void Open_RotatedExtrusionAndProfile_PlacesPipeInWorldCoordinates() + { + var model = Open(BuildIfc(Millimetre)); + + var pipe = Element(model, "Pipe"); + Assert.Equal("IFCFLOWSEGMENT", pipe.IfcType); + Assert.NotNull(pipe.Mesh); + var box = pipe.Mesh.BoundingBox; + AssertPoints([new(1095, 2000, 2990), new(1105, 3000, 3010)], [box.Min, box.Max]); + + // The parametric solid keeps the item's own definition, as before. + Assert.NotNull(pipe.Solid); + Assert.Equal(1000.0, pipe.Solid.Depth); + } + + [Fact] + public void Open_ClippingResult_AppliesRotatedOperandPosition() + { + var model = Open(BuildIfc(Millimetre)); + + var beam = Element(model, "Beam"); + Assert.NotNull(beam.Mesh); + var box = beam.Mesh.BoundingBox; + AssertPoints([new(0, -10, -5), new(1000, 10, 5)], [box.Min, box.Max]); + Assert.NotNull(beam.BooleanResult); + Assert.NotNull(beam.BooleanResult.FirstMesh); + Assert.Null(beam.BooleanResult.FirstSolid); + } + + [Fact] + public void Open_MappedItem_AppliesMappingOriginAndTarget() + { + var model = Open(BuildIfc(Millimetre)); + + var fittingA = Element(model, "Fitting A"); + var fittingB = Element(model, "Fitting B"); + Assert.NotNull(fittingA.Mesh); + Assert.NotNull(fittingB.Mesh); + AssertPoints(FittingAWorld, fittingA.Mesh.Vertices); + AssertPoints(FittingBWorld, fittingB.Mesh.Vertices); + } + + [Fact] + public void Open_MappedItemWithoutAxis2_MirrorsAndKeepsFacesOutward() + { + var model = Open(BuildIfc(Millimetre)); + + var fitting = Element(model, "Fitting C"); + Assert.NotNull(fitting.Mesh); + AssertPoints([new(0, 0, 100), new(-10, 0, 100), new(0, 10, 100)], fitting.Mesh.Vertices); + Assert.Equal([0, 2, 1], fitting.Mesh.Indices); + } + + [Fact] + public void Open_WorldCoordinateSystem_OffsetsAbsolutePlacements() + { + var model = Open(BuildIfc(Millimetre, wcsOrigin: "0.,0.,-50.")); + + var down = new IfcVector3(0, 0, -50); + AssertPoints(PanelWorld.Select(p => p + down).ToArray(), Element(model, "Panel").Mesh!.Vertices); + AssertPoints(FittingBWorld.Select(p => p + down).ToArray(), Element(model, "Fitting B").Mesh!.Vertices); + } + + [Fact] + public void Open_PlacementCycle_ThrowsInvalidData() + { + string cyclic = BuildIfc(Millimetre).Replace("#61=IFCLOCALPLACEMENT(#51,#62);", "#61=IFCLOCALPLACEMENT(#101,#62);", StringComparison.Ordinal); + + var ex = Assert.Throws(() => Open(cyclic)); + Assert.Contains("PlacementRelTo cycle", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Open_DistributionPort_UsesWorldPlacement() + { + var model = Open(BuildIfc(Millimetre)); + + var port = Assert.Single(Element(model, "Pipe").Ports); + AssertPoints([new(1100, 3000, 3000), IfcVector3.UnitY], [port.Position, port.Direction]); + } + + [Theory] + [InlineData(Millimetre, IfcLengthUnit.Millimetre, 1.0)] + [InlineData(Metre, IfcLengthUnit.Metre, 1.0)] + [InlineData(Foot, IfcLengthUnit.Foot, 1.0)] + [InlineData(Centimetre, IfcLengthUnit.Metre, 0.01)] + public void Open_LengthUnit_IsReadFromUnitAssignment(string lengthUnit, IfcLengthUnit expectedUnit, double coordinateScale) + { + var model = Open(BuildIfc(lengthUnit)); + + Assert.Equal(expectedUnit, model.LengthUnit); + AssertPoints(PanelWorld.Select(p => p * coordinateScale).ToArray(), Element(model, "Panel").Mesh!.Vertices); + Assert.Equal(3000 * coordinateScale, Assert.Single(model.Storeys).Elevation, 9); + } + + [Theory] + [InlineData(Millimetre, 0.001)] + [InlineData(Metre, 1.0)] + [InlineData(Foot, 0.3048)] + public void SaveGlb_WritesWorldVerticesInMetresYUp(string lengthUnit, double metresPerUnit) + { + var model = Open(BuildIfc(lengthUnit)); + using var ms = new MemoryStream(); + model.SaveGlb(ms); + + var glb = GlbReader.Parse(ms.ToArray()); + + // Only the root node moves (to the model centre); everything below it is untransformed. + var root = glb.Nodes.Last(); + Assert.All(glb.Nodes, n => Assert.False( + n.TryGetProperty("matrix", out _) || n.TryGetProperty("rotation", out _) || n.TryGetProperty("scale", out _) || + (n.TryGetProperty("translation", out _) && !n.Equals(root)))); + var t = root.GetProperty("translation"); + var offset = new IfcVector3(t[0].GetDouble(), t[1].GetDouble(), t[2].GetDouble()); + + // glTF is Y-up: IFC (x, y, z) becomes (x, z, -y). + static IfcVector3 ToGltf(IfcVector3 p, double s) => new(p.X * s, p.Z * s, -p.Y * s); + AssertPoints( + PanelWorld.Select(p => ToGltf(p, metresPerUnit)).ToArray(), + glb.Positions("Panel [IFCBUILDINGELEMENTPROXY]").Select(p => p + offset).ToArray(), + 1e-4); + AssertPoints( + FittingAWorld.Select(p => ToGltf(p, metresPerUnit)).ToArray(), + glb.Positions("Fitting A [IFCFLOWFITTING]").Select(p => p + offset).ToArray(), + 1e-4); + } + + [Fact] + public void ObjExport_WritesWorldVerticesInModelUnits() + { + var model = Open(BuildIfc(Millimetre)); + var panel = Element(model, "Panel"); + using var obj = new StringWriter(); + using var mtl = new StringWriter(); + IfcObjExporter.Write(model, obj, mtl); + + var lines = obj.ToString().Split('\n').Select(l => l.TrimEnd('\r')).ToList(); + int start = lines.IndexOf($"o Panel_{panel.Id}"); + Assert.True(start >= 0); + var vertices = lines.Skip(start + 1) + .SkipWhile(l => !l.StartsWith("v ", StringComparison.Ordinal)) + .TakeWhile(l => l.StartsWith("v ", StringComparison.Ordinal)) + .Select(l => l.Split(' ')) + .Select(p => new IfcVector3( + double.Parse(p[1], CultureInfo.InvariantCulture), + double.Parse(p[2], CultureInfo.InvariantCulture), + double.Parse(p[3], CultureInfo.InvariantCulture))) + .ToList(); + AssertPoints(PanelWorld, vertices, 1e-4); + } + + private sealed class GlbReader + { + private readonly JsonElement _root; + private readonly byte[] _bin; + + private GlbReader(JsonElement root, byte[] bin) + { + _root = root; + _bin = bin; + } + + public IEnumerable Nodes => _root.GetProperty("nodes").EnumerateArray(); + + public static GlbReader Parse(byte[] bytes) + { + Assert.Equal(0x46546C67u, BinaryPrimitives.ReadUInt32LittleEndian(bytes)); + int jsonLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(12)); + using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(bytes, 20, jsonLength)); + int binStart = 20 + jsonLength; + int binLength = (int)BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(binStart)); + return new GlbReader(doc.RootElement.Clone(), bytes.AsSpan(binStart + 8, binLength).ToArray()); + } + + public IfcVector3[] Positions(string nodeName) + { + var node = Assert.Single(Nodes, n => n.GetProperty("name").GetString() == nodeName); + var mesh = _root.GetProperty("meshes")[node.GetProperty("mesh").GetInt32()]; + int accessorIndex = mesh.GetProperty("primitives")[0].GetProperty("attributes").GetProperty("POSITION").GetInt32(); + var accessor = _root.GetProperty("accessors")[accessorIndex]; + var view = _root.GetProperty("bufferViews")[accessor.GetProperty("bufferView").GetInt32()]; + int offset = view.GetProperty("byteOffset").GetInt32() + accessor.GetProperty("byteOffset").GetInt32(); + + var result = new IfcVector3[accessor.GetProperty("count").GetInt32()]; + for (int i = 0; i < result.Length; i++) + { + var span = _bin.AsSpan(offset + (i * 12)); + result[i] = new IfcVector3( + BinaryPrimitives.ReadSingleLittleEndian(span), + BinaryPrimitives.ReadSingleLittleEndian(span[4..]), + BinaryPrimitives.ReadSingleLittleEndian(span[8..])); + } + return result; + } + } +} From 45d7d9253573c7e6e8ce1afd051a7565e28cd266 Mon Sep 17 00:00:00 2001 From: mie Date: Thu, 12 Nov 2026 03:54:56 +0300 Subject: [PATCH 2/3] fix: cap vertices materialised while reading IFC geometry 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. --- CHANGELOG.md | 2 +- src/IfcIO/IfcModel.cs | 21 ++++++++ tests/IfcIO.Tests/PlacementGeometryTests.cs | 53 +++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d5e2c..7ce71ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ All notable changes to the `IfcIO` library are documented in this file. - **GLB export follows glTF 2.0 units and axes**: positions are written in metres (converted from `IfcModel.LengthUnit`) with +Y up (IFC `(x, y, z)` becomes `(x, z, -y)`). Vertices are stored relative to the centre of the model, and the root node carries that centre as `translation`, so georeferenced models keep millimetre precision in 32-bit floats. GLB files of millimetre models were previously 1000 times too large and lying on their side. - OBJ export is unchanged: model length units, Z up, now with world-placed geometry. - `IfcElement.Solid`, `RevolvedSolid` and `BooleanResult` of read elements still describe the first body item in its own coordinates (only the `Mesh` is world-placed). Writing a read model back therefore still places those solids without the element placement, as before. -- Malformed placement data (a `PlacementRelTo` cycle, an `IfcMappedItem` cycle or mapping nested deeper than 64 levels) makes `IfcModel.Open` throw `InvalidDataException`. +- Malformed placement data (a `PlacementRelTo` cycle, an `IfcMappedItem` cycle or mapping nested deeper than 64 levels) makes `IfcModel.Open` throw `InvalidDataException`. So does geometry that expands to more than 1,000,000 + 256 vertices per STEP entity, which guards against nested `IfcMappedItem` amplification. - For length units without an `IfcLengthUnit` value, only geometry and storey elevations are converted to metres; quantities and property values stay in file units. ## [0.10.0] - 2026-11-11 diff --git a/src/IfcIO/IfcModel.cs b/src/IfcIO/IfcModel.cs index 291927b..3804f00 100644 --- a/src/IfcIO/IfcModel.cs +++ b/src/IfcIO/IfcModel.cs @@ -2790,9 +2790,29 @@ private sealed class GeometryReadContext(StepFile step, Dictionary Placements { get; } = []; public Dictionary MappedRepresentations { get; } = []; public HashSet MapsInProgress { get; } = []; + + /// + /// Cap on vertices materialised while reading, linear in the file size: nested IfcMappedItems can + /// otherwise multiply a small file into exponentially many vertices. + /// + public long VertexBudget { get; } = MinVertexBudget + (VerticesPerEntity * (long)step.Entities.Count); + + private long _materialisedVertices; + + public void ChargeVertices(int count) + { + _materialisedVertices += count; + if (_materialisedVertices > VertexBudget) + { + throw new InvalidDataException( + $"IFC geometry expands to more than {VertexBudget} vertices (limit for {Step.Entities.Count} entities); check for nested IfcMappedItem amplification."); + } + } } private const int MaxMappingDepth = 64; + private const long MinVertexBudget = 1_000_000; + private const long VerticesPerEntity = 256; /// Parametric form of the first body item, in the item's own coordinates. private readonly record struct ParametricBody(IfcExtrudedAreaSolid? Solid, IfcRevolvedAreaSolid? Revolved, IfcBooleanResult? Boolean); @@ -2872,6 +2892,7 @@ private sealed class GeometryReadContext(StepFile step, Dictionary $"#{rep + 1 + i}").ToList(); + foreach (var item in items) + { + data.AppendLine(CultureInfo.InvariantCulture, $"{item}=IFCMAPPEDITEM(#{rep - 99},#22);"); + } + data.AppendLine(CultureInfo.InvariantCulture, $"#{rep}=IFCSHAPEREPRESENTATION($,'Body','MappedRepresentation',({string.Join(",", items)}));"); + data.AppendLine(CultureInfo.InvariantCulture, $"#{rep + 1}=IFCREPRESENTATIONMAP(#20,#{rep});"); + } + data.AppendLine("#2000=IFCMAPPEDITEM(#901,#22);"); + data.AppendLine("#2001=IFCSHAPEREPRESENTATION($,'Body','MappedRepresentation',(#2000));"); + data.AppendLine("#2002=IFCPRODUCTDEFINITIONSHAPE($,$,(#2001));"); + data.AppendLine("#2003=IFCBUILDINGELEMENTPROXY('1YvctVUKr0kugbFTf53O9L',$,'Bomb',$,$,$,#2002,$,$);"); + string ifc = $""" + ISO-10303-21; + HEADER; + FILE_DESCRIPTION((''),'2;1'); + FILE_NAME('bomb.ifc','2026-01-01T00:00:00',(''),(''),'','',''); + FILE_SCHEMA(('IFC2X3')); + ENDSEC; + DATA; + {data}ENDSEC; + END-ISO-10303-21; + """; + + var ex = Assert.Throws(() => Open(ifc)); + Assert.Contains("vertices", ex.Message, StringComparison.Ordinal); + } + [Fact] public void Open_DistributionPort_UsesWorldPlacement() { From 6f68b41aca63994487c0721b0d7a9a871b3eb613 Mon Sep 17 00:00:00 2001 From: mie Date: Thu, 12 Nov 2026 04:08:51 +0300 Subject: [PATCH 3/3] fix: keep re-saved units consistent and follow IfcFirstProjAxis default - 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. --- CHANGELOG.md | 2 +- src/IfcIO/IfcModel.cs | 14 +++++--- tests/IfcIO.Tests/PlacementGeometryTests.cs | 37 +++++++++++++++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce71ea..11e60ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ All notable changes to the `IfcIO` library are documented in this file. - OBJ export is unchanged: model length units, Z up, now with world-placed geometry. - `IfcElement.Solid`, `RevolvedSolid` and `BooleanResult` of read elements still describe the first body item in its own coordinates (only the `Mesh` is world-placed). Writing a read model back therefore still places those solids without the element placement, as before. - Malformed placement data (a `PlacementRelTo` cycle, an `IfcMappedItem` cycle or mapping nested deeper than 64 levels) makes `IfcModel.Open` throw `InvalidDataException`. So does geometry that expands to more than 1,000,000 + 256 vertices per STEP entity, which guards against nested `IfcMappedItem` amplification. -- For length units without an `IfcLengthUnit` value, only geometry and storey elevations are converted to metres; quantities and property values stay in file units. +- For length units without an `IfcLengthUnit` value, only geometry and storey elevations are converted to metres; quantities and property values stay in file units. Such elements get no `Solid` / `RevolvedSolid` / `BooleanResult` (those would stay in file units), so saving writes the metre `Mesh`. ## [0.10.0] - 2026-11-11 diff --git a/src/IfcIO/IfcModel.cs b/src/IfcIO/IfcModel.cs index 3804f00..17a445e 100644 --- a/src/IfcIO/IfcModel.cs +++ b/src/IfcIO/IfcModel.cs @@ -1823,7 +1823,8 @@ public static IfcModel Open(TextReader reader, string? modelName = null) var geometry = new GeometryReadContext( step, geomToMaterialMap, - IfcTransform.Scale(geometryScale).Multiply(ParseWorldCoordinateSystem(step))); + IfcTransform.Scale(geometryScale).Multiply(ParseWorldCoordinateSystem(step)), + keepParametric: geometryScale == 1.0); var defaultStorey = model.Storeys[0]; var allElementsMap = new Dictionary(); @@ -2780,13 +2781,16 @@ private static int WriteRevolvedAreaSolid(StepWriter w, IfcRevolvedAreaSolid sol } /// State shared while reading product geometry: caches for placements and mapped representations. - private sealed class GeometryReadContext(StepFile step, Dictionary geomToMaterialMap, IfcTransform root) + private sealed class GeometryReadContext(StepFile step, Dictionary geomToMaterialMap, IfcTransform root, bool keepParametric) { public StepFile Step { get; } = step; public Dictionary GeomToMaterialMap { get; } = geomToMaterialMap; /// Unit scale applied after the world coordinate system of the model context. public IfcTransform Root { get; } = root; + + /// False when geometry is rescaled to metres: parametric solids stay in file units and would disagree with LengthUnit. + public bool KeepParametric { get; } = keepParametric; public Dictionary Placements { get; } = []; public Dictionary MappedRepresentations { get; } = []; public HashSet MapsInProgress { get; } = []; @@ -2853,7 +2857,7 @@ public void ChargeVertices(int count) if (mesh is null) continue; material = repMaterial; - if (parametric is { } body) + if (parametric is { } body && ctx.KeepParametric) { solid = body.Solid; revolvedSolid = body.Revolved; @@ -3152,7 +3156,9 @@ private static IfcTransform ParseCartesianTransformationOperator(StepFile step, /// IFC IfcFirstProjAxis: the reference direction projected onto the plane normal to . private static IfcVector3 FirstProjAxis(IfcVector3 z, IfcVector3? reference) { - foreach (var candidate in new[] { reference ?? IfcVector3.UnitX, IfcVector3.UnitX, IfcVector3.UnitZ }) + // Defaulted reference: (1,0,0), or (0,1,0) when Z is (1,0,0). A reference parallel to Z is invalid: use the default. + var defaultReference = (z - IfcVector3.UnitX).Length < 1e-9 ? IfcVector3.UnitY : IfcVector3.UnitX; + foreach (var candidate in new[] { reference ?? defaultReference, defaultReference, IfcVector3.UnitY }) { var x = (candidate - (z * IfcVector3.Dot(candidate, z))).Normalized(); if (x != IfcVector3.Zero) return x; diff --git a/tests/IfcIO.Tests/PlacementGeometryTests.cs b/tests/IfcIO.Tests/PlacementGeometryTests.cs index 3801f1f..8f81692 100644 --- a/tests/IfcIO.Tests/PlacementGeometryTests.cs +++ b/tests/IfcIO.Tests/PlacementGeometryTests.cs @@ -30,7 +30,7 @@ public class PlacementGeometryTests // Panel: triangle (0,0,0),(1,0,0),(0,1,0) at (10,20,0) in the storey (IfcFaceBasedSurfaceModel). // Pipe: 20x10 rectangle (profile rotated 90 degrees) extruded 1000 along the element X axis. // Fittings: one representation map (origin (0,0,100), triangle (0,0,0),(10,0,0),(0,10,0)) used three times; - // Fitting C mirrors it in X (Axis1 = -X with Axis2 omitted). Beam: an extrusion along world X inside a clipping result. + // Fitting C mirrors it in X (Axis1 = -X with Axis2 omitted); Fitting D has only Axis3 = +X, so X defaults to +Y. Beam: an extrusion along world X inside a clipping result. private static string BuildIfc(string lengthUnit, string wcsOrigin = "0.,0.,0.") => $""" ISO-10303-21; HEADER; @@ -120,6 +120,11 @@ private static string BuildIfc(string lengthUnit, string wcsOrigin = "0.,0.,0.") #345=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#346)); #346=IFCMAPPEDITEM(#300,#347); #347=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#34,$,#204,$,$); + #350=IFCFLOWFITTING('DYvctVUKr0kugbFTf53O9L',$,'Fitting D',$,$,#321,#353,$); + #353=IFCPRODUCTDEFINITIONSHAPE($,$,(#355)); + #355=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#356)); + #356=IFCMAPPEDITEM(#300,#357); + #357=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#204,$,#32); #600=IFCBEAM('CYvctVUKr0kugbFTf53O9L',$,'Beam',$,$,#321,#603,$); #603=IFCPRODUCTDEFINITIONSHAPE($,$,(#605)); #605=IFCSHAPEREPRESENTATION(#23,'Body','Clipping',(#606)); @@ -136,7 +141,7 @@ private static string BuildIfc(string lengthUnit, string wcsOrigin = "0.,0.,0.") #402=IFCAXIS2PLACEMENT3D(#403,#32,$); #403=IFCCARTESIANPOINT((1000.,0.,0.)); #410=IFCRELCONNECTSPORTTOELEMENT('9YvctVUKr0kugbFTf53O9L',$,$,$,#400,#200); - #500=IFCRELCONTAINEDINSPATIALSTRUCTURE('AYvctVUKr0kugbFTf53O9L',$,$,$,(#100,#200,#320,#330,#340,#600),#60); + #500=IFCRELCONTAINEDINSPATIALSTRUCTURE('AYvctVUKr0kugbFTf53O9L',$,$,$,(#100,#200,#320,#330,#340,#350,#600),#60); ENDSEC; END-ISO-10303-21; """; @@ -217,6 +222,34 @@ public void Open_MappedItem_AppliesMappingOriginAndTarget() AssertPoints(FittingBWorld, fittingB.Mesh.Vertices); } + [Fact] + public void Open_MappedItemWithAxis3AlongX_DefaultsXAxisToY() + { + var model = Open(BuildIfc(Millimetre)); + + // IfcFirstProjAxis: with Z = (1,0,0) the defaulted X axis is (0,1,0), so Y = Z x X = (0,0,1). + var fitting = Element(model, "Fitting D"); + Assert.NotNull(fitting.Mesh); + AssertPoints([new(100, 0, 0), new(100, 10, 0), new(100, 0, 10)], fitting.Mesh.Vertices); + Assert.Equal([0, 1, 2], fitting.Mesh.Indices); + } + + [Fact] + public void Open_UnitWithoutEnumValue_DropsParametricSolidsAndSavesRoundTrip() + { + var model = Open(BuildIfc(Centimetre)); + var pipe = Element(model, "Pipe"); + Assert.Null(pipe.Solid); + var box = pipe.Mesh!.BoundingBox; + + var reopened = Open(model.ToStepString()); + + Assert.Equal(IfcLengthUnit.Metre, reopened.LengthUnit); + var reopenedBox = Element(reopened, "Pipe").Mesh!.BoundingBox; + AssertPoints([box.Min, box.Max], [reopenedBox.Min, reopenedBox.Max], 1e-6); + AssertPoints([new(10.95, 20, 29.9), new(11.05, 30, 30.1)], [box.Min, box.Max], 1e-9); + } + [Fact] public void Open_MappedItemWithoutAxis2_MirrorsAndKeepsFacesOutward() {