Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 55 additions & 16 deletions src/IfcIO/Exporters/IfcGlbExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Text.Json;
using IfcIO.Geometry;
using IfcIO.Model;
using IfcIO.Schema;

namespace IfcIO.Exporters;

Expand Down Expand Up @@ -69,18 +70,26 @@ public static void Write(IfcModel model, Stream output)
}

var rootChildNodeIndices = new List<int>();
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<int>();

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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -165,6 +175,7 @@ public static void Write(IfcModel model, Stream output)
{
var dict = new Dictionary<string, object> { ["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(),
Expand Down Expand Up @@ -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<BufferViewDef> bufferViews,
List<AccessorDef> accessors,
List<MeshDef> 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)
Expand All @@ -270,15 +288,15 @@ 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)
{
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];
Expand All @@ -291,7 +309,7 @@ private static int PackMesh(
}

Span<byte> 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;
Expand Down Expand Up @@ -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]
Expand All @@ -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);
Expand All @@ -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)
{
Expand Down Expand Up @@ -411,6 +429,26 @@ private static int PackMesh(
return meshIdx;
}

/// <summary>IFC is Z-up in model length units; glTF is Y-up in metres: (x, y, z) -> (x, z, -y).</summary>
private static double[] ToGltf(IfcVector3 p, double metresPerUnit) =>
[p.X * metresPerUnit, p.Z * metresPerUnit, -p.Y * metresPerUnit];

/// <summary>Centre of the bounding box of all vertices, in model coordinates (zero when there are none).</summary>
private static IfcVector3 ComputeCentre(IEnumerable<IfcMesh> 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);
Expand Down Expand Up @@ -458,6 +496,7 @@ private sealed class NodeDef
public required string Name { get; init; }
public int? Mesh { get; init; }
public List<int>? Children { get; init; }
public double[]? Translation { get; init; }
}

private sealed class MaterialDef
Expand Down
64 changes: 64 additions & 0 deletions src/IfcIO/Geometry/IfcTransform.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
namespace IfcIO.Geometry;

/// <summary>
/// Affine 3D transform: <c>p' = X * p.X + Y * p.Y + Z * p.Z + Origin</c>.
/// The axis columns may be scaled, sheared or mirrored (IFC placements, mapping operators, extrusion shear).
/// </summary>
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));

/// <summary>True when the linear part is the identity (the transform is at most a translation).</summary>
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);

/// <summary>Returns the transform that applies <paramref name="inner"/> first and then this transform.</summary>
public IfcTransform Multiply(IfcTransform inner) =>
new(ApplyVector(inner.X), ApplyVector(inner.Y), ApplyVector(inner.Z), Apply(inner.Origin));

/// <summary>
/// Transforms the mesh vertices. Triangle winding is reversed when the transform mirrors,
/// so faces keep pointing outwards.
/// </summary>
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<int> 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;
}
Loading
Loading