diff --git a/CHANGELOG.md b/CHANGELOG.md index 131c52b..11e60ac 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`. 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. 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 ### 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..17a445e 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,12 @@ 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)), + keepParametric: geometryScale == 1.0); var defaultStorey = model.Storeys[0]; var allElementsMap = new Dictionary(); @@ -1897,7 +1913,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 +2501,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 +2780,56 @@ 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, 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; } = []; + + /// + /// 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); + + /// + /// 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 +2839,436 @@ 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 && ctx.KeepParametric) { - 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; + ctx.ChargeVertices(mesh.Vertices.Count); + 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) + { + // 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; + } + 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 +3390,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 +3547,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 +3669,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..8f81692 --- /dev/null +++ b/tests/IfcIO.Tests/PlacementGeometryTests.cs @@ -0,0 +1,459 @@ +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); 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; + 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,$,$); + #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)); + #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,#350,#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_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() + { + 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_NestedMappedItemAmplification_ThrowsInsteadOfExhaustingMemory() + { + // 8 levels of representation maps, each holding 10 instances of the previous one: 3 * 10^8 vertices if expanded. + var data = new StringBuilder(""" + #1=IFCPROJECT('0YvctVUKr0kugbFTf53O9L',$,'Bomb',$,$,$,$,$,#10); + #10=IFCUNITASSIGNMENT((#11)); + #11=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); + #20=IFCAXIS2PLACEMENT3D(#21,$,$); + #21=IFCCARTESIANPOINT((0.,0.,0.)); + #22=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#21,$,$); + #30=IFCCARTESIANPOINT((1.,0.,0.)); + #31=IFCCARTESIANPOINT((0.,1.,0.)); + #32=IFCPOLYLOOP((#21,#30,#31)); + #33=IFCFACEOUTERBOUND(#32,.T.); + #34=IFCFACE((#33)); + #35=IFCCLOSEDSHELL((#34)); + #36=IFCFACETEDBREP(#35); + #100=IFCSHAPEREPRESENTATION($,'Body','Brep',(#36)); + #101=IFCREPRESENTATIONMAP(#20,#100); + + """); + for (int level = 1; level <= 8; level++) + { + int rep = 100 + (level * 100); + var items = Enumerable.Range(1, 10).Select(i => $"#{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() + { + 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; + } + } +}