From a93cc9a90d4d8efa24ba35b2a3097f2ddab155b8 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:54:08 +0300 Subject: [PATCH 01/11] Record how a placed symbol reached an element's geometry The instance expansion now notes on the element's geometry whether the tight box of the GRep cut a placed symbol or left it out (ClipGeometryToBounds reports whether it changed the faces), whether a rescaled component was replaced by its box, and whether a GInstance resolved to nothing. The reader also notes whether the geometry of a 64-bit-id file was left undecoded because it was read without the R22 rewrite. The flags are internal and change no output. --- src/RvtIO/Model/Geometry.cs | 10 +++++ src/RvtIO/Model/RevitFileInfo.cs | 5 +++ src/RvtIO/Parsing/ElementBuilder.cs | 66 ++++++++++++++++++++--------- src/RvtIO/RvtFileReader.cs | 2 + 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/RvtIO/Model/Geometry.cs b/src/RvtIO/Model/Geometry.cs index 71bafdb..9876d12 100644 --- a/src/RvtIO/Model/Geometry.cs +++ b/src/RvtIO/Model/Geometry.cs @@ -64,6 +64,16 @@ internal bool HasCurvedFace(IReadOnlyCollection? nodes = null) => MeshNodes /// representation-map structure instead of duplicating the expanded mesh. internal List? ExactMappedInstances { get; set; } + /// A symbol placed into this geometry was cut by the tight box of the owning GRep, or left out because it lay + /// outside it (ElementBuilder.ClipGeometryToBounds): the faces are not the placed symbols as they are. + internal bool PlacedSymbolClipped { get; set; } + + /// A component placed at a rescaled placement was replaced by its tight box (ElementBuilder.ScaledComponentFaceBudget). + internal bool PlacedSymbolBox { get; set; } + + /// A GInstance of this geometry placed nothing: its symbol has no geometry. + internal bool UnresolvedSymbol { get; set; } + /// Number of stored GFace and Edge objects in the GRep block this geometry was /// built from (null when the block's object graph was not walked). Such node counts of a family instance's /// geometry are compared with its symbol's to decide whether the diff --git a/src/RvtIO/Model/RevitFileInfo.cs b/src/RvtIO/Model/RevitFileInfo.cs index 6c15ab0..78a21c7 100644 --- a/src/RvtIO/Model/RevitFileInfo.cs +++ b/src/RvtIO/Model/RevitFileInfo.cs @@ -33,6 +33,11 @@ public class RevitFileInfo [System.Text.Json.Serialization.JsonIgnore] internal bool GeometryUndecoded { get; set; } + /// The file is of the 64-bit-id layout and was read without , so no element + /// geometry was decoded. ( stays true for such a file read with the rewrite.) + [System.Text.Json.Serialization.JsonIgnore] + internal bool WideLayoutNotRewritten { get; set; } + /// GStyleElem id -> the ElementId of its GStyle material (-1 or 0 stand for no material) and the /// ElementId of its category, for the graphics styles whose block decoded exactly once. The material /// of a solid whose faces carry none comes from the style of the solid, diff --git a/src/RvtIO/Parsing/ElementBuilder.cs b/src/RvtIO/Parsing/ElementBuilder.cs index e45e566..c89f55b 100644 --- a/src/RvtIO/Parsing/ElementBuilder.cs +++ b/src/RvtIO/Parsing/ElementBuilder.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using RvtIO.Internal; using RvtIO.Lookup; using RvtIO.Model; @@ -17,12 +18,22 @@ private static void ExpandInstanceGeometry(Element elem, GRepData grep, int type elem.Geometry ??= new GeometryData(); int ownFaceCount = elem.Geometry.Faces.Count; var expandedTopLevel = new List(); + var placedBox = new StrongBox(); foreach (var instance in grep.Instances) { var placed = BuildPlacedSymbolGeometry( - instance.SymbolElemId, instance.Transform, state, new HashSet()); - if (placed == null) { LogUnresolvedSymbol(elem, instance.SymbolElemId, "no-geometry", state); continue; } - if (!state.IncludeNonVisibleQuantities) placed = ClipGeometryToBounds(placed, grep.BoundingBox); + instance.SymbolElemId, instance.Transform, state, new HashSet(), placedBox: placedBox); + if (placed == null) + { + elem.Geometry.UnresolvedSymbol = true; + LogUnresolvedSymbol(elem, instance.SymbolElemId, "no-geometry", state); + continue; + } + if (!state.IncludeNonVisibleQuantities) + { + placed = ClipGeometryToBounds(placed, grep.BoundingBox, out bool clipped); + if (clipped) elem.Geometry.PlacedSymbolClipped = true; + } if (placed == null) { LogUnresolvedSymbol(elem, instance.SymbolElemId, "clipped-away", state); continue; } AppendGeometry(elem.Geometry, placed, instance.NodeOrder); expandedTopLevel.Add(new GeoInstance @@ -32,6 +43,7 @@ private static void ExpandInstanceGeometry(Element elem, GRepData grep, int type }); } DeduplicateExactFaces(elem.Geometry); + if (placedBox.Value) elem.Geometry.PlacedSymbolBox = true; if (ownFaceCount == 0 && expandedTopLevel.Count > 0) elem.Geometry.ExactMappedInstances = expandedTopLevel; } @@ -46,11 +58,18 @@ private static void ExpandTransformOnlyInstanceGeometry( if ((elem.Geometry?.Faces?.Count ?? 0) >= 3) return; if (!state.FamilyInstanceTransforms.TryGetValue(elemId, out var fi)) return; if (fi.SymbolId <= 0 || fi.SymbolId == elem.Id) return; - var placed = BuildPlacedSymbolGeometry(fi.SymbolId, fi.Trf, state, new HashSet()); - if (placed == null) { LogUnresolvedSymbol(elem, fi.SymbolId, "transform-only", state); return; } + var placedBox = new StrongBox(); + var placed = BuildPlacedSymbolGeometry(fi.SymbolId, fi.Trf, state, new HashSet(), placedBox: placedBox); + if (placed == null) + { + if (elem.Geometry != null) elem.Geometry.UnresolvedSymbol = true; + LogUnresolvedSymbol(elem, fi.SymbolId, "transform-only", state); + return; + } elem.Geometry ??= new GeometryData(); bool hadOwnFaces = elem.Geometry.Faces.Count > 0; AppendGeometry(elem.Geometry, placed); + if (placedBox.Value) elem.Geometry.PlacedSymbolBox = true; elem.Geometry.Instances ??= new List(); elem.Geometry.Instances.Add(new GeoInstance { @@ -194,9 +213,10 @@ private static double[] TransformGeometryPoint(double[] point, double[] t) => t[11] + point[0] * t[6] + point[1] * t[7] + point[2] * t[8], ]; + /// Set to true when this symbol or a nested one is replaced by its tight box. internal static GeometryData? BuildPlacedSymbolGeometry( int symbolId, double[] transform, ParsingState state, HashSet path, - int scaledComponentFaceBudget = ScaledComponentFaceBudget) + int scaledComponentFaceBudget = ScaledComponentFaceBudget, StrongBox? placedBox = null) { if (symbolId <= 0 || transform.Length < 12 || !path.Add(symbolId)) return null; try @@ -214,6 +234,7 @@ static int MostFrequent(IEnumerable values) => values.GroupBy(value => valu .Select(group => group.Key).FirstOrDefault(-1); int material = MostFrequent(symbol.Faces.Select(face => face.RenderStyleId ?? 0).Where(id => id > 0)); int style = MostFrequent(symbol.Faces.Select(face => face.GeometryStyle).Where(id => id > 0)); + if (placedBox != null) placedBox.Value = true; return BuildPlacedBox(symbol.BoundingBox, transform, material > 0 ? material : null, style); } var result = new GeometryData(); @@ -230,7 +251,7 @@ static int MostFrequent(IEnumerable values) => values.GroupBy(value => valu { var childTransform = ComposeTransforms(transform, child.Transform); var placedChild = BuildPlacedSymbolGeometry( - child.SymbolElemId, childTransform, state, path, scaledComponentFaceBudget); + child.SymbolElemId, childTransform, state, path, scaledComponentFaceBudget, placedBox); if (placedChild != null) AppendGeometry(result, placedChild, child.NodeOrder); } @@ -353,7 +374,7 @@ private static double[] ComposeTransforms(double[] o, double[] i) /// A placement that keeps lengths and angles: orthonormal rows (a rotation, or a mirror image /// when the determinant is negative). /// - private static bool IsRigidPlacement(double[] t) + internal static bool IsRigidPlacement(double[] t) { if (t.Length < 12 || t.Any(v => !double.IsFinite(v))) return false; for (int a = 0; a < 3; a++) @@ -466,8 +487,14 @@ double[] Map(double[] p) /// symbol overrides can shorten the type geometry; using the declared box prevents the /// unmodified symbol from leaking past the occurrence. /// - internal static GeometryData? ClipGeometryToBounds(GeometryData source, double[]? bounds) + internal static GeometryData? ClipGeometryToBounds(GeometryData source, double[]? bounds) => + ClipGeometryToBounds(source, bounds, out _); + + /// True when the result is not the faces of as they are: a face was cut + /// or left out, or nothing is left (null). + internal static GeometryData? ClipGeometryToBounds(GeometryData source, double[]? bounds, out bool changed) { + changed = false; if (bounds is not { Length: 6 } || bounds.Any(value => !double.IsFinite(value)) || bounds[0] > bounds[3] || bounds[1] > bounds[4] || bounds[2] > bounds[5]) @@ -545,15 +572,16 @@ double[] Intersect(double[] a, double[] b) for (int oldIndex = 0; oldIndex < source.Faces.Count; oldIndex++) { var face = source.Faces[oldIndex]; - if (face.OuterVertices is not { Count: >= 3 } outer) continue; - bool changed = false; + if (face.OuterVertices is not { Count: >= 3 } outer) { changed = true; continue; } + bool faceChanged = false; var polygon = outer.Select(point => (double[])point.Clone()).ToList(); - polygon = ClipPlane(polygon, 0, bounds[0], keepGreater: true, ref changed); - polygon = ClipPlane(polygon, 0, bounds[3], keepGreater: false, ref changed); - polygon = ClipPlane(polygon, 1, bounds[1], keepGreater: true, ref changed); - polygon = ClipPlane(polygon, 1, bounds[4], keepGreater: false, ref changed); - polygon = ClipPlane(polygon, 2, bounds[2], keepGreater: true, ref changed); - polygon = ClipPlane(polygon, 2, bounds[5], keepGreater: false, ref changed); + polygon = ClipPlane(polygon, 0, bounds[0], keepGreater: true, ref faceChanged); + polygon = ClipPlane(polygon, 0, bounds[3], keepGreater: false, ref faceChanged); + polygon = ClipPlane(polygon, 1, bounds[1], keepGreater: true, ref faceChanged); + polygon = ClipPlane(polygon, 1, bounds[4], keepGreater: false, ref faceChanged); + polygon = ClipPlane(polygon, 2, bounds[2], keepGreater: true, ref faceChanged); + polygon = ClipPlane(polygon, 2, bounds[5], keepGreater: false, ref faceChanged); + if (faceChanged) changed = true; if (polygon.Count < 3) continue; int newIndex = result.Faces.Count; @@ -575,9 +603,9 @@ double[] Intersect(double[] a, double[] b) MeshMarker = face.SourceFace != 0 ? face.SourceFace : face.MeshMarker, }); oldToNew[oldIndex] = newIndex; - if (changed) clippedFaces.Add(oldIndex); + if (faceChanged) clippedFaces.Add(oldIndex); } - if (result.Faces.Count == 0) return null; + if (result.Faces.Count == 0) { changed = true; return null; } // the integrals of a node are those of the whole solid: a node with a clipped or dropped face has none if (source.NodeMeasures is { Count: > 0 } measures) { diff --git a/src/RvtIO/RvtFileReader.cs b/src/RvtIO/RvtFileReader.cs index d8567d4..c19c2b0 100644 --- a/src/RvtIO/RvtFileReader.cs +++ b/src/RvtIO/RvtFileReader.cs @@ -601,6 +601,7 @@ public RevitFileInfo Read(string rvtPath) { ElemTableIsExact = elemTableIsExact, GeometryUndecoded = state.WideSchema != null, + WideLayoutNotRewritten = state.WideSchema != null && !state.R22View, Metadata = new FileMetadata { StreamVersion = fileMetadata.GetValueOrDefault("StreamVersion"), @@ -883,6 +884,7 @@ internal static void MergeElemScanStringsInChunkOrder( { ElemTableIsExact = elemTableIsExact, GeometryUndecoded = state.WideSchema != null, + WideLayoutNotRewritten = state.WideSchema != null && !state.R22View, Metadata = new FileMetadata { StreamVersion = fileMetadata.GetValueOrDefault("StreamVersion"), From 93eea21cd312a2f6dc65f4e22f22a4ff764f9231 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:54:18 +0300 Subject: [PATCH 02/11] Share the product rules of the IFC export with other callers The phase reference, type element, transform-only family, non-physical category, wall opening and export phase checks of the product loop become internal members so that another export can apply the same rules. The IFC output is unchanged. --- src/RvtIO/Ifc/IfcExporter.ExportPhase.cs | 2 +- src/RvtIO/Ifc/IfcExporter.cs | 48 +++++++++++++++--------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs b/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs index ddd85a4..f91186b 100644 --- a/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs +++ b/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs @@ -9,7 +9,7 @@ public partial class IfcExporter /// explicit ) still needs only the set of phases: an element demolished in any phase of the project /// is demolished at or before the last one. That set is the ProjectPhase elements of the document; a document without any is /// not filtered. - private ExportPhaseFilter? BuildExportPhaseFilter(RevitFileInfo info) + internal ExportPhaseFilter? BuildExportPhaseFilter(RevitFileInfo info) { if (info.SerializedProjectPhaseIds is { } order) return ResolveActivePhase(info) is int phase ? ExportPhaseFilter.For(order, phase) : null; diff --git a/src/RvtIO/Ifc/IfcExporter.cs b/src/RvtIO/Ifc/IfcExporter.cs index 8321800..bd675c1 100644 --- a/src/RvtIO/Ifc/IfcExporter.cs +++ b/src/RvtIO/Ifc/IfcExporter.cs @@ -722,22 +722,9 @@ string SiteCoordinate(double feet) => object? value = e.Parameters.FirstOrDefault(p => p.Id == id)?.Value; return value is int n ? n : null; } - static bool HasProjectPhaseReference(Element e) - { - int? phase = IntParam(e, -1012100); // PHASE_CREATED - // Id 0 is a phase of Revit 2023 and later (the first project phase, a row of its - // element table); the earlier layouts number phases from 1. - return phase is > 0 && (e.References?.Contains(phase.Value) == true || e.SerializedCreatedPhaseId == phase.Value) - || phase == 0 && e.SerializedCreatedPhaseId == 0; - } - // Element types can carry preview/type geometry in the RVT stream, but they are definitions // rather than placed products. - var typeElementIds = new HashSet(); - foreach (var e in info.Elements) - foreach (var p in e.Parameters) - if (p.Id is -1002050 or -1002052 && p.Value is int typeId && typeId > 0) - typeElementIds.Add(typeId); + var typeElementIds = TypeElementIds(info.Elements); // A placed curtain panel/mullion references its parent wall. var curtainWallIds = new HashSet(); @@ -1649,8 +1636,7 @@ bool HasCompetingExactHostedWriterAuthority( continue; } if (CurrentProfile && HasOnlyUnfilledFacesCurrent(elem)) { Skip(elem, "only-unfilled-faces"); continue; } - bool transformOnlyFamily = elem.Transform != null && elem.TypeId > 0 - && elem.CategoryId is null or -2000080 or -2001000 or -2001100; + bool transformOnlyFamily = IsTransformOnlyFamily(elem); // Current profile: a wall record that is itself the type of another element (it names no project phase) // is a type definition; the type is written from the instances that use it. The legacy profile keeps // wall records, as wall type propagation is not reliable enough there. @@ -65129,7 +65115,7 @@ internal static HashSet ApplyDoorWindowOpeningIdentities( : null; } - private static bool IsWallRectangularOpening(Element element) => + internal static bool IsWallRectangularOpening(Element element) => element.Category?.Contains("OST_SWallRectOpening", StringComparison.OrdinalIgnoreCase) == true; // The door sub-element rule (= WindowOpeningRelVoid). @@ -69695,11 +69681,37 @@ List Groups(IEnumerable values) => values } + /// Whether the element names its created phase (PHASE_CREATED) in its own record graph. Preview and type records + /// inherit the parameter during parameter propagation without referencing that phase. + internal static bool HasProjectPhaseReference(Element e) + { + int? phase = e.Parameters.FirstOrDefault(p => p.Id == -1012100)?.Value is int n ? n : null; // PHASE_CREATED + // Id 0 is a phase of Revit 2023 and later (the first project phase, a row of its + // element table); the earlier layouts number phases from 1. + return phase is > 0 && (e.References?.Contains(phase.Value) == true || e.SerializedCreatedPhaseId == phase.Value) + || phase == 0 && e.SerializedCreatedPhaseId == 0; + } + + /// The elements that other elements name as their type (ELEM_TYPE_PARAM, ELEM_FAMILY_AND_TYPE_PARAM). + internal static HashSet TypeElementIds(IEnumerable elements) + { + var typeElementIds = new HashSet(); + foreach (var e in elements) + foreach (var p in e.Parameters) + if (p.Id is -1002050 or -1002052 && p.Value is int typeId && typeId > 0) + typeElementIds.Add(typeId); + return typeElementIds; + } + + /// A family instance placed by its FamilyInstance transform whose category is unknown or generic. + internal static bool IsTransformOnlyFamily(Element elem) => + elem.Transform != null && elem.TypeId > 0 && elem.CategoryId is null or -2000080 or -2001000 or -2001100; + /// /// Returns true if the Category string indicates a non-physical element (annotation, link, /// symbol, etc). /// - private static bool IsNonPhysicalCategory(string? category) + internal static bool IsNonPhysicalCategory(string? category) { if (string.IsNullOrEmpty(category)) return false; // null category handled by MapCategoryToIfcType // Annotations, symbols, links, views, grids, etc. From b31edc2a9aa2f1fb6576e616821eb82bc82ade90 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:54:19 +0300 Subject: [PATCH 03/11] Add ElementMeshes, the triangle meshes of the drawable elements ElementMeshes.Extract returns, for an opened document, the triangles of every drawable element in feet: its own triangles per material, or the placements of shared symbol meshes when the element is shown to be exactly its symbols placed (no own faces, nothing clipped, every symbol resolved, no box substitute, rigid transforms). A mirrored placement keeps the winding of its symbol and carries the mirror in its transform; own triangles are wound around the normals of their faces. Hidden Geometry nodes, face materials and the element selection follow the IFC export of the current profile. Each element reports its coverage, and the set reports a document whose geometry was not decoded. --- src/RvtIO/Meshes/ElementMeshOptions.cs | 9 + src/RvtIO/Meshes/ElementMeshes.cs | 319 ++++++++++ src/RvtIO/Meshes/MeshSet.cs | 118 ++++ src/RvtIO/Model/Geometry.cs | 4 +- tests/RvtIO.Tests/ElementMeshesSampleTests.cs | 177 ++++++ tests/RvtIO.Tests/ElementMeshesTests.cs | 577 ++++++++++++++++++ 6 files changed, 1203 insertions(+), 1 deletion(-) create mode 100644 src/RvtIO/Meshes/ElementMeshOptions.cs create mode 100644 src/RvtIO/Meshes/ElementMeshes.cs create mode 100644 src/RvtIO/Meshes/MeshSet.cs create mode 100644 tests/RvtIO.Tests/ElementMeshesSampleTests.cs create mode 100644 tests/RvtIO.Tests/ElementMeshesTests.cs diff --git a/src/RvtIO/Meshes/ElementMeshOptions.cs b/src/RvtIO/Meshes/ElementMeshOptions.cs new file mode 100644 index 0000000..e1852ff --- /dev/null +++ b/src/RvtIO/Meshes/ElementMeshOptions.cs @@ -0,0 +1,9 @@ +namespace RvtIO.Meshes; + +/// Options of . +public sealed class ElementMeshOptions +{ + /// Returns an element that is exactly its symbols placed as of shared symbol meshes + /// (true, the default). False gives every element its own triangles and leaves empty. + public bool UseSymbolPlacements { get; init; } = true; +} diff --git a/src/RvtIO/Meshes/ElementMeshes.cs b/src/RvtIO/Meshes/ElementMeshes.cs new file mode 100644 index 0000000..e54056c --- /dev/null +++ b/src/RvtIO/Meshes/ElementMeshes.cs @@ -0,0 +1,319 @@ +using RvtIO.Geometry.Meshing; +using RvtIO.Ifc; +using RvtIO.Model; +using RvtIO.Parsing; + +namespace RvtIO.Meshes; + +/// +/// Triangle meshes of the drawable model elements of an opened document, in feet, in the coordinates of the document (Revit +/// internal coordinates, Z up). The document is not read again: the meshes are made from the decoded faces it holds. +/// +/// +/// An element is drawable when the IFC export of the current profile would consider it a product by these of its rules: +/// it is not a symbol or type definition (a record kept only for its geometry, an element that other elements name as their type +/// and that names no project phase), not a group template, not in a secondary design option, +/// not outside the last project phase (demolished or not yet created), not a reveal, not a rectangular wall opening (a void), not +/// the container of a stacked wall (its member walls are drawn), not a record outside an exact element table, not owned by a +/// family, not of a non-physical category (annotations, rooms, spaces, ...), names its project phase (walls and family instances +/// of a generic category excepted), and not a void helper whose faces are all unfilled. An element whose geometry is exactly +/// other drawable elements placed (a stair of its runs and landings, a model group of its members) is left out, as those +/// elements are listed themselves; a placement of a drawable element is likewise left out of an element's placements. A +/// drawable element is listed when it has a geometry record, or, in a document whose geometry was not decoded, a bounding box. +/// Faces of Geometry nodes that Revit does not take (another detail level) are left out when the element has faces of +/// nodes it takes. Planar faces with holes are triangulated in their plane; curved surfaces are the dense polygons the reader +/// decoded for them. Every triangle is counter-clockwise around the normal of its face. +/// The material of a face is its own material, else the material of the symbol face it was placed from, else the material +/// of the graphics style of its Geometry node, else none (0). Only elements of the Materials category count as materials. +/// +public static class ElementMeshes +{ + private const int MaterialsCategoryId = -2000700; + private const int RevealsCategoryId = -2000182; + private const int WallsCategoryId = -2000011; + private const double RigidTolerance = 1e-6; + + /// The meshes of the drawable elements of . + /// is null. + public static MeshSet Extract(RvtDocument document, ElementMeshOptions? options = null) + { + ArgumentNullException.ThrowIfNull(document); + options ??= new ElementMeshOptions(); + var info = document.RawData; + var drawable = DrawableElements(info); + var drawableIds = drawable.Select(item => item.Element.Id).ToHashSet(); + var symbolCache = new Dictionary(); + var placedSymbols = new Dictionary>(); + + bool IsMaterial(int id) => document.GetElement(id)?.CategoryId == MaterialsCategoryId; + int MaterialOf(GeoFace face) + { + if (face.RenderStyleId is > 0 and int own && IsMaterial(own)) return own; + if (face.NestedRenderStyleId is > 0 and int nested && IsMaterial(nested)) return nested; + if (face.GeometryStyle > 0 && info.GraphicsStyles?.TryGetValue(face.GeometryStyle, out var style) == true + && style.Material > 0 && IsMaterial(style.Material)) + return style.Material; + return 0; + } + + SymbolMeshes? SymbolOf(int symbolId) + { + if (symbolCache.TryGetValue(symbolId, out var cached)) return cached; + SymbolMeshes? symbol = null; + // The symbol element holds the symbol in the frame of its family; it is the placed symbol only when its own nested + // symbols were placed whole. + if (document.GetElement(symbolId)?.Geometry is { PlacedSymbolClipped: false, PlacedSymbolBox: false } geometry) + { + var (meshes, failed) = Triangulate(geometry.Faces, MaterialOf); + if (meshes.Count > 0) + symbol = new SymbolMeshes(meshes, failed, geometry.Faces.Any(face => face.GeometryNode > 0), + geometry.Faces.Any(face => face.GeometryNode < 0)); + } + symbolCache[symbolId] = symbol; + return symbol; + } + + // The placements of an element whose geometry is exactly its symbols placed, or null. + (List Placements, int Failed)? PlacementsOf(GeometryData geometry) + { + if (geometry.ExactMappedInstances is not { Count: > 0 } mapped || geometry.PlacedSymbolClipped || geometry.PlacedSymbolBox) + return null; + bool visibleNodes = geometry.Faces.Any(face => face.GeometryNode > 0); + var placements = new List(mapped.Count); + int failed = 0; + foreach (var instance in mapped) + { + if (drawableIds.Contains(instance.SymbolId)) continue; // drawn as an element of its own + if (!ElementBuilder.IsRigidPlacement(instance.Transform)) return null; + var transform = MeshTransform.FromRowMajor(instance.Transform); + if (Math.Abs(Math.Abs(transform.Determinant) - 1) > RigidTolerance) return null; + if (SymbolOf(instance.SymbolId) is not { } symbol) return null; + // The hidden nodes of the symbol are dropped in the element but not in the symbol alone. + if (visibleNodes && !symbol.HasVisibleNode && symbol.HasHiddenNode) return null; + placements.Add(new SymbolPlacement(instance.SymbolId, transform)); + failed += symbol.Failed; + } + return placements.Count > 0 ? (placements, failed) : null; + } + + var elements = new List(); + foreach (var (element, body) in drawable) + { + if (body == null) + { + elements.Add(new ElementMesh { ElementId = element.Id, Coverage = MeshCoverage.Undecoded }); + continue; + } + if (options.UseSymbolPlacements && ReferenceEquals(body, element.Geometry) && PlacementsOf(body) is var (placements, failedInSymbols)) + { + foreach (var placement in placements) + placedSymbols.TryAdd(placement.SymbolId, symbolCache[placement.SymbolId]!.Meshes); + elements.Add(new ElementMesh + { + ElementId = element.Id, + Placements = placements, + Coverage = CoverageOf(true, body, failedInSymbols), + UntriangulatedFaces = failedInSymbols, + }); + continue; + } + var (meshes, failed) = Triangulate(body.Faces, MaterialOf); + elements.Add(new ElementMesh + { + ElementId = element.Id, + Meshes = meshes, + Coverage = CoverageOf(meshes.Count > 0, body, failed), + UntriangulatedFaces = failed, + }); + } + return new MeshSet { Elements = elements, Symbols = placedSymbols, GeometryUndecoded = info.WideLayoutNotRewritten }; + } + + private static MeshCoverage CoverageOf(bool drawn, GeometryData geometry, int failed) => + !drawn ? MeshCoverage.NoGeometry + : geometry.PlacedSymbolBox ? MeshCoverage.BoxSubstitute + : failed > 0 || geometry.UnresolvedSymbol ? MeshCoverage.Partial + : MeshCoverage.Complete; + + private sealed record SymbolMeshes(IReadOnlyList Meshes, int Failed, bool HasVisibleNode, bool HasHiddenNode); + + /// The drawable elements (see ) in document order, each with the geometry that is its body: + /// the geometry of its computed symbol for a void helper that has one, its own geometry otherwise (null in a document whose + /// geometry was not decoded). + internal static List<(Element Element, GeometryData? Body)> DrawableElements(RevitFileInfo info) + { + var designOptions = DesignOptions.Of(info.Elements); + var phaseFilter = new IfcExporter().BuildExportPhaseFilter(info); + var typeElementIds = IfcExporter.TypeElementIds(info.Elements); + var elementsById = new Dictionary(); + foreach (var element in info.Elements) elementsById.TryAdd(element.Id, element); + var drawable = new List<(Element, GeometryData?)>(); + foreach (var element in info.Elements) + { + if (element.IsBlockOnly || element.IsGroupTemplate + || designOptions.InNonPrimaryOption(element) == true || phaseFilter?.Excludes(element) == true + || element.CategoryId == RevealsCategoryId || IfcExporter.IsWallRectangularOpening(element) + || IfcExporter.IsValidatedStackedWallContainer(element, elementsById) + || info.ElemTableIsExact && element.IsDiscovered + || element.SerializedFamilyId is > 0 + || IfcExporter.IsNonPhysicalCategory(element.Category)) + continue; + bool namesPhase = IfcExporter.HasProjectPhaseReference(element) || IfcExporter.IsTransformOnlyFamily(element); + if (!namesPhase && (typeElementIds.Contains(element.Id) || element.CategoryId != WallsCategoryId)) continue; + GeometryData? body = element.Geometry; + if (element.SerializedComputedSymbolGeometry is { } computed) body = computed; + else if (IfcExporter.HasOnlyUnfilledFacesCurrent(element)) continue; + if (body != null || info.WideLayoutNotRewritten && element.BoundingBox != null) drawable.Add((element, body)); + } + // A stair places its runs and landings, a model group its members: those are drawn as elements of their own. + var ids = drawable.Select(item => item.Item1.Id).ToHashSet(); + return drawable.Where(item => item.Item2?.ExactMappedInstances is not { Count: > 0 } mapped + || !mapped.All(instance => ids.Contains(instance.SymbolId))).ToList(); + } + + /// The triangles of , one mesh per material in the order the materials first appear, and the + /// number of faces that could not be triangulated. The faces of untaken Geometry nodes (a negative node id) are left out when + /// a face of a taken node is there. + internal static (List Meshes, int Failed) Triangulate(IReadOnlyList faces, Func materialOf) + { + bool dropUntakenNodes = faces.Any(face => face.GeometryNode > 0); + var builders = new List(); + var builderOfMaterial = new Dictionary(); + int failed = 0; + foreach (var face in faces) + { + if (dropUntakenNodes && face.GeometryNode < 0) continue; + int material = materialOf(face); + if (!builderOfMaterial.TryGetValue(material, out var builder)) + { + builder = new MeshBuilder(material); + builderOfMaterial[material] = builder; + builders.Add(builder); + } + if (!AppendFace(face, builder)) failed++; + } + return (builders.Where(builder => builder.Indices.Count > 0).Select(builder => builder.ToMesh()).ToList(), failed); + } + + /// Appends the triangles of one polygon face with its holes; false when the face has no polygon, its triangles do not + /// cover its area, or (for a face with holes) they cover more than its area: a hole that is filled. A face of no area is drawn + /// as nothing and counts as triangulated. + internal static bool AppendFace(GeoFace face, MeshBuilder target) + { + if (face.OuterVertices is not { Count: >= 3 } outer) return false; + var rings = new List> { outer }; + if (face.InnerVertices != null) rings.AddRange(face.InnerVertices.Where(ring => ring is { Count: >= 3 })); + foreach (var ring in rings) + foreach (var point in ring) + if (point.Length < 3 || !double.IsFinite(point[0]) || !double.IsFinite(point[1]) || !double.IsFinite(point[2])) + return false; + + // The plane of the face: the Newell normal of its outer ring (relative to its first corner, so that large coordinates keep + // their precision), turned to the side of the face normal. + var origin = outer[0]; + double nx = 0, ny = 0, nz = 0; + for (int index = 0; index < outer.Count; index++) + { + var a = outer[index]; + var b = outer[(index + 1) % outer.Count]; + double ay = a[1] - origin[1], az = a[2] - origin[2], ax = a[0] - origin[0]; + double by = b[1] - origin[1], bz = b[2] - origin[2], bx = b[0] - origin[0]; + nx += (ay - by) * (az + bz); + ny += (az - bz) * (ax + bx); + nz += (ax - bx) * (ay + by); + } + double length = Math.Sqrt(nx * nx + ny * ny + nz * nz); + if (!(length > 0)) return true; + nx /= length; ny /= length; nz /= length; + if (face.Normal is { Length: >= 3 } given && nx * given[0] + ny * given[1] + nz * given[2] < 0) + { + nx = -nx; ny = -ny; nz = -nz; + } + + // In-plane axes u, v with u x v = n. + double hx = 0, hy = 0, hz = 0; + if (Math.Abs(nx) <= Math.Abs(ny) && Math.Abs(nx) <= Math.Abs(nz)) hx = 1; + else if (Math.Abs(ny) <= Math.Abs(nz)) hy = 1; + else hz = 1; + double ux = hy * nz - hz * ny, uy = hz * nx - hx * nz, uz = hx * ny - hy * nx; + double uLength = Math.Sqrt(ux * ux + uy * uy + uz * uz); + ux /= uLength; uy /= uLength; uz /= uLength; + double vx = ny * uz - nz * uy, vy = nz * ux - nx * uz, vz = nx * uy - ny * ux; + + var points = new List<(double X, double Y)>(); + var loops = new List(rings.Count); + var outerFlags = new bool[rings.Count]; + double expectedArea = 0; + for (int ringIndex = 0; ringIndex < rings.Count; ringIndex++) + { + var ring = rings[ringIndex]; + var loop = new int[ring.Count]; + var projected = new (double X, double Y)[ring.Count]; + for (int index = 0; index < ring.Count; index++) + { + double dx = ring[index][0] - origin[0], dy = ring[index][1] - origin[1], dz = ring[index][2] - origin[2]; + projected[index] = (dx * ux + dy * uy + dz * uz, dx * vx + dy * vy + dz * vz); + loop[index] = points.Count; + points.Add(projected[index]); + } + loops.Add(loop); + outerFlags[ringIndex] = ringIndex == 0; + double ringArea = Math.Abs(GeTess.SignedArea(projected)); + expectedArea += ringIndex == 0 ? ringArea : -ringArea; + } + double outerArea = Math.Abs(GeTess.SignedArea(loops[0].Select(index => points[index]).ToList())); + + var triangles = GeTess.TriangulateProfile(points, loops, outerFlags); + double coveredArea = 0; + if (triangles.Count > 0) + { + int first = target.VertexCount; + foreach (var ring in rings) + foreach (var point in ring) + target.AddVertex(point, nx, ny, nz); + foreach (var (a, b, c) in triangles) + { + var (pa, pb, pc) = (points[a], points[b], points[c]); + double cross = (pb.X - pa.X) * (pc.Y - pa.Y) - (pb.Y - pa.Y) * (pc.X - pa.X); + coveredArea += Math.Abs(cross) / 2; + // Counter-clockwise in (u, v) is counter-clockwise around n. + if (cross < 0) target.AddTriangle(first + a, first + c, first + b); + else target.AddTriangle(first + a, first + b, first + c); + } + } + // Without holes, more area than the outline is a fold of a polygon that is not quite planar (the dense polygons of a curved + // surface), which still covers the face. + double tolerance = 1e-6 * outerArea + 1e-12; + return coveredArea >= expectedArea - tolerance && (rings.Count == 1 || coveredArea <= expectedArea + tolerance); + } + + /// The vertices and triangles of one material. + internal sealed class MeshBuilder(int materialId) + { + private readonly List _positions = []; + private readonly List _normals = []; + + public List Indices { get; } = []; + + public int VertexCount => _positions.Count / 3; + + public void AddVertex(double[] point, double nx, double ny, double nz) + { + _positions.Add(point[0]); _positions.Add(point[1]); _positions.Add(point[2]); + _normals.Add(nx); _normals.Add(ny); _normals.Add(nz); + } + + public void AddTriangle(int a, int b, int c) + { + Indices.Add(a); Indices.Add(b); Indices.Add(c); + } + + public TriangleMesh ToMesh() => new() + { + Positions = _positions.ToArray(), + Indices = Indices.ToArray(), + Normals = _normals.ToArray(), + MaterialId = materialId, + }; + } +} diff --git a/src/RvtIO/Meshes/MeshSet.cs b/src/RvtIO/Meshes/MeshSet.cs new file mode 100644 index 0000000..ab435fe --- /dev/null +++ b/src/RvtIO/Meshes/MeshSet.cs @@ -0,0 +1,118 @@ +namespace RvtIO.Meshes; + +/// The triangle meshes of the drawable model elements of a document (see ). All +/// coordinates are in feet. +public sealed class MeshSet +{ + /// One entry per drawable element, in the order of . + public IReadOnlyList Elements { get; init; } = []; + + /// The triangles of every symbol that an element places (), by symbol element id, + /// in the frame of the family. Only placed symbols are listed. + public IReadOnlyDictionary> Symbols { get; init; } = new Dictionary>(); + + /// The document was read without decoding its geometry (a file with 64-bit element ids read without + /// ); its elements are listed with . + public bool GeometryUndecoded { get; init; } +} + +/// The geometry of one element: its own triangles in the coordinates of the document, plus symbols placed whole. +public sealed class ElementMesh +{ + /// The id of the element. + public int ElementId { get; init; } + + /// The triangles of the element itself, one mesh per material, in the coordinates of the document. + public IReadOnlyList Meshes { get; init; } = []; + + /// Symbols () that make up the rest of the element, each drawn by its transform. A placement + /// is given only when the element's geometry is exactly its symbols placed rigidly; any other element has its own triangles. + public IReadOnlyList Placements { get; init; } = []; + + /// How completely and represent the element. + public MeshCoverage Coverage { get; init; } + + /// Faces of the element (or of its placed symbols, once per placement) that could not be triangulated. + public int UntriangulatedFaces { get; init; } +} + +/// How completely an represents its element. When several apply, the first in the order +/// , , , is reported. +public enum MeshCoverage +{ + /// Every decoded face is triangulated. + Complete, + + /// Some faces could not be triangulated, or a symbol the element places has no geometry. + Partial, + + /// A symbol placed at a rescaled placement was replaced by its bounding box. + BoxSubstitute, + + /// The element has a geometry record but nothing in it could be drawn. + NoGeometry, + + /// The geometry of the document was not decoded (). + Undecoded, +} + +/// Triangles that share one material. +public sealed class TriangleMesh +{ + /// Vertex coordinates x, y, z in feet, three values per vertex. + public double[] Positions { get; init; } = []; + + /// Three vertex indices per triangle, counter-clockwise seen from the side the normals point to. + public int[] Indices { get; init; } = []; + + /// Unit normal x, y, z per vertex. Vertices are not shared between faces, so a face keeps its own normal and the + /// edges between faces stay sharp. + public double[] Normals { get; init; } = []; + + /// The material element id of the triangles, 0 for none. + public int MaterialId { get; init; } +} + +/// A symbol drawn by a transform. +/// The key of the symbol in . +/// The transform from the frame of the family to the coordinates of the document. +public readonly record struct SymbolPlacement(int SymbolId, MeshTransform Transform); + +/// A point or a direction in feet. +public readonly record struct MeshVector(double X, double Y, double Z); + +/// +/// A transform from the frame of a family to the coordinates of the document: a point p maps to +/// Origin + BasisX * p.X + BasisY * p.Y + BasisZ * p.Z. The basis vectors are the images of the family axes. +/// A negative is a mirror image: a renderer that culls back faces reverses the winding of the +/// symbol's triangles for it. +/// +/// A t holds the 3x3 matrix row by row and then the translation, so the +/// basis vectors are its columns: BasisX = (t[0], t[3], t[6]), BasisY = (t[1], t[4], t[7]), BasisZ = (t[2], t[5], t[8]) and +/// Origin = (t[9], t[10], t[11]) (see ). +public readonly record struct MeshTransform(MeshVector Origin, MeshVector BasisX, MeshVector BasisY, MeshVector BasisZ) +{ + /// The transform of the 12 values of a . + /// Fewer than 12 values. + public static MeshTransform FromRowMajor(IReadOnlyList transform) + { + ArgumentNullException.ThrowIfNull(transform); + if (transform.Count < 12) throw new ArgumentException("A transform has 12 values.", nameof(transform)); + return new(new(transform[9], transform[10], transform[11]), + new(transform[0], transform[3], transform[6]), + new(transform[1], transform[4], transform[7]), + new(transform[2], transform[5], transform[8])); + } + + /// The determinant of the basis: 1 for a rotation, -1 for a mirror image. + public double Determinant => + BasisX.X * (BasisY.Y * BasisZ.Z - BasisZ.Y * BasisY.Z) + - BasisY.X * (BasisX.Y * BasisZ.Z - BasisZ.Y * BasisX.Z) + + BasisZ.X * (BasisX.Y * BasisY.Z - BasisY.Y * BasisX.Z); + + /// The image of a point of the family frame. + public MeshVector Apply(MeshVector point) => new( + Origin.X + BasisX.X * point.X + BasisY.X * point.Y + BasisZ.X * point.Z, + Origin.Y + BasisX.Y * point.X + BasisY.Y * point.Y + BasisZ.Y * point.Z, + Origin.Z + BasisX.Z * point.X + BasisY.Z * point.Y + BasisZ.Z * point.Z); +} diff --git a/src/RvtIO/Model/Geometry.cs b/src/RvtIO/Model/Geometry.cs index 9876d12..bb2e1a0 100644 --- a/src/RvtIO/Model/Geometry.cs +++ b/src/RvtIO/Model/Geometry.cs @@ -99,7 +99,9 @@ public class GeoInstance { /// The id of the symbol that is placed. public int SymbolId { get; set; } - /// The 12 values of the placement transform. + /// The 12 values of the placement transform: the 3x3 matrix row by row, then the translation, so a point p of the + /// symbol maps to (t[9] + p.X t[0] + p.Y t[1] + p.Z t[2], t[10] + p.X t[3] + ..., t[11] + p.X t[6] + ...) and the basis vectors are the + /// columns (see ). public double[] Transform { get; set; } = new double[12]; /// Where the GInstance stands among the nodes of its GRep in the depth-first walk (internal order key). diff --git a/tests/RvtIO.Tests/ElementMeshesSampleTests.cs b/tests/RvtIO.Tests/ElementMeshesSampleTests.cs new file mode 100644 index 0000000..12fcf4f --- /dev/null +++ b/tests/RvtIO.Tests/ElementMeshesSampleTests.cs @@ -0,0 +1,177 @@ +using RvtIO.Meshes; +using Xunit; + +namespace RvtIO.Tests; + +/// The public triangle meshes () on private project files: a placement is exactly the +/// element's own geometry, triangles follow their normals, and the result does not change between two runs. +public class ElementMeshesSampleTests +{ + private static readonly Lazy Sample2022 = new(() => RvtDocument.Open(TestFiles.Find("Test_2022.rvt"))); + + private static RvtDocument Open2022() + { + TestFiles.Find("Test_2022.rvt"); // skips when the file is missing + return Sample2022.Value; + } + + private static (double[] Min, double[] Max, double Area) Measure(IEnumerable meshes, MeshTransform? transform = null) + { + double[] min = [double.MaxValue, double.MaxValue, double.MaxValue], max = [double.MinValue, double.MinValue, double.MinValue]; + double area = 0; + foreach (var mesh in meshes) + { + var points = new MeshVector[mesh.Positions.Length / 3]; + for (int vertex = 0; vertex < points.Length; vertex++) + { + var point = new MeshVector(mesh.Positions[3 * vertex], mesh.Positions[3 * vertex + 1], mesh.Positions[3 * vertex + 2]); + points[vertex] = transform is { } placed ? placed.Apply(point) : point; + } + for (int index = 0; index < mesh.Indices.Length; index += 3) + { + var (a, b, c) = (points[mesh.Indices[index]], points[mesh.Indices[index + 1]], points[mesh.Indices[index + 2]]); + double ux = b.X - a.X, uy = b.Y - a.Y, uz = b.Z - a.Z, vx = c.X - a.X, vy = c.Y - a.Y, vz = c.Z - a.Z; + double nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; + area += Math.Sqrt(nx * nx + ny * ny + nz * nz) / 2; + foreach (var p in new[] { a, b, c }) + { + min[0] = Math.Min(min[0], p.X); min[1] = Math.Min(min[1], p.Y); min[2] = Math.Min(min[2], p.Z); + max[0] = Math.Max(max[0], p.X); max[1] = Math.Max(max[1], p.Y); max[2] = Math.Max(max[2], p.Z); + } + } + } + return (min, max, area); + } + + private static (double[] Min, double[] Max, double Area) MeasurePlacements(MeshSet set, ElementMesh element) + { + var parts = element.Placements.Select(placement => Measure(set.Symbols[placement.SymbolId], placement.Transform)).ToList(); + return (Enumerable.Range(0, 3).Select(axis => parts.Min(part => part.Min[axis])).ToArray(), + Enumerable.Range(0, 3).Select(axis => parts.Max(part => part.Max[axis])).ToArray(), parts.Sum(part => part.Area)); + } + + /// The placed symbols cover what the element's own faces cover: the same box and the same area. + private static void AssertPlacementsAreTheElement(RvtDocument document, MeshSet set, ElementMesh element) + { + var placed = MeasurePlacements(set, element); + var own = Measure(ElementMeshes.Triangulate(document.GetElement(element.ElementId)!.Geometry!.Faces, _ => 0).Meshes); + for (int axis = 0; axis < 3; axis++) + { + Assert.Equal(own.Min[axis], placed.Min[axis], 6); + Assert.Equal(own.Max[axis], placed.Max[axis], 6); + } + Assert.Equal(own.Area, placed.Area, 6); + } + + private static void AssertWindingFollowsNormals(TriangleMesh mesh) + { + for (int index = 0; index < mesh.Indices.Length; index += 3) + { + int a = 3 * mesh.Indices[index], b = 3 * mesh.Indices[index + 1], c = 3 * mesh.Indices[index + 2]; + double ux = mesh.Positions[b] - mesh.Positions[a], uy = mesh.Positions[b + 1] - mesh.Positions[a + 1], uz = mesh.Positions[b + 2] - mesh.Positions[a + 2]; + double vx = mesh.Positions[c] - mesh.Positions[a], vy = mesh.Positions[c + 1] - mesh.Positions[a + 1], vz = mesh.Positions[c + 2] - mesh.Positions[a + 2]; + double dot = (uy * vz - uz * vy) * mesh.Normals[a] + (uz * vx - ux * vz) * mesh.Normals[a + 1] + (ux * vy - uy * vx) * mesh.Normals[a + 2]; + // A triangle of no area (three corners on a line, up to rounding) has no orientation. + double scale = Math.Sqrt((ux * ux + uy * uy + uz * uz) * (vx * vx + vy * vy + vz * vz)); + Assert.True(dot >= -1e-9 * scale, $"triangle {index / 3} runs against its normal"); + } + } + + [SkippableFact] + public void DoorInAWall_IsAPlacementOfItsSymbol() + { + var document = RvtDocument.Open(TestFiles.Find("test_door_in_wall.rvt")); + + var set = ElementMeshes.Extract(document); + + Assert.Equal(2, set.Elements.Count); + Assert.All(set.Elements, element => Assert.Equal(MeshCoverage.Complete, element.Coverage)); + var door = Assert.Single(set.Elements, element => document.GetElement(element.ElementId)!.CategoryId == -2000023); + var wall = Assert.Single(set.Elements, element => document.GetElement(element.ElementId)!.CategoryId == -2000011); + Assert.Empty(door.Meshes); + var placement = Assert.Single(door.Placements); + Assert.Equal(1, Math.Abs(placement.Transform.Determinant), 6); + Assert.Equal([placement.SymbolId], set.Symbols.Keys); + AssertPlacementsAreTheElement(document, set, door); + Assert.Empty(wall.Placements); + Assert.NotEmpty(wall.Meshes); + } + + [SkippableFact] + public void Sample_PlacementsAreTheirElementsAndTrianglesFollowTheirNormals() + { + var document = Open2022(); + + var set = ElementMeshes.Extract(document); + + Assert.False(set.GeometryUndecoded); + Assert.Equal(set.Elements.Count, set.Elements.Select(element => element.ElementId).Distinct().Count()); + Assert.DoesNotContain(set.Elements, element => element.Coverage == MeshCoverage.Undecoded); + var placed = set.Elements.Where(element => element.Placements.Count > 0).ToList(); + Assert.NotEmpty(placed); + Assert.Equal(placed.SelectMany(element => element.Placements).Select(placement => placement.SymbolId).ToHashSet(), + set.Symbols.Keys.ToHashSet()); + foreach (var element in placed) + { + Assert.Empty(element.Meshes); + AssertPlacementsAreTheElement(document, set, element); + } + foreach (var mesh in set.Elements.SelectMany(element => element.Meshes).Concat(set.Symbols.Values.SelectMany(meshes => meshes))) + { + Assert.Equal(mesh.Positions.Length, mesh.Normals.Length); + Assert.Equal(0, mesh.Indices.Length % 3); + AssertWindingFollowsNormals(mesh); + } + // Every door and window of the sample is its symbol placed. + foreach (int category in new[] { -2000023, -2000014 }) + { + var inserts = set.Elements.Where(element => document.GetElement(element.ElementId)!.CategoryId == category).ToList(); + Assert.NotEmpty(inserts); + Assert.All(inserts, element => Assert.NotEmpty(element.Placements)); + } + } + + [SkippableFact] + public void Sample_GivesTheSameMeshesTwice() + { + var document = Open2022(); + + var first = ElementMeshes.Extract(document); + var second = ElementMeshes.Extract(document); + + Assert.Equal(first.Elements.Select(element => (element.ElementId, element.Coverage, element.Placements.Count, element.UntriangulatedFaces, + element.Meshes.Sum(mesh => mesh.Indices.Length))), + second.Elements.Select(element => (element.ElementId, element.Coverage, element.Placements.Count, element.UntriangulatedFaces, + element.Meshes.Sum(mesh => mesh.Indices.Length)))); + Assert.Equal(first.Symbols.Keys.Order(), second.Symbols.Keys.Order()); + } + + [SkippableFact] + public void FileOfALaterRelease_ReadWithoutTheRewrite_IsUndecoded() + { + var document = RvtDocument.Open(TestFiles.Find("Test_2025.rvt")); + + var set = ElementMeshes.Extract(document); + + Assert.True(set.GeometryUndecoded); + Assert.NotEmpty(set.Elements); + Assert.All(set.Elements, element => + { + Assert.Equal(MeshCoverage.Undecoded, element.Coverage); + Assert.Empty(element.Meshes); + Assert.Empty(element.Placements); + }); + } + + [SkippableFact] + public void FileOfALaterRelease_ReadWithTheRewrite_IsDecoded() + { + var document = RvtDocument.Open(TestFiles.Find("Test_2025.rvt"), new RvtReaderOptions { R22Layout = true }); + + var set = ElementMeshes.Extract(document); + + Assert.False(set.GeometryUndecoded); + Assert.Contains(set.Elements, element => element.Placements.Count > 0); + Assert.Contains(set.Elements, element => element.Meshes.Count > 0); + } +} diff --git a/tests/RvtIO.Tests/ElementMeshesTests.cs b/tests/RvtIO.Tests/ElementMeshesTests.cs new file mode 100644 index 0000000..d3212d2 --- /dev/null +++ b/tests/RvtIO.Tests/ElementMeshesTests.cs @@ -0,0 +1,577 @@ +using System.Runtime.CompilerServices; +using RvtIO.Internal; +using RvtIO.Meshes; +using RvtIO.Model; +using RvtIO.Parsing; +using Xunit; + +namespace RvtIO.Tests; + +/// The public triangle meshes () on synthetic geometry: triangulation, materials, hidden +/// nodes, the decision between a symbol placement and the element's own triangles, and winding. +public class ElementMeshesTests +{ + private const int Phase = 7; + private const int SymbolId = 2; + private const int InstanceId = 1; + private static readonly double[] Identity = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]; + + // ---- synthetic model ---- + + /// An element the drawable rules keep: a generic model that names its project phase. + private static Element Product(int id, GeometryData? geometry) => new() + { + Id = id, + CategoryId = -2000151, + Category = "OST_GenericModel", + Geometry = geometry, + Parameters = [new Parameter { Id = -1012100, Value = Phase }], + SerializedCreatedPhaseId = Phase, + }; + + private static RvtDocument Document(params Element[] elements) => RvtDocument.FromInfo(new RevitFileInfo { Elements = [.. elements] }); + + private static GeoFace Face(double[][] ring, double[] normal, int node = 1) => + new() { OuterVertices = [.. ring], Normal = normal, GeometryNode = node }; + + /// The six faces of the box [min, max], counter-clockwise seen from outside, normals outward. + private static List<(double[][] Ring, double[] Normal)> BoxFaces(double[] min, double[] max) + { + double[] C(int x, int y, int z) => [x == 0 ? min[0] : max[0], y == 0 ? min[1] : max[1], z == 0 ? min[2] : max[2]]; + return + [ + ([C(0, 0, 0), C(0, 1, 0), C(1, 1, 0), C(1, 0, 0)], [0, 0, -1]), + ([C(0, 0, 1), C(1, 0, 1), C(1, 1, 1), C(0, 1, 1)], [0, 0, 1]), + ([C(0, 0, 0), C(1, 0, 0), C(1, 0, 1), C(0, 0, 1)], [0, -1, 0]), + ([C(0, 1, 0), C(0, 1, 1), C(1, 1, 1), C(1, 1, 0)], [0, 1, 0]), + ([C(0, 0, 0), C(0, 0, 1), C(0, 1, 1), C(0, 1, 0)], [-1, 0, 0]), + ([C(1, 0, 0), C(1, 1, 0), C(1, 1, 1), C(1, 0, 1)], [1, 0, 0]), + ]; + } + + private static GRepData BoxBlock(double[] min, double[] max, int? material = null) => new() + { + BoundingBox = [.. min, .. max], + Faces = BoxFaces(min, max).Select(face => new GRepFace + { + Vertices = [.. face.Ring], + Origin = face.Ring[0], + XVector = face.Normal, // the decoder keeps the polygon normal there + GeometryNode = 1, + RenderStyleId = material, + }).ToList(), + }; + + /// A family symbol (a box) and an instance that places it, built by the reader's own element builder; the instance + /// is then made a drawable product. + private static RvtDocument PlacedSymbol(double[] transform, double[]? instanceBox, GRepData? ownFaces = null) + { + var state = new ParsingState(); + state.GrepBlocks[SymbolId] = BoxBlock([0, 0, 0], [1, 2, 3]); + var instance = ownFaces ?? new GRepData(); + instance.BoundingBox = instanceBox; + instance.Instances = [new GRepInstance { SymbolElemId = SymbolId, Transform = transform }]; + state.GrepBlocks[InstanceId] = instance; + var info = new RevitFileInfo(); + ElementBuilder.BuildBlockOnlyElement(SymbolId, state, info); + ElementBuilder.BuildBlockOnlyElement(InstanceId, state, info); + var element = info.Elements.Single(candidate => candidate.Id == InstanceId); + element.IsBlockOnly = false; + element.CategoryId = -2000151; + element.Category = "OST_GenericModel"; + element.Parameters.Add(new Parameter { Id = -1012100, Value = Phase }); + element.SerializedCreatedPhaseId = Phase; + return RvtDocument.FromInfo(info); + } + + // ---- geometry checks ---- + + private static IEnumerable<(double[] A, double[] B, double[] C, double[] Normal)> Triangles(TriangleMesh mesh) + { + double[] P(double[] values, int vertex) => [values[3 * vertex], values[3 * vertex + 1], values[3 * vertex + 2]]; + for (int index = 0; index < mesh.Indices.Length; index += 3) + yield return (P(mesh.Positions, mesh.Indices[index]), P(mesh.Positions, mesh.Indices[index + 1]), + P(mesh.Positions, mesh.Indices[index + 2]), P(mesh.Normals, mesh.Indices[index])); + } + + private static double[] Cross(double[] a, double[] b, double[] c) + { + double ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2], vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2]; + return [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx]; + } + + private static double Dot(double[] a, double[] b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + + private static double Area(IEnumerable meshes) => + meshes.SelectMany(Triangles).Sum(t => Math.Sqrt(Dot(Cross(t.A, t.B, t.C), Cross(t.A, t.B, t.C))) / 2); + + /// Every triangle is counter-clockwise around its (flat) vertex normal. + private static void AssertWindingFollowsNormals(IEnumerable meshes) + { + foreach (var (a, b, c, normal) in meshes.SelectMany(Triangles)) + Assert.True(Dot(Cross(a, b, c), normal) > 0); + } + + // ---- triangulation ---- + + [Fact] + public void PlanarFaceWithHole_CoversTheRingAndLeavesTheHoleOpen() + { + var face = Face([[0, 0, 5], [4, 0, 5], [4, 4, 5], [0, 4, 5]], [0, 0, 1]); + face.InnerVertices = [[[1, 1, 5], [1, 3, 5], [3, 3, 5], [3, 1, 5]]]; + + var (meshes, failed) = ElementMeshes.Triangulate([face], _ => 0); + + Assert.Equal(0, failed); + var mesh = Assert.Single(meshes); + Assert.Equal(12, Area(meshes), 9); + Assert.All(Triangles(mesh), t => + { + double cx = (t.A[0] + t.B[0] + t.C[0]) / 3, cy = (t.A[1] + t.B[1] + t.C[1]) / 3; + Assert.False(cx > 1 && cx < 3 && cy > 1 && cy < 3, "a triangle lies in the hole"); + Assert.Equal([0, 0, 1], t.Normal); + }); + AssertWindingFollowsNormals(meshes); + } + + [Fact] + public void RingAgainstItsNormal_IsWoundAroundTheNormal() + { + // A mirrored placement keeps the order of the corners and maps the normal: the ring runs clockwise around it. + var face = Face([[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]], [0, 0, 1]); + + var (meshes, failed) = ElementMeshes.Triangulate([face], _ => 0); + + Assert.Equal(0, failed); + Assert.Equal(1, Area(meshes), 12); + AssertWindingFollowsNormals(meshes); + Assert.All(meshes[0].Normals.Chunk(3), normal => Assert.Equal([0, 0, 1], normal)); + } + + [Fact] + public void FaceWithoutAPolygon_IsCountedAsUntriangulated() + { + var (meshes, failed) = ElementMeshes.Triangulate([new GeoFace { Normal = [0, 0, 1] }], _ => 0); + + Assert.Empty(meshes); + Assert.Equal(1, failed); + } + + [Fact] + public void FilledHole_IsCountedAsUntriangulated() + { + // A hole that crosses the outline cannot be cut out of it. + var face = Face([[0, 0, 0], [4, 0, 0], [4, 4, 0], [0, 4, 0]], [0, 0, 1]); + face.InnerVertices = [[[3, 1, 0], [3, 3, 0], [6, 3, 0], [6, 1, 0]]]; + + var (_, failed) = ElementMeshes.Triangulate([face], _ => 0); + + Assert.Equal(1, failed); + } + + [Fact] + public void LargeCoordinates_KeepTheirPrecision() + { + const double far = 1e6; + var face = Face([[far, far, 0], [far + 0.001, far, 0], [far + 0.001, far + 0.001, 0], [far, far + 0.001, 0]], [0, 0, 1]); + + var (meshes, failed) = ElementMeshes.Triangulate([face], _ => 0); + + Assert.Equal(0, failed); + Assert.Equal(1e-6, Area(meshes), 10); + Assert.Equal(far + 0.001, meshes[0].Positions.Where((_, index) => index % 3 == 0).Max()); + } + + [Fact] + public void UntakenNodes_AreDroppedOnlyBesideTakenNodes() + { + var taken = Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1], node: 1); + var untaken = Face([[0, 0, 1], [2, 0, 1], [2, 2, 1]], [0, 0, 1], node: -2); + var unnumbered = Face([[0, 0, 2], [3, 0, 2], [3, 3, 2]], [0, 0, 1], node: 0); + + Assert.Equal(0.5 + 4.5, Area(ElementMeshes.Triangulate([taken, untaken, unnumbered], _ => 0).Meshes), 12); + // Without a taken node the untaken one is all the element has. + Assert.Equal(2, Area(ElementMeshes.Triangulate([untaken], _ => 0).Meshes), 12); + } + + // ---- materials ---- + + [Fact] + public void FaceMaterial_FollowsOwnThenNestedThenGraphicsStyle_OneMeshPerMaterial() + { + static GeoFace At(double z) => Face([[0, 0, z], [1, 0, z], [1, 1, z], [0, 1, z]], [0, 0, 1]); + var own = At(0); own.RenderStyleId = 901; own.NestedRenderStyleId = 902; + var nested = At(1); nested.NestedRenderStyleId = 902; + var notAMaterial = At(2); notAMaterial.RenderStyleId = 800; notAMaterial.NestedRenderStyleId = 902; + var styled = At(3); styled.GeometryStyle = 50; + var styledWithoutMaterial = At(4); styledWithoutMaterial.GeometryStyle = 51; + var none = At(5); + var styledInvalid = At(6); styledInvalid.RenderStyleId = 800; + var info = new RevitFileInfo + { + Elements = + [ + Product(10, new GeometryData { Faces = [own, nested, notAMaterial, styled, styledWithoutMaterial, none, styledInvalid] }), + new Element { Id = 901, CategoryId = -2000700 }, + new Element { Id = 902, CategoryId = -2000700 }, + new Element { Id = 903, CategoryId = -2000700 }, + new Element { Id = 800, CategoryId = -2000151 }, + ], + GraphicsStyles = new Dictionary { [50] = (903, -2000151), [51] = (-1, -2000151) }, + }; + + var element = Assert.Single(ElementMeshes.Extract(RvtDocument.FromInfo(info)).Elements); + + Assert.Equal([901, 902, 903, 0], element.Meshes.Select(mesh => mesh.MaterialId)); + Assert.Equal([1.0, 2.0, 1.0, 3.0], element.Meshes.Select(mesh => Area([mesh])).Select(area => Math.Round(area, 9))); + } + + // ---- placements ---- + + [Fact] + public void InstanceThatIsItsSymbolPlaced_IsAPlacement() + { + double[] rotated = [0, -1, 0, 1, 0, 0, 0, 0, 1, 10, 20, 30]; + var document = PlacedSymbol(rotated, [8, 20, 30, 10, 21, 33]); + + var set = ElementMeshes.Extract(document); + + var element = Assert.Single(set.Elements); + Assert.Equal(InstanceId, element.ElementId); + Assert.Empty(element.Meshes); + Assert.Equal(MeshCoverage.Complete, element.Coverage); + var placement = Assert.Single(element.Placements); + Assert.Equal(SymbolId, placement.SymbolId); + Assert.Equal(new MeshTransform(new(10, 20, 30), new(0, 1, 0), new(-1, 0, 0), new(0, 0, 1)), placement.Transform); + // The symbol mesh is in the frame of the family, wound around its own normals. + var symbol = set.Symbols[SymbolId]; + Assert.Equal(2 * (1 * 2 + 2 * 3 + 1 * 3), Area(symbol), 9); + Assert.All(symbol.SelectMany(mesh => mesh.Positions), value => Assert.InRange(value, 0, 3)); + AssertWindingFollowsNormals(symbol); + // Placed, it is the instance's own geometry. + var own = ElementMeshes.Triangulate(document.GetElement(InstanceId)!.Geometry!.Faces, _ => 0).Meshes; + Assert.Equal(Area(own), Area(symbol), 9); + } + + [Fact] + public void ClippedInstance_KeepsItsOwnShape() + { + // The tight box of the occurrence is lower than the symbol: the reader cuts the placed symbol to it. + var document = PlacedSymbol(Identity, [0, 0, 0, 1, 2, 1.5]); + Assert.True(document.GetElement(InstanceId)!.Geometry!.PlacedSymbolClipped); + Assert.NotNull(document.GetElement(InstanceId)!.Geometry!.ExactMappedInstances); // the IFC mapping flag alone would allow it + + var set = ElementMeshes.Extract(document); + + var element = Assert.Single(set.Elements); + Assert.Empty(element.Placements); + Assert.Empty(set.Symbols); + Assert.Equal(1.5, element.Meshes.SelectMany(mesh => mesh.Positions).Where((_, index) => index % 3 == 2).Max()); + // The bottom and the four sides cut at the box; the cut leaves no top. + Assert.Equal(1 * 2 + 2 * (1 * 1.5) + 2 * (2 * 1.5), Area(element.Meshes), 9); + } + + [Fact] + public void InstanceWithFacesOfItsOwn_KeepsItsOwnShape() + { + var own = new GRepData + { + Faces = [new GRepFace { Vertices = [[5, 5, 0], [6, 5, 0], [6, 6, 0]], Origin = [5, 5, 0], XVector = [0, 0, 1], GeometryNode = 1 }], + }; + var document = PlacedSymbol(Identity, null, own); + + var element = Assert.Single(ElementMeshes.Extract(document).Elements); + + Assert.Empty(element.Placements); + Assert.Equal(22 + 0.5, Area(element.Meshes), 9); + } + + [Fact] + public void ScaledInstance_KeepsItsOwnShape() + { + double[] scaled = [2, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0]; + var document = PlacedSymbol(scaled, null); + + var set = ElementMeshes.Extract(document); + + var element = Assert.Single(set.Elements); + Assert.Empty(element.Placements); + Assert.Empty(set.Symbols); + Assert.Equal(4 * 22, Area(element.Meshes), 9); + } + + [Fact] + public void BoxSubstitute_IsReportedAndNeverPlaced() + { + // The reader replaces a large component at a rescaled placement by its box and says so. + var state = new ParsingState(); + state.GrepBlocks[1] = new GRepData { Instances = [new GRepInstance { SymbolElemId = 2, Transform = [0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, 0, 0] }] }; + state.GrepBlocks[2] = BoxBlock([0, 0, 0], [4, 2, 2]); + var placedBox = new StrongBox(); + Assert.NotNull(ElementBuilder.BuildPlacedSymbolGeometry(1, Identity, state, [], scaledComponentFaceBudget: 5, placedBox: placedBox)); + Assert.True(placedBox.Value); + + var geometry = new GeometryData + { + Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList(), + ExactMappedInstances = [new GeoInstance { SymbolId = 5, Transform = Identity }], + PlacedSymbolBox = true, + }; + var symbol = new Element { Id = 5, Geometry = new GeometryData { Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList() } }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry), symbol)).Elements); + + Assert.Equal(MeshCoverage.BoxSubstitute, element.Coverage); + Assert.Empty(element.Placements); + Assert.Equal(6, Area(element.Meshes), 9); + } + + [Fact] + public void MirroredPlacement_CarriesTheMirrorAndKeepsTheSymbolWinding() + { + double[] mirrored = [-1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 0, 0]; + var document = PlacedSymbol(mirrored, [4, 0, 0, 5, 2, 3]); + + var set = ElementMeshes.Extract(document); + + var placement = Assert.Single(Assert.Single(set.Elements).Placements); + Assert.Equal(-1, placement.Transform.Determinant, 12); + // The shared triangles are those of the family, wound around the family's outward normals; the mirror is left to the + // consumer, who reverses the winding for a negative determinant. + var symbol = set.Symbols[SymbolId]; + AssertWindingFollowsNormals(symbol); + Assert.All(symbol.SelectMany(Triangles), t => + { + double[] centre = [0.5, 1, 1.5]; + double[] centroid = [(t.A[0] + t.B[0] + t.C[0]) / 3, (t.A[1] + t.B[1] + t.C[1]) / 3, (t.A[2] + t.B[2] + t.C[2]) / 3]; + Assert.True(Dot(t.Normal, [centroid[0] - centre[0], centroid[1] - centre[1], centroid[2] - centre[2]]) > 0); + }); + } + + [Fact] + public void MirroredInstanceWithItsOwnTriangles_IsWoundAroundTheMirroredNormals() + { + // The reader places the faces of a mirrored instance without changing the order of the corners, so its rings run + // against the mapped normals; the own triangles follow the normals, which point out of the mirrored box. + double[] mirrored = [-1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 0, 0]; + var document = PlacedSymbol(mirrored, [4, 0, 0, 5, 2, 2]); // clipped: the instance keeps its own shape + + var element = Assert.Single(ElementMeshes.Extract(document).Elements); + + Assert.Empty(element.Placements); + AssertWindingFollowsNormals(element.Meshes); + double[] centre = [4.5, 1, 1]; + Assert.All(element.Meshes.SelectMany(Triangles), t => + { + double[] centroid = [(t.A[0] + t.B[0] + t.C[0]) / 3, (t.A[1] + t.B[1] + t.C[1]) / 3, (t.A[2] + t.B[2] + t.C[2]) / 3]; + Assert.True(Dot(t.Normal, [centroid[0] - centre[0], centroid[1] - centre[1], centroid[2] - centre[2]]) > 0); + }); + } + + [Fact] + public void SymbolPlacementsOff_GivesEveryElementItsOwnTriangles() + { + var document = PlacedSymbol(Identity, null); + + var set = ElementMeshes.Extract(document, new ElementMeshOptions { UseSymbolPlacements = false }); + + var element = Assert.Single(set.Elements); + Assert.Empty(element.Placements); + Assert.Empty(set.Symbols); + Assert.Equal(22, Area(element.Meshes), 9); + } + + [Fact] + public void SymbolWhoseOwnNestedSymbolWasClipped_IsNotShared() + { + var geometry = new GeometryData + { + Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList(), + ExactMappedInstances = [new GeoInstance { SymbolId = 5, Transform = Identity }], + }; + var symbol = new Element + { + Id = 5, + Geometry = new GeometryData + { + Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList(), + PlacedSymbolClipped = true, + }, + }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry), symbol)).Elements); + + Assert.Empty(element.Placements); + Assert.Equal(6, Area(element.Meshes), 9); + } + + [Fact] + public void SymbolOfUntakenNodesBesideTakenOnes_IsNotShared() + { + // Alone, the symbol keeps its untaken nodes; in the element beside taken ones they are dropped. + var hidden = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal, node: -1)).ToList(); + var geometry = new GeometryData + { + Faces = [.. hidden, Face([[5, 5, 5], [6, 5, 5], [6, 6, 5]], [0, 0, 1], node: 2)], + ExactMappedInstances = [new GeoInstance { SymbolId = 5, Transform = Identity }, new GeoInstance { SymbolId = 6, Transform = Identity }], + }; + var symbolA = new Element { Id = 5, Geometry = new GeometryData { Faces = hidden } }; + var symbolB = new Element { Id = 6, Geometry = new GeometryData { Faces = [Face([[5, 5, 5], [6, 5, 5], [6, 6, 5]], [0, 0, 1], node: 1)] } }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry), symbolA, symbolB)).Elements); + + Assert.Empty(element.Placements); + Assert.Equal(0.5, Area(element.Meshes), 12); + } + + // ---- coverage and selection ---- + + [Fact] + public void UnresolvedSymbol_IsPartial() + { + var geometry = new GeometryData { Faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1])], UnresolvedSymbol = true }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry))).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + Assert.Equal(0, element.UntriangulatedFaces); + } + + [Fact] + public void UntriangulatedFace_IsPartial() + { + var geometry = new GeometryData { Faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1]), new GeoFace()] }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry))).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + Assert.Equal(1, element.UntriangulatedFaces); + } + + [Fact] + public void GeometryRecordWithoutFaces_IsNoGeometry() + { + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, new GeometryData { BoundingBox = [0, 0, 0, 1, 1, 1] }))).Elements); + + Assert.Equal(MeshCoverage.NoGeometry, element.Coverage); + Assert.Empty(element.Meshes); + } + + [Fact] + public void UndecodedDocument_ListsItsElementsAsUndecoded() + { + var info = new RevitFileInfo + { + WideLayoutNotRewritten = true, + Elements = [Product(1, null), Product(2, null)], + }; + info.Elements[0].BoundingBox = [0, 0, 0, 1, 1, 1]; + + var set = ElementMeshes.Extract(RvtDocument.FromInfo(info)); + + Assert.True(set.GeometryUndecoded); + var element = Assert.Single(set.Elements); // the other one has no box either + Assert.Equal(1, element.ElementId); + Assert.Equal(MeshCoverage.Undecoded, element.Coverage); + Assert.Empty(element.Meshes); + } + + [Fact] + public void DefinitionsAndContainers_AreNotListed() + { + static GeometryData Triangle() => new() { Faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1])] }; + var blockOnly = Product(2, Triangle()); blockOnly.IsBlockOnly = true; + var template = Product(3, Triangle()); template.IsGroupTemplate = true; + var withoutPhase = Product(4, Triangle()); withoutPhase.SerializedCreatedPhaseId = null; + var room = Product(5, Triangle()); room.Category = "OST_Rooms"; room.CategoryId = -2000160; + var run = Product(6, Triangle()); + var stair = Product(7, new GeometryData + { + Faces = Triangle().Faces, + ExactMappedInstances = [new GeoInstance { SymbolId = 6, Transform = Identity }], + }); + + var set = ElementMeshes.Extract(Document(Product(1, Triangle()), blockOnly, template, withoutPhase, room, run, stair)); + + Assert.Equal([1, 6], set.Elements.Select(element => element.ElementId)); + } + + [Fact] + public void PlacementOfADrawableElement_IsLeftToThatElement() + { + static List Box() => BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList(); + var member = Product(6, new GeometryData { Faces = Box() }); + var symbol = new Element { Id = 5, Geometry = new GeometryData { Faces = Box() } }; + var container = Product(7, new GeometryData + { + Faces = [.. Box(), .. Box()], + ExactMappedInstances = [new GeoInstance { SymbolId = 6, Transform = Identity }, new GeoInstance { SymbolId = 5, Transform = Identity }], + }); + + var set = ElementMeshes.Extract(Document(member, symbol, container)); + + Assert.Equal([6, 7], set.Elements.Select(element => element.ElementId)); + Assert.Equal([5], set.Elements[1].Placements.Select(placement => placement.SymbolId)); + Assert.Equal([5], set.Symbols.Keys); + } + + [Fact] + public void Extract_RejectsANullDocument() + { + Assert.Throws(() => ElementMeshes.Extract(null!)); + } + + // ---- reader flags ---- + + [Fact] + public void ClipGeometryToBounds_ReportsWhetherItChangedTheFaces() + { + var source = new GeometryData { Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList() }; + + Assert.NotNull(ElementBuilder.ClipGeometryToBounds(source, [0, 0, 0, 1, 1, 1], out bool inside)); + Assert.False(inside); + Assert.NotNull(ElementBuilder.ClipGeometryToBounds(source, [0, 0, 0, 1, 1, 0.5], out bool cut)); + Assert.True(cut); + Assert.Null(ElementBuilder.ClipGeometryToBounds(source, [5, 5, 5, 6, 6, 6], out bool away)); + Assert.True(away); + Assert.Same(source, ElementBuilder.ClipGeometryToBounds(source, null, out bool noBox)); + Assert.False(noBox); + } + + // ---- public types ---- + + [Fact] + public void MeshTransform_ReadsTheRowMajorInstanceTransform() + { + double[] t = [0, -1, 0, 1, 0, 0, 0, 0, 1, 10, 20, 30]; + var transform = MeshTransform.FromRowMajor(t); + double[] p = [1, 2, 3]; + + Assert.Equal(new MeshVector( + t[9] + p[0] * t[0] + p[1] * t[1] + p[2] * t[2], + t[10] + p[0] * t[3] + p[1] * t[4] + p[2] * t[5], + t[11] + p[0] * t[6] + p[1] * t[7] + p[2] * t[8]), transform.Apply(new MeshVector(1, 2, 3))); + Assert.Equal(1, transform.Determinant, 12); + Assert.Equal(-1, MeshTransform.FromRowMajor([-1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]).Determinant, 12); + Assert.Throws(() => MeshTransform.FromRowMajor([1, 0, 0])); + } + + [Fact] + public void MeshSet_CanBeBuiltByAConsumer() + { + var mesh = new TriangleMesh { Positions = [0, 0, 0, 1, 0, 0, 0, 1, 0], Indices = [0, 1, 2], Normals = [0, 0, 1, 0, 0, 1, 0, 0, 1], MaterialId = 3 }; + var set = new MeshSet + { + Elements = + [ + new ElementMesh { ElementId = 1, Meshes = [mesh], Coverage = MeshCoverage.Complete }, + new ElementMesh { ElementId = 2, Placements = [new SymbolPlacement(9, MeshTransform.FromRowMajor(Identity))] }, + ], + Symbols = new Dictionary> { [9] = [mesh] }, + }; + + Assert.Equal(2, set.Elements.Count); + Assert.False(set.GeometryUndecoded); + Assert.Same(mesh, set.Symbols[9][0]); + } +} From 2d3c968cc299bda78d53a977f3583df1f7475efe Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 05:54:19 +0300 Subject: [PATCH 04/11] Document the element meshes --- CHANGELOG.md | 1 + README.md | 1 + docs/getting-started.md | 20 ++++++++++++++++++++ docs/index.md | 1 + docs/public-api.md | 23 +++++++++++++++++++++++ docs/test-fixtures.md | 1 + 6 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61148dc..11f944b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ API between minor releases. ### Added +- Library: `ElementMeshes.Extract` (namespace `RvtIO.Meshes`) returns the triangle meshes of the drawable model elements of an opened `RvtDocument`, in feet, without reading the file again: per element its own triangles (one mesh per material, flat normals, counter-clockwise around them) and, for an element that is exactly its family symbols placed rigidly, the placements of shared symbol meshes in the frame of the family, with a coverage per element (complete, partial, box in place of a rescaled symbol, no geometry, undecoded). A cut or clipped instance, an instance with faces of its own and a scaled one keep their own triangles; a mirrored placement keeps the winding of its symbol and carries the mirror in its transform. The drawable elements, the face materials and the hidden Geometry nodes follow the rules of the IFC export of the current profile. The types are listed in [the public API](docs/public-api.md). IFC and JSON output is unchanged. - Default output, current IFC profile: every beam with a straight drive curve gets `Pset_BeamCommon.Slope`, the angle in degrees of its drive curve to the horizontal (0 for a horizontal beam, never negative), whether or not BaseQuantities are exported and whatever its body; its quantities are unchanged. A beam without a straight drive curve gets no Slope. - Default output, current IFC profile: `Pset_BeamCommon.Span` of a beam without a stored length is the length of its straight drive curve; a beam without one has no Span. - Default output, current IFC profile: a round column gets the volume and surface of the measured node that carries its two exact end faces in place of the open tessellated side. A round column is a vertical cylinder: exactly two exact planar end faces, horizontal, without holes, of at least eight corners on one circle (the same for both faces), each with an exact area within 0.5% of the area of that circle (a regular polygon of 32 corners or fewer is refused), and every other face with its corners on the cylinder between the end faces. The node is taken only when it is the one node that holds nothing but end faces. It is used for `NetVolume` after the sources that come first for a column (the Medium solids, the nominal area of a tube, the computed host volume, the measures of all nodes) and for `CrossSectionArea` (half the surface) of a column without extrusion data. When the two end faces also have the same exact area (a vertical prism) the column gets the extrusion data `Length`, `CrossSectionArea` (the area of its circle), `OuterSurfaceArea` and `GrossVolume`. Other columns are unchanged. Measured on the round columns of one sample (corner distances to a millionth of the radius, or eight units in the last place of a 32-bit coordinate where that is more). diff --git a/README.md b/README.md index 87064df..d9296d3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ RvtIO is a .NET library and command line tool that reads Autodesk Revit projects - Queries elements through `FilteredElementCollector`. - Exports IFC2X3 coordination view, with optional Revit parameters as property sets. - Writes compact JSON or a full JSON dump with every parameter and geometry. +- Returns the triangle meshes of the model elements, with one shared mesh per family symbol for repeated instances (`ElementMeshes`). ## Install diff --git a/docs/getting-started.md b/docs/getting-started.md index ecaa74d..fb7356f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -47,6 +47,26 @@ foreach (var wall in walls) new IfcExporter().Export(doc.RawData, "project.ifc"); ``` +### Triangle meshes + +`ElementMeshes.Extract` returns the triangles of the model elements of an opened document, in feet. A repeated family instance +that is exactly its symbol is a placement of one shared symbol mesh: + +```csharp +using RvtIO.Meshes; + +var meshes = ElementMeshes.Extract(doc); +foreach (var element in meshes.Elements) +{ + // element.Meshes: own triangles in the coordinates of the document, one mesh per material. + // element.Placements: symbols drawn by a transform; their triangles are meshes.Symbols[placement.SymbolId]. + Console.WriteLine($"{element.ElementId}: {element.Meshes.Count} meshes, {element.Placements.Count} placements, {element.Coverage}"); +} +``` + +Open a file of Revit 2023 or later with `new RvtReaderOptions { R22Layout = true }` to decode its geometry; without it +`MeshSet.GeometryUndecoded` is true and the elements are listed as `MeshCoverage.Undecoded`. + The library is silent by default. Set `RvtReaderOptions.DiagnosticOutput` for reading or `IfcExporter.DiagnosticOutput` for export to receive diagnostics through a `TextWriter`. The supported surface is listed in [Public API](public-api.md); every type is in the [API reference](api/RvtIO.yml). diff --git a/docs/index.md b/docs/index.md index 4c17510..6cefe0d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,7 @@ RvtIO is a .NET library and command line tool that reads Autodesk Revit projects - Queries elements through `FilteredElementCollector`. - Exports IFC2X3 coordination view, with optional Revit parameters as property sets. - Writes compact JSON or a full JSON dump with every parameter and geometry. +- Returns the triangle meshes of the model elements, with one shared mesh per family symbol for repeated instances (`ElementMeshes`). The library targets .NET 8 and .NET 10, uses managed code only, and supports Windows, Linux and macOS. diff --git a/docs/public-api.md b/docs/public-api.md index d2e1319..a24f874 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -42,6 +42,29 @@ extension and label helpers carry the ids and names stored in the file format. | `CompactJsonExporter` | `Export(rvtPath, outputPath, readerOptions, options)` reads a file and writes the compact JSON. | | `CompactJsonExportOptions` | `JsonClassNamePrefix`: text put in front of every class name in the `name` field. | +## Triangle meshes + +| Type | Use | +|---|---| +| `ElementMeshes` | `Extract(document, options)` returns the triangle meshes of the drawable model elements of an opened `RvtDocument`, from the geometry the document already holds (the file is not read again). Coordinates are in feet, in the coordinates of the document (Revit internal coordinates, Z up). | +| `ElementMeshOptions` | `UseSymbolPlacements` (default true): an element that is exactly its family symbols placed rigidly is returned as placements of shared symbol meshes; false gives every element its own triangles. | +| `MeshSet` | `Elements` (one `ElementMesh` per drawable element, in document order), `Symbols` (symbol element id to its triangle meshes in the frame of the family, only for placed symbols) and `GeometryUndecoded` (a file of the 64-bit-id layout read without `RvtReaderOptions.R22Layout`: its elements are listed as `Undecoded`). | +| `ElementMesh` | `ElementId`, `Meshes` (the element's own triangles, one mesh per material), `Placements`, `Coverage` and `UntriangulatedFaces`. | +| `TriangleMesh` | `Positions` (x, y, z per vertex), `Indices` (three per triangle, counter-clockwise around the normals), `Normals` (one unit normal per vertex, flat per face: vertices are not shared between faces) and `MaterialId` (the material element id, 0 for none). | +| `SymbolPlacement`, `MeshTransform`, `MeshVector` | A symbol id with its transform. `MeshTransform` maps a family point p to `Origin + BasisX * p.X + BasisY * p.Y + BasisZ * p.Z`; the basis vectors are the columns of the row-major 3x3 matrix of `GeoInstance.Transform` (`BasisX = (t[0], t[3], t[6])`, `Origin = (t[9], t[10], t[11])`, see `FromRowMajor`). A negative `Determinant` is a mirror image. | +| `MeshCoverage` | `Complete`, `Partial` (some faces did not triangulate, or a placed symbol has no geometry), `BoxSubstitute` (a symbol placed at a rescaled placement was replaced by its box), `NoGeometry` (a geometry record with nothing to draw) and `Undecoded`. | + +A placement is given only when it is shown that the element is exactly its symbols placed: the element's geometry record holds +no face of its own, the tight box of the element cut nothing off the placed symbols, every symbol resolved, no box was put in +the place of a component, and every transform is rigid (orthonormal, determinant 1 or -1). Any other element, a cut or clipped +instance included, gets its own triangles. The shared symbol triangles keep the winding of the family: a consumer that culls back +faces reverses the winding of a placement with a negative determinant. The own triangles of an element, those of a mirrored +instance included, are wound around the normals of their faces. + +The drawable elements, the materials and the hidden Geometry nodes follow the rules of the IFC export of the current profile, as +far as they do not depend on the IFC writer: they are listed in the documentation comment of `ElementMeshes`. Curved surfaces are +the dense polygons the reader decodes for them. + ## Diagnostics The library never writes to the console. A diagnostic message is written only when the caller gives a `TextWriter`: diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md index aecd9d3..bcbb71d 100644 --- a/docs/test-fixtures.md +++ b/docs/test-fixtures.md @@ -82,6 +82,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `ElementExportIdTests` | 3 | 6 | TESTFILE.rvt | | `ElementHeaderCategoryTest2022Tests` | 2 | 2 | TESTFILE.rvt, Test_2022.rvt | | `ElementHeaderCategoryTestFileTests` | 1 | 1 | TESTFILE.rvt | +| `ElementMeshesSampleTests` | 5 | 5 | Test_2022.rvt, Test_2025.rvt, test_door_in_wall.rvt | | `EpisodeGuidTests` | 2 | 2 | TESTFILE.rvt | | `ExportReportTests` | 8 | 13 | Test_2020.rvt, TESTFILE.rvt | | `FaceAreaTests` | 2 | 2 | TestData blocks | From 11608e1a5d8c3c606193d1b58f818fb55b696fa4 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:20:46 +0300 Subject: [PATCH 05/11] Move the product rules that ElementMeshes shares with the IFC export into ProductRules ElementMeshes no longer builds an IfcExporter for the export phase filter. The rules are moved without change; IFC and compact JSON output of the samples is identical to the base. --- src/RvtIO/Ifc/IfcExporter.ExportPhase.cs | 20 +-- .../Ifc/IfcExporter.VoidInstancesCurrent.cs | 14 +- src/RvtIO/Ifc/IfcExporter.cs | 85 ++----------- src/RvtIO/Meshes/ElementMeshes.cs | 15 +-- src/RvtIO/Model/Element.cs | 2 +- src/RvtIO/{Ifc => Model}/ExportPhaseFilter.cs | 4 +- src/RvtIO/Model/Geometry.cs | 2 +- src/RvtIO/Model/ProductRules.cs | 120 ++++++++++++++++++ .../ElementBuilder.InstanceQuantities.cs | 2 +- src/RvtIO/Parsing/ElementBuilder.cs | 4 +- 10 files changed, 153 insertions(+), 115 deletions(-) rename src/RvtIO/{Ifc => Model}/ExportPhaseFilter.cs (98%) create mode 100644 src/RvtIO/Model/ProductRules.cs diff --git a/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs b/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs index f91186b..e253749 100644 --- a/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs +++ b/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs @@ -4,19 +4,9 @@ namespace RvtIO.Ifc; public partial class IfcExporter { - /// The filter of the elements that do not exist in the export phase, null when there is none to apply. With a known - /// phase order it is the general rule (). Without it, the default export phase (the last one, no - /// explicit ) still needs only the set of phases: an element demolished in any phase of the project - /// is demolished at or before the last one. That set is the ProjectPhase elements of the document; a document without any is - /// not filtered. - internal ExportPhaseFilter? BuildExportPhaseFilter(RevitFileInfo info) - { - if (info.SerializedProjectPhaseIds is { } order) - return ResolveActivePhase(info) is int phase ? ExportPhaseFilter.For(order, phase) : null; - if (ExportPhaseId != null || ExportAllPhases || !CurrentProfile) return null; - var phases = info.Elements.Where(element => element.RootClassName == "ProjectPhase" - && !(info.ElemTableIsExact && element.IsDiscovered)) - .Select(element => element.Id).ToArray(); - return phases.Length == 0 ? null : ExportPhaseFilter.OfLastPhase(phases); - } + /// The filter of the elements that do not exist in the export phase, null when there is none to apply: the rule of + /// for , with the last project phase as the default of the + /// current profile unless is set. + private ExportPhaseFilter? BuildExportPhaseFilter(RevitFileInfo info) => + ProductRules.BuildExportPhaseFilter(info, ExportPhaseId, CurrentProfile && !ExportAllPhases); } diff --git a/src/RvtIO/Ifc/IfcExporter.VoidInstancesCurrent.cs b/src/RvtIO/Ifc/IfcExporter.VoidInstancesCurrent.cs index 692057d..5ba8100 100644 --- a/src/RvtIO/Ifc/IfcExporter.VoidInstancesCurrent.cs +++ b/src/RvtIO/Ifc/IfcExporter.VoidInstancesCurrent.cs @@ -4,15 +4,7 @@ namespace RvtIO.Ifc; public partial class IfcExporter { - /// Bit 0x200 of the GInfo flags of a serialized Face. Every element has at least one - /// Face with it, except those whose Faces are all display (0x00200000) or auxiliary (0x04000000) surfaces, which never - /// reach the body. - internal const uint FaceFillFlag = 0x00000200; - - /// Whether every decoded Face of the element lacks . The current profile writes no such instance: - /// an instance whose own geometry is null is not exported (for example the floor-hosted family - /// "Assignment - Rectangular opening (floor)"). Faces whose flags are not known (placed from a nested symbol) count as filled. - internal static bool HasOnlyUnfilledFacesCurrent(Element element) => - element.Geometry?.Faces is { Count: > 0 } faces - && faces.All(face => face.GInfoFlags is uint flags && (flags & FaceFillFlag) == 0); + /// Whether every decoded Face of the element is unfilled (). The current + /// profile writes no such instance. + internal static bool HasOnlyUnfilledFacesCurrent(Element element) => ProductRules.HasOnlyUnfilledFaces(element); } diff --git a/src/RvtIO/Ifc/IfcExporter.cs b/src/RvtIO/Ifc/IfcExporter.cs index bd675c1..60d56ff 100644 --- a/src/RvtIO/Ifc/IfcExporter.cs +++ b/src/RvtIO/Ifc/IfcExporter.cs @@ -724,7 +724,7 @@ string SiteCoordinate(double feet) => } // Element types can carry preview/type geometry in the RVT stream, but they are definitions // rather than placed products. - var typeElementIds = TypeElementIds(info.Elements); + var typeElementIds = ProductRules.TypeElementIds(info.Elements); // A placed curtain panel/mullion references its parent wall. var curtainWallIds = new HashSet(); @@ -757,7 +757,7 @@ string SiteCoordinate(double feet) => .Where(e => { int rich = e.Geometry?.Faces?.Count(f => f.OuterVertices is { Count: >= 3 }) ?? 0; - return e.Location != null || rich >= 3 || HasProjectPhaseReference(e); + return e.Location != null || rich >= 3 || ProductRules.HasProjectPhaseReference(e); }) .Select(e => e.Id) .ToHashSet(); @@ -1030,7 +1030,7 @@ element.SuperInstanceId is not > 0 } if (!builtExactSourceWallProfilesMappedBreakpoint && doorOrWindow - && HostedFillingProductCanBeEmitted(cutter, elemById, storeyIds, HasProjectPhaseReference(cutter)) + && HostedFillingProductCanBeEmitted(cutter, elemById, storeyIds, ProductRules.HasProjectPhaseReference(cutter)) && TryBuildOwnPrimaryFamilyCutout(cutter, elemById[hostId], out var ownPrimaryOpening)) ownPrimaryFamilyWriterOperands[(hostId, cutter.Id)] = ownPrimaryOpening; if (precomputeDiagnostics) @@ -1203,7 +1203,7 @@ element.SuperInstanceId is not > 0 || cutter.ExplicitHostIds?.Contains(hostId) == true; bool visibleFillingProductIsValidated = doorOrWindow && directHost - && HostedFillingProductCanBeEmitted(cutter, elemById, storeyIds, HasProjectPhaseReference(cutter)); + && HostedFillingProductCanBeEmitted(cutter, elemById, storeyIds, ProductRules.HasProjectPhaseReference(cutter)); HostedOpeningGeometry writerOpening = usesExactCompleteOpenTenFaceQuarteredReveal ? targetedOpening @@ -1383,7 +1383,7 @@ element.SuperInstanceId is not > 0 // future Boolean operand. bool transformOnlyFamily = opening.Transform != null && opening.TypeId > 0 && opening.CategoryId is null or -2000080 or -2001000 or -2001100; - bool hasProjectPhaseReference = HasProjectPhaseReference(opening); + bool hasProjectPhaseReference = ProductRules.HasProjectPhaseReference(opening); bool standaloneDiagnostics = Environment.GetEnvironmentVariable( "RVTIO_RECT_BREP_GATE_DIAG") == "1"; bool RejectStandaloneWriterPath(string stage) @@ -1636,12 +1636,12 @@ bool HasCompetingExactHostedWriterAuthority( continue; } if (CurrentProfile && HasOnlyUnfilledFacesCurrent(elem)) { Skip(elem, "only-unfilled-faces"); continue; } - bool transformOnlyFamily = IsTransformOnlyFamily(elem); + bool transformOnlyFamily = ProductRules.IsTransformOnlyFamily(elem); // Current profile: a wall record that is itself the type of another element (it names no project phase) // is a type definition; the type is written from the instances that use it. The legacy profile keeps // wall records, as wall type propagation is not reliable enough there. if ((CurrentProfile || elem.CategoryId != -2000011) && typeElementIds.Contains(elem.Id) - && !HasProjectPhaseReference(elem) && !transformOnlyFamily) + && !ProductRules.HasProjectPhaseReference(elem) && !transformOnlyFamily) { Skip(elem, "type-element"); continue; } if (IsNonPhysicalCategory(elem.Category)) { Skip(elem, "non-physical-category"); continue; } @@ -1649,7 +1649,7 @@ bool HasCompetingExactHostedWriterAuthority( // Preview/type records inherit PHASE_CREATED during parameter propagation but do not // reference that phase in their own record graph. if (elem.CategoryId != -2000011 - && !HasProjectPhaseReference(elem) && !transformOnlyFamily) + && !ProductRules.HasProjectPhaseReference(elem) && !transformOnlyFamily) { Skip(elem, "no-project-phase-reference"); continue; } // A placed family instance must reference a project level when its serialized level @@ -63672,10 +63672,7 @@ ifcType is "IFCWALL" or "IFCWALLSTANDARDCASE" /// The wall export skips a StackedWall parent, not its source record or children. internal static bool IsValidatedStackedWallContainer( Element element, IReadOnlyDictionary elementsById) => - element.CategoryId == -2000011 && element.FamilyTypeId > 0 - && elementsById.TryGetValue(element.FamilyTypeId, out var type) - && type.Id == element.FamilyTypeId && type.CategoryId == -2003500 - && type.IsSerializedStackedWallType; + ProductRules.IsValidatedStackedWallContainer(element, elementsById); /// /// A positive Element.m_unplacedOwnerId is an authored ownership edge to another record, so the @@ -65115,8 +65112,7 @@ internal static HashSet ApplyDoorWindowOpeningIdentities( : null; } - internal static bool IsWallRectangularOpening(Element element) => - element.Category?.Contains("OST_SWallRectOpening", StringComparison.OrdinalIgnoreCase) == true; + private static bool IsWallRectangularOpening(Element element) => ProductRules.IsWallRectangularOpening(element); // The door sub-element rule (= WindowOpeningRelVoid). internal const int DoorOpeningRelVoid = 20; @@ -68734,7 +68730,6 @@ private Dictionary WriteSpaces( private static readonly (string Name, int ParamId)[] SpaceCoveringParams = [("FloorCovering", -1006903), ("WallCovering", -1006904), ("CeilingCovering", -1006905)]; // ROOM_FINISH_FLOOR, _WALL, _CEILING private const int AreaSchemeIdParam = -1012704; // RoomElem.m_areaSchemeId - private const int PhaseCategoryId = -2000112; // OST_Phases /// The phase whose rooms the current profile exports. private int? ResolveActiveRoomPhase(RevitFileInfo info) @@ -68748,14 +68743,7 @@ private static readonly (string Name, int ParamId)[] SpaceCoveringParams = private int? ResolveActivePhase(RevitFileInfo info) { if (ExportPhaseId != null) return ExportPhaseId; - if (!CurrentProfile || ExportAllPhases || info.SerializedProjectPhaseIds is not { Length: > 0 } order) return null; - // a 64-bit-id file can have a phase whose category is not resolved, so the class counts too - var phases = info.Elements.Where(element => (element.CategoryId == PhaseCategoryId || element.RootClassName == "ProjectPhase") - && !(info.ElemTableIsExact && element.IsDiscovered)) - .Select(element => element.Id).ToHashSet(); - for (int index = order.Length - 1; index >= 0; index--) - if (phases.Contains(order[index])) return order[index]; - return null; + return CurrentProfile && !ExportAllPhases ? ProductRules.LastProjectPhase(info) : null; } private void WriteAreaSchemeGroups( @@ -69681,60 +69669,11 @@ List Groups(IEnumerable values) => values } - /// Whether the element names its created phase (PHASE_CREATED) in its own record graph. Preview and type records - /// inherit the parameter during parameter propagation without referencing that phase. - internal static bool HasProjectPhaseReference(Element e) - { - int? phase = e.Parameters.FirstOrDefault(p => p.Id == -1012100)?.Value is int n ? n : null; // PHASE_CREATED - // Id 0 is a phase of Revit 2023 and later (the first project phase, a row of its - // element table); the earlier layouts number phases from 1. - return phase is > 0 && (e.References?.Contains(phase.Value) == true || e.SerializedCreatedPhaseId == phase.Value) - || phase == 0 && e.SerializedCreatedPhaseId == 0; - } - - /// The elements that other elements name as their type (ELEM_TYPE_PARAM, ELEM_FAMILY_AND_TYPE_PARAM). - internal static HashSet TypeElementIds(IEnumerable elements) - { - var typeElementIds = new HashSet(); - foreach (var e in elements) - foreach (var p in e.Parameters) - if (p.Id is -1002050 or -1002052 && p.Value is int typeId && typeId > 0) - typeElementIds.Add(typeId); - return typeElementIds; - } - - /// A family instance placed by its FamilyInstance transform whose category is unknown or generic. - internal static bool IsTransformOnlyFamily(Element elem) => - elem.Transform != null && elem.TypeId > 0 && elem.CategoryId is null or -2000080 or -2001000 or -2001100; - /// /// Returns true if the Category string indicates a non-physical element (annotation, link, /// symbol, etc). /// - internal static bool IsNonPhysicalCategory(string? category) - { - if (string.IsNullOrEmpty(category)) return false; // null category handled by MapCategoryToIfcType - // Annotations, symbols, links, views, grids, etc. - return category.Contains("Annotation", StringComparison.OrdinalIgnoreCase) - || category.Contains("RvtLink", StringComparison.OrdinalIgnoreCase) - || category.Contains("Symbol", StringComparison.OrdinalIgnoreCase) - || category.Contains("Tag", StringComparison.OrdinalIgnoreCase) - || category.Contains("Schedule", StringComparison.OrdinalIgnoreCase) - || category.Contains("Legend", StringComparison.OrdinalIgnoreCase) - || category.Contains("Titleblock", StringComparison.OrdinalIgnoreCase) - || category.Contains("Detail", StringComparison.OrdinalIgnoreCase) - || category.Contains("Camera", StringComparison.OrdinalIgnoreCase) - || category.Contains("Analytical", StringComparison.OrdinalIgnoreCase) - // Spatial/compound sub-elements are represented by their parent IFC entity, not as - // independent building-element proxies. - || category.Contains("OST_Rooms", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_MEPSpaces", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_RailingTopRail", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_RailingHandRail", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_StairsTrisers", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_StairsRailingBaluster", StringComparison.OrdinalIgnoreCase) - || category.Equals("OST_Parts", StringComparison.OrdinalIgnoreCase); - } + private static bool IsNonPhysicalCategory(string? category) => ProductRules.IsNonPhysicalCategory(category); /// diff --git a/src/RvtIO/Meshes/ElementMeshes.cs b/src/RvtIO/Meshes/ElementMeshes.cs index e54056c..ef29277 100644 --- a/src/RvtIO/Meshes/ElementMeshes.cs +++ b/src/RvtIO/Meshes/ElementMeshes.cs @@ -1,5 +1,4 @@ using RvtIO.Geometry.Meshing; -using RvtIO.Ifc; using RvtIO.Model; using RvtIO.Parsing; @@ -143,8 +142,8 @@ private sealed record SymbolMeshes(IReadOnlyList Meshes, int Faile internal static List<(Element Element, GeometryData? Body)> DrawableElements(RevitFileInfo info) { var designOptions = DesignOptions.Of(info.Elements); - var phaseFilter = new IfcExporter().BuildExportPhaseFilter(info); - var typeElementIds = IfcExporter.TypeElementIds(info.Elements); + var phaseFilter = ProductRules.BuildExportPhaseFilter(info, exportPhaseId: null, lastPhaseByDefault: true); + var typeElementIds = ProductRules.TypeElementIds(info.Elements); var elementsById = new Dictionary(); foreach (var element in info.Elements) elementsById.TryAdd(element.Id, element); var drawable = new List<(Element, GeometryData?)>(); @@ -152,17 +151,17 @@ private sealed record SymbolMeshes(IReadOnlyList Meshes, int Faile { if (element.IsBlockOnly || element.IsGroupTemplate || designOptions.InNonPrimaryOption(element) == true || phaseFilter?.Excludes(element) == true - || element.CategoryId == RevealsCategoryId || IfcExporter.IsWallRectangularOpening(element) - || IfcExporter.IsValidatedStackedWallContainer(element, elementsById) + || element.CategoryId == RevealsCategoryId || ProductRules.IsWallRectangularOpening(element) + || ProductRules.IsValidatedStackedWallContainer(element, elementsById) || info.ElemTableIsExact && element.IsDiscovered || element.SerializedFamilyId is > 0 - || IfcExporter.IsNonPhysicalCategory(element.Category)) + || ProductRules.IsNonPhysicalCategory(element.Category)) continue; - bool namesPhase = IfcExporter.HasProjectPhaseReference(element) || IfcExporter.IsTransformOnlyFamily(element); + bool namesPhase = ProductRules.HasProjectPhaseReference(element) || ProductRules.IsTransformOnlyFamily(element); if (!namesPhase && (typeElementIds.Contains(element.Id) || element.CategoryId != WallsCategoryId)) continue; GeometryData? body = element.Geometry; if (element.SerializedComputedSymbolGeometry is { } computed) body = computed; - else if (IfcExporter.HasOnlyUnfilledFacesCurrent(element)) continue; + else if (ProductRules.HasOnlyUnfilledFaces(element)) continue; if (body != null || info.WideLayoutNotRewritten && element.BoundingBox != null) drawable.Add((element, body)); } // A stair places its runs and landings, a model group its members: those are drawn as elements of their own. diff --git a/src/RvtIO/Model/Element.cs b/src/RvtIO/Model/Element.cs index ca7689e..82a7ac6 100644 --- a/src/RvtIO/Model/Element.cs +++ b/src/RvtIO/Model/Element.cs @@ -983,7 +983,7 @@ internal bool RootClassIs(string className) => NativeRootClassName != null internal int? SerializedCurtainHostId { get; set; } /// The geometry of an instance whose own Faces are all unfilled (a void or cut helper, see - /// IfcExporter.HasOnlyUnfilledFacesCurrent) while its computed symbol has filled ones: the symbol's geometry placed by the + /// ) while its computed symbol has filled ones: the symbol's geometry placed by the /// instance's own transform, with that symbol as its one mapped instance. The current profile exports such an instance with the body of the /// symbol (for example louvers whose own geometry is the box they fill). Null for any other element. [JsonIgnore] diff --git a/src/RvtIO/Ifc/ExportPhaseFilter.cs b/src/RvtIO/Model/ExportPhaseFilter.cs similarity index 98% rename from src/RvtIO/Ifc/ExportPhaseFilter.cs rename to src/RvtIO/Model/ExportPhaseFilter.cs index 5cafe02..68496ea 100644 --- a/src/RvtIO/Ifc/ExportPhaseFilter.cs +++ b/src/RvtIO/Model/ExportPhaseFilter.cs @@ -1,6 +1,4 @@ -using RvtIO.Model; - -namespace RvtIO.Ifc; +namespace RvtIO.Model; /// /// Which elements exist in the export phase: the phases are taken in the order of the project's phase list diff --git a/src/RvtIO/Model/Geometry.cs b/src/RvtIO/Model/Geometry.cs index bb2e1a0..028675b 100644 --- a/src/RvtIO/Model/Geometry.cs +++ b/src/RvtIO/Model/Geometry.cs @@ -200,7 +200,7 @@ public class GeoFace internal int GeometryStyle { get; set; } = -1; /// GInfo flags of the serialized Face this polygon came from; null for a face placed from a nested symbol - /// or not read (see IfcExporter.HasOnlyUnfilledFacesCurrent). + /// or not read (see ). internal uint? GInfoFlags { get; set; } /// The material of a face that was placed here from a nested symbol (GInstance); RenderStyleId is diff --git a/src/RvtIO/Model/ProductRules.cs b/src/RvtIO/Model/ProductRules.cs new file mode 100644 index 0000000..09b2cdb --- /dev/null +++ b/src/RvtIO/Model/ProductRules.cs @@ -0,0 +1,120 @@ +namespace RvtIO.Model; + +/// +/// The rules that decide whether a decoded element is a placed product of the model (and not a definition, a void, an annotation or +/// a record of another phase). The IFC export and apply them. +/// +internal static class ProductRules +{ + private const int PhaseCategoryId = -2000112; // OST_Phases + + /// Bit 0x200 of the GInfo flags of a serialized Face. Every element has at least one + /// Face with it, except those whose Faces are all display (0x00200000) or auxiliary (0x04000000) surfaces, which never + /// reach the body. + internal const uint FaceFillFlag = 0x00000200; + + /// Whether every decoded Face of the element lacks . The current IFC profile writes no such instance: + /// an instance whose own geometry is null is not exported (for example the floor-hosted family + /// "Assignment - Rectangular opening (floor)"). Faces whose flags are not known (placed from a nested symbol) count as filled. + internal static bool HasOnlyUnfilledFaces(Element element) => + element.Geometry?.Faces is { Count: > 0 } faces + && faces.All(face => face.GInfoFlags is uint flags && (flags & FaceFillFlag) == 0); + + /// Whether the element names its created phase (PHASE_CREATED) in its own record graph. Preview and type records + /// inherit the parameter during parameter propagation without referencing that phase. + internal static bool HasProjectPhaseReference(Element e) + { + int? phase = e.Parameters.FirstOrDefault(p => p.Id == -1012100)?.Value is int n ? n : null; // PHASE_CREATED + // Id 0 is a phase of Revit 2023 and later (the first project phase, a row of its + // element table); the earlier layouts number phases from 1. + return phase is > 0 && (e.References?.Contains(phase.Value) == true || e.SerializedCreatedPhaseId == phase.Value) + || phase == 0 && e.SerializedCreatedPhaseId == 0; + } + + /// The elements that other elements name as their type (ELEM_TYPE_PARAM, ELEM_FAMILY_AND_TYPE_PARAM). + internal static HashSet TypeElementIds(IEnumerable elements) + { + var typeElementIds = new HashSet(); + foreach (var e in elements) + foreach (var p in e.Parameters) + if (p.Id is -1002050 or -1002052 && p.Value is int typeId && typeId > 0) + typeElementIds.Add(typeId); + return typeElementIds; + } + + /// A family instance placed by its FamilyInstance transform whose category is unknown or generic. + internal static bool IsTransformOnlyFamily(Element elem) => + elem.Transform != null && elem.TypeId > 0 && elem.CategoryId is null or -2000080 or -2001000 or -2001100; + + /// + /// Returns true if the Category string indicates a non-physical element (annotation, link, + /// symbol, etc). + /// + internal static bool IsNonPhysicalCategory(string? category) + { + if (string.IsNullOrEmpty(category)) return false; // null category handled by the IFC class mapping + // Annotations, symbols, links, views, grids, etc. + return category.Contains("Annotation", StringComparison.OrdinalIgnoreCase) + || category.Contains("RvtLink", StringComparison.OrdinalIgnoreCase) + || category.Contains("Symbol", StringComparison.OrdinalIgnoreCase) + || category.Contains("Tag", StringComparison.OrdinalIgnoreCase) + || category.Contains("Schedule", StringComparison.OrdinalIgnoreCase) + || category.Contains("Legend", StringComparison.OrdinalIgnoreCase) + || category.Contains("Titleblock", StringComparison.OrdinalIgnoreCase) + || category.Contains("Detail", StringComparison.OrdinalIgnoreCase) + || category.Contains("Camera", StringComparison.OrdinalIgnoreCase) + || category.Contains("Analytical", StringComparison.OrdinalIgnoreCase) + // Spatial/compound sub-elements are represented by their parent IFC entity, not as + // independent building-element proxies. + || category.Contains("OST_Rooms", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_MEPSpaces", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_RailingTopRail", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_RailingHandRail", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_StairsTrisers", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_StairsRailingBaluster", StringComparison.OrdinalIgnoreCase) + || category.Equals("OST_Parts", StringComparison.OrdinalIgnoreCase); + } + + /// A rectangular wall opening: a void, not a product of its own. + internal static bool IsWallRectangularOpening(Element element) => + element.Category?.Contains("OST_SWallRectOpening", StringComparison.OrdinalIgnoreCase) == true; + + /// A StackedWall parent, whose member walls are the products; not its source record or children. + internal static bool IsValidatedStackedWallContainer( + Element element, IReadOnlyDictionary elementsById) => + element.CategoryId == -2000011 && element.FamilyTypeId > 0 + && elementsById.TryGetValue(element.FamilyTypeId, out var type) + && type.Id == element.FamilyTypeId && type.CategoryId == -2003500 + && type.IsSerializedStackedWallType; + + /// The last project phase of the authored phase order that is a phase element of the document, null when there is none. + internal static int? LastProjectPhase(RevitFileInfo info) + { + if (info.SerializedProjectPhaseIds is not { Length: > 0 } order) return null; + // a 64-bit-id file can have a phase whose category is not resolved, so the class counts too + var phases = info.Elements.Where(element => (element.CategoryId == PhaseCategoryId || element.RootClassName == "ProjectPhase") + && !(info.ElemTableIsExact && element.IsDiscovered)) + .Select(element => element.Id).ToHashSet(); + for (int index = order.Length - 1; index >= 0; index--) + if (phases.Contains(order[index])) return order[index]; + return null; + } + + /// The filter of the elements that do not exist in the export phase, null when there is none to apply. The export phase is + /// , else, with , the last project phase. With a known phase + /// order it is the general rule (). Without it, the default export phase (the last one, no explicit + /// ) still needs only the set of phases: an element demolished in any phase of the project is + /// demolished at or before the last one. That set is the ProjectPhase elements of the document; a document without any is not + /// filtered. + internal static ExportPhaseFilter? BuildExportPhaseFilter(RevitFileInfo info, int? exportPhaseId, bool lastPhaseByDefault) + { + if (info.SerializedProjectPhaseIds is { } order) + return (exportPhaseId ?? (lastPhaseByDefault ? LastProjectPhase(info) : null)) is int phase + ? ExportPhaseFilter.For(order, phase) : null; + if (exportPhaseId != null || !lastPhaseByDefault) return null; + var phases = info.Elements.Where(element => element.RootClassName == "ProjectPhase" + && !(info.ElemTableIsExact && element.IsDiscovered)) + .Select(element => element.Id).ToArray(); + return phases.Length == 0 ? null : ExportPhaseFilter.OfLastPhase(phases); + } +} diff --git a/src/RvtIO/Parsing/ElementBuilder.InstanceQuantities.cs b/src/RvtIO/Parsing/ElementBuilder.InstanceQuantities.cs index 48f2117..3ae14d0 100644 --- a/src/RvtIO/Parsing/ElementBuilder.InstanceQuantities.cs +++ b/src/RvtIO/Parsing/ElementBuilder.InstanceQuantities.cs @@ -42,7 +42,7 @@ internal static void ApplyInstanceQuantities(RevitFileInfo info, IReadOnlyDictio // A family symbol holds solids too but has no Volume, and a void form (all faces // unfilled) is not a body. if (e.NativeMasterSymbolId != null && (!classByElement.TryGetValue((uint)e.Id, out var rootClass) || rootClass != FamilyInstanceRootClassId)) continue; - if (Ifc.IfcExporter.HasOnlyUnfilledFacesCurrent(e)) continue; + if (ProductRules.HasOnlyUnfilledFaces(e)) continue; if (category == -2001320 && e.FramingCopingDistance is not < 0) continue; int level = Array.FindIndex(e.LevelSolidCounts, count => count > 0); if (level < 0 || e.LevelShells[level] is not { } shell) continue; diff --git a/src/RvtIO/Parsing/ElementBuilder.cs b/src/RvtIO/Parsing/ElementBuilder.cs index c89f55b..c4f9489 100644 --- a/src/RvtIO/Parsing/ElementBuilder.cs +++ b/src/RvtIO/Parsing/ElementBuilder.cs @@ -95,14 +95,14 @@ private static void ExpandTransformOnlyInstanceGeometry( internal static void AttachComputedSymbolGeometry(Element elem, uint elemId, ParsingState state) { elem.SerializedComputedSymbolGeometry = null; - if (!Ifc.IfcExporter.HasOnlyUnfilledFacesCurrent(elem) + if (!ProductRules.HasOnlyUnfilledFaces(elem) || !state.ComputedFamilySymbols.TryGetValue(elemId, out var own) || own.BlockCount != 1 || own.Symbol is not { SymbolId: > 0, Transform: { Length: 12 } transform } symbol || symbol.SymbolId == elem.Id || !state.ComputedSymbolTargetClasses.TryGetValue((uint)symbol.SymbolId, out var target) || target is not { BlockCount: 1, ClassId: 1829 } || !state.GrepBlocks.TryGetValue((uint)symbol.SymbolId, out var symbolGrep) - || !symbolGrep.Faces.Any(face => face.GeometryNode > 0 && face.GInfoFlags is uint flags && (flags & Ifc.IfcExporter.FaceFillFlag) != 0)) + || !symbolGrep.Faces.Any(face => face.GeometryNode > 0 && face.GInfoFlags is uint flags && (flags & ProductRules.FaceFillFlag) != 0)) return; var placed = BuildPlacedSymbolGeometry(symbol.SymbolId, transform, state, new HashSet()); if (placed?.Faces is not { Count: > 0 }) return; From 85f38733c3bd6b226c33cbe334588104e8aa535b Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:33:34 +0300 Subject: [PATCH 06/11] Keep untaken Geometry nodes beside ghost nodes in the element meshes The IFC export drops the faces of untaken nodes only when the geometry has no ghost node; the meshes now apply the same condition, a symbol is shared only when it drops the same nodes as the element, and an element or symbol with ghost nodes is reported as Partial. Document the positional properties of MeshVector and MeshTransform, give ElementMeshOptions a settable property like the other options types, and name the IFC product rules that ElementMeshes does not apply. --- CHANGELOG.md | 2 +- docs/public-api.md | 9 ++-- src/RvtIO/Meshes/ElementMeshOptions.cs | 2 +- src/RvtIO/Meshes/ElementMeshes.cs | 55 ++++++++++++++++--------- src/RvtIO/Meshes/MeshSet.cs | 10 ++++- tests/RvtIO.Tests/ElementMeshesTests.cs | 37 +++++++++++++++++ 6 files changed, 88 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f944b..4601ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ API between minor releases. ### Added -- Library: `ElementMeshes.Extract` (namespace `RvtIO.Meshes`) returns the triangle meshes of the drawable model elements of an opened `RvtDocument`, in feet, without reading the file again: per element its own triangles (one mesh per material, flat normals, counter-clockwise around them) and, for an element that is exactly its family symbols placed rigidly, the placements of shared symbol meshes in the frame of the family, with a coverage per element (complete, partial, box in place of a rescaled symbol, no geometry, undecoded). A cut or clipped instance, an instance with faces of its own and a scaled one keep their own triangles; a mirrored placement keeps the winding of its symbol and carries the mirror in its transform. The drawable elements, the face materials and the hidden Geometry nodes follow the rules of the IFC export of the current profile. The types are listed in [the public API](docs/public-api.md). IFC and JSON output is unchanged. +- API: `ElementMeshes.Extract` (namespace `RvtIO.Meshes`) returns the triangle meshes of the drawable model elements of an opened `RvtDocument`, in feet, without reading the file again: per element its own triangles (one mesh per material, flat normals, counter-clockwise around them) and, for an element that is exactly its family symbols placed rigidly, the placements of shared symbol meshes in the frame of the family, with a coverage per element (complete, partial, box in place of a rescaled symbol, no geometry, undecoded; partial also when a Geometry node that Revit exports has no decoded polygon). A cut or clipped instance, an instance with faces of its own and a scaled one keep their own triangles; a mirrored placement keeps the winding of its symbol and carries the mirror in its transform. The drawable elements follow a subset of the product rules of the IFC export of the current profile (listed in the `ElementMeshes` remarks, with those not applied, such as the "export to IFC" flag and topography, so the meshes can include elements the IFC leaves out); the face materials and the hidden Geometry nodes follow its rules. The types are listed in [the public API](docs/public-api.md). IFC and JSON output is unchanged. - Default output, current IFC profile: every beam with a straight drive curve gets `Pset_BeamCommon.Slope`, the angle in degrees of its drive curve to the horizontal (0 for a horizontal beam, never negative), whether or not BaseQuantities are exported and whatever its body; its quantities are unchanged. A beam without a straight drive curve gets no Slope. - Default output, current IFC profile: `Pset_BeamCommon.Span` of a beam without a stored length is the length of its straight drive curve; a beam without one has no Span. - Default output, current IFC profile: a round column gets the volume and surface of the measured node that carries its two exact end faces in place of the open tessellated side. A round column is a vertical cylinder: exactly two exact planar end faces, horizontal, without holes, of at least eight corners on one circle (the same for both faces), each with an exact area within 0.5% of the area of that circle (a regular polygon of 32 corners or fewer is refused), and every other face with its corners on the cylinder between the end faces. The node is taken only when it is the one node that holds nothing but end faces. It is used for `NetVolume` after the sources that come first for a column (the Medium solids, the nominal area of a tube, the computed host volume, the measures of all nodes) and for `CrossSectionArea` (half the surface) of a column without extrusion data. When the two end faces also have the same exact area (a vertical prism) the column gets the extrusion data `Length`, `CrossSectionArea` (the area of its circle), `OuterSurfaceArea` and `GrossVolume`. Other columns are unchanged. Measured on the round columns of one sample (corner distances to a millionth of the radius, or eight units in the last place of a 32-bit coordinate where that is more). diff --git a/docs/public-api.md b/docs/public-api.md index a24f874..382f7b3 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -52,7 +52,7 @@ extension and label helpers carry the ids and names stored in the file format. | `ElementMesh` | `ElementId`, `Meshes` (the element's own triangles, one mesh per material), `Placements`, `Coverage` and `UntriangulatedFaces`. | | `TriangleMesh` | `Positions` (x, y, z per vertex), `Indices` (three per triangle, counter-clockwise around the normals), `Normals` (one unit normal per vertex, flat per face: vertices are not shared between faces) and `MaterialId` (the material element id, 0 for none). | | `SymbolPlacement`, `MeshTransform`, `MeshVector` | A symbol id with its transform. `MeshTransform` maps a family point p to `Origin + BasisX * p.X + BasisY * p.Y + BasisZ * p.Z`; the basis vectors are the columns of the row-major 3x3 matrix of `GeoInstance.Transform` (`BasisX = (t[0], t[3], t[6])`, `Origin = (t[9], t[10], t[11])`, see `FromRowMajor`). A negative `Determinant` is a mirror image. | -| `MeshCoverage` | `Complete`, `Partial` (some faces did not triangulate, or a placed symbol has no geometry), `BoxSubstitute` (a symbol placed at a rescaled placement was replaced by its box), `NoGeometry` (a geometry record with nothing to draw) and `Undecoded`. | +| `MeshCoverage` | `Complete`, `Partial` (some faces did not triangulate, a placed symbol has no geometry, or a Geometry node that Revit exports has no decoded polygon), `BoxSubstitute` (a symbol placed at a rescaled placement was replaced by its box), `NoGeometry` (a geometry record with nothing to draw) and `Undecoded`. | A placement is given only when it is shown that the element is exactly its symbols placed: the element's geometry record holds no face of its own, the tight box of the element cut nothing off the placed symbols, every symbol resolved, no box was put in @@ -61,9 +61,10 @@ instance included, gets its own triangles. The shared symbol triangles keep the faces reverses the winding of a placement with a negative determinant. The own triangles of an element, those of a mirrored instance included, are wound around the normals of their faces. -The drawable elements, the materials and the hidden Geometry nodes follow the rules of the IFC export of the current profile, as -far as they do not depend on the IFC writer: they are listed in the documentation comment of `ElementMeshes`. Curved surfaces are -the dense polygons the reader decodes for them. +The drawable elements follow a subset of the product rules of the IFC export of the current profile, and the materials and the +hidden Geometry nodes follow its rules: they are listed in the documentation comment of `ElementMeshes`, with the product rules +that are not applied (the "export to IFC" flag, topography, walls that are not visible and others), so the meshes can include +elements that the IFC leaves out. Curved surfaces are the dense polygons the reader decodes for them. ## Diagnostics diff --git a/src/RvtIO/Meshes/ElementMeshOptions.cs b/src/RvtIO/Meshes/ElementMeshOptions.cs index e1852ff..d4f90ad 100644 --- a/src/RvtIO/Meshes/ElementMeshOptions.cs +++ b/src/RvtIO/Meshes/ElementMeshOptions.cs @@ -5,5 +5,5 @@ public sealed class ElementMeshOptions { /// Returns an element that is exactly its symbols placed as of shared symbol meshes /// (true, the default). False gives every element its own triangles and leaves empty. - public bool UseSymbolPlacements { get; init; } = true; + public bool UseSymbolPlacements { get; set; } = true; } diff --git a/src/RvtIO/Meshes/ElementMeshes.cs b/src/RvtIO/Meshes/ElementMeshes.cs index ef29277..119fbd5 100644 --- a/src/RvtIO/Meshes/ElementMeshes.cs +++ b/src/RvtIO/Meshes/ElementMeshes.cs @@ -18,9 +18,13 @@ namespace RvtIO.Meshes; /// of a generic category excepted), and not a void helper whose faces are all unfilled. An element whose geometry is exactly /// other drawable elements placed (a stair of its runs and landings, a model group of its members) is left out, as those /// elements are listed themselves; a placement of a drawable element is likewise left out of an element's placements. A -/// drawable element is listed when it has a geometry record, or, in a document whose geometry was not decoded, a bounding box. +/// drawable element is listed when it has a geometry record, or, in a document whose geometry was not decoded, a bounding box. +/// Other product rules of the IFC export are not applied, so the meshes can include elements that the IFC leaves out: the +/// "export to IFC" flag, topography, walls that are not visible, curtain panels that are walls, family instances whose level is +/// not a project level, curtain children without bounds, openings of hidden walls, and the rebar rules. /// Faces of Geometry nodes that Revit does not take (another detail level) are left out when the element has faces of -/// nodes it takes. Planar faces with holes are triangulated in their plane; curved surfaces are the dense polygons the reader +/// nodes it takes and no ghost node (a node Revit exports that has no decoded polygon, which also makes the element +/// ). Planar faces with holes are triangulated in their plane; curved surfaces are the dense polygons the reader /// decoded for them. Every triangle is counter-clockwise around the normal of its face. /// The material of a face is its own material, else the material of the symbol face it was placed from, else the material /// of the graphics style of its Geometry node, else none (0). Only elements of the Materials category count as materials. @@ -63,23 +67,25 @@ int MaterialOf(GeoFace face) // symbols were placed whole. if (document.GetElement(symbolId)?.Geometry is { PlacedSymbolClipped: false, PlacedSymbolBox: false } geometry) { - var (meshes, failed) = Triangulate(geometry.Faces, MaterialOf); + bool ghosts = geometry.GhostNodes is { Count: > 0 }; + var (meshes, failed) = Triangulate(geometry.Faces, MaterialOf, ghosts); if (meshes.Count > 0) - symbol = new SymbolMeshes(meshes, failed, geometry.Faces.Any(face => face.GeometryNode > 0), - geometry.Faces.Any(face => face.GeometryNode < 0)); + symbol = new SymbolMeshes(meshes, failed, DropsUntakenNodes(geometry.Faces, ghosts), + geometry.Faces.Any(face => face.GeometryNode < 0), ghosts); } symbolCache[symbolId] = symbol; return symbol; } // The placements of an element whose geometry is exactly its symbols placed, or null. - (List Placements, int Failed)? PlacementsOf(GeometryData geometry) + (List Placements, int Failed, bool Ghosts)? PlacementsOf(GeometryData geometry) { if (geometry.ExactMappedInstances is not { Count: > 0 } mapped || geometry.PlacedSymbolClipped || geometry.PlacedSymbolBox) return null; - bool visibleNodes = geometry.Faces.Any(face => face.GeometryNode > 0); + bool dropsUntaken = DropsUntakenNodes(geometry.Faces, geometry.GhostNodes is { Count: > 0 }); var placements = new List(mapped.Count); int failed = 0; + bool ghosts = false; foreach (var instance in mapped) { if (drawableIds.Contains(instance.SymbolId)) continue; // drawn as an element of its own @@ -87,12 +93,13 @@ int MaterialOf(GeoFace face) var transform = MeshTransform.FromRowMajor(instance.Transform); if (Math.Abs(Math.Abs(transform.Determinant) - 1) > RigidTolerance) return null; if (SymbolOf(instance.SymbolId) is not { } symbol) return null; - // The hidden nodes of the symbol are dropped in the element but not in the symbol alone. - if (visibleNodes && !symbol.HasVisibleNode && symbol.HasHiddenNode) return null; + // The hidden nodes of the symbol must be dropped in the element exactly when they are in the symbol alone. + if (symbol.HasHiddenNode && symbol.DropsUntakenNodes != dropsUntaken) return null; placements.Add(new SymbolPlacement(instance.SymbolId, transform)); failed += symbol.Failed; + ghosts |= symbol.HasGhostNodes; } - return placements.Count > 0 ? (placements, failed) : null; + return placements.Count > 0 ? (placements, failed, ghosts) : null; } var elements = new List(); @@ -103,7 +110,7 @@ int MaterialOf(GeoFace face) elements.Add(new ElementMesh { ElementId = element.Id, Coverage = MeshCoverage.Undecoded }); continue; } - if (options.UseSymbolPlacements && ReferenceEquals(body, element.Geometry) && PlacementsOf(body) is var (placements, failedInSymbols)) + if (options.UseSymbolPlacements && ReferenceEquals(body, element.Geometry) && PlacementsOf(body) is var (placements, failedInSymbols, symbolGhosts)) { foreach (var placement in placements) placedSymbols.TryAdd(placement.SymbolId, symbolCache[placement.SymbolId]!.Meshes); @@ -111,30 +118,37 @@ int MaterialOf(GeoFace face) { ElementId = element.Id, Placements = placements, - Coverage = CoverageOf(true, body, failedInSymbols), + Coverage = CoverageOf(true, body, failedInSymbols, symbolGhosts), UntriangulatedFaces = failedInSymbols, }); continue; } - var (meshes, failed) = Triangulate(body.Faces, MaterialOf); + var (meshes, failed) = Triangulate(body.Faces, MaterialOf, body.GhostNodes is { Count: > 0 }); elements.Add(new ElementMesh { ElementId = element.Id, Meshes = meshes, - Coverage = CoverageOf(meshes.Count > 0, body, failed), + Coverage = CoverageOf(meshes.Count > 0, body, failed, false), UntriangulatedFaces = failed, }); } return new MeshSet { Elements = elements, Symbols = placedSymbols, GeometryUndecoded = info.WideLayoutNotRewritten }; } - private static MeshCoverage CoverageOf(bool drawn, GeometryData geometry, int failed) => + // A ghost node is a Geometry node Revit exports that has no polygon here (display surfaces, or a duplicate of another node). + private static MeshCoverage CoverageOf(bool drawn, GeometryData geometry, int failed, bool symbolGhosts) => !drawn ? MeshCoverage.NoGeometry : geometry.PlacedSymbolBox ? MeshCoverage.BoxSubstitute - : failed > 0 || geometry.UnresolvedSymbol ? MeshCoverage.Partial + : failed > 0 || geometry.UnresolvedSymbol || symbolGhosts || geometry.GhostNodes is { Count: > 0 } ? MeshCoverage.Partial : MeshCoverage.Complete; - private sealed record SymbolMeshes(IReadOnlyList Meshes, int Failed, bool HasVisibleNode, bool HasHiddenNode); + private sealed record SymbolMeshes(IReadOnlyList Meshes, int Failed, bool DropsUntakenNodes, bool HasHiddenNode, + bool HasGhostNodes); + + /// Whether the faces of untaken Geometry nodes are left out: when a face of a taken node is there and the geometry has + /// no ghost node, the rule of the IFC export of the current profile. + private static bool DropsUntakenNodes(IReadOnlyList faces, bool hasGhostNodes) => + !hasGhostNodes && faces.Any(face => face.GeometryNode > 0); /// The drawable elements (see ) in document order, each with the geometry that is its body: /// the geometry of its computed symbol for a void helper that has one, its own geometry otherwise (null in a document whose @@ -172,10 +186,11 @@ private sealed record SymbolMeshes(IReadOnlyList Meshes, int Faile /// The triangles of , one mesh per material in the order the materials first appear, and the /// number of faces that could not be triangulated. The faces of untaken Geometry nodes (a negative node id) are left out when - /// a face of a taken node is there. - internal static (List Meshes, int Failed) Triangulate(IReadOnlyList faces, Func materialOf) + /// a face of a taken node is there, unless the geometry has ghost nodes (). + internal static (List Meshes, int Failed) Triangulate(IReadOnlyList faces, Func materialOf, + bool hasGhostNodes = false) { - bool dropUntakenNodes = faces.Any(face => face.GeometryNode > 0); + bool dropUntakenNodes = DropsUntakenNodes(faces, hasGhostNodes); var builders = new List(); var builderOfMaterial = new Dictionary(); int failed = 0; diff --git a/src/RvtIO/Meshes/MeshSet.cs b/src/RvtIO/Meshes/MeshSet.cs index ab435fe..31e6e98 100644 --- a/src/RvtIO/Meshes/MeshSet.cs +++ b/src/RvtIO/Meshes/MeshSet.cs @@ -43,7 +43,8 @@ public enum MeshCoverage /// Every decoded face is triangulated. Complete, - /// Some faces could not be triangulated, or a symbol the element places has no geometry. + /// Some faces could not be triangulated, a symbol the element places has no geometry, or a Geometry node that Revit + /// exports has no decoded polygon (display surfaces, or a duplicate of another node), so it may be missing. Partial, /// A symbol placed at a rescaled placement was replaced by its bounding box. @@ -79,6 +80,9 @@ public sealed class TriangleMesh public readonly record struct SymbolPlacement(int SymbolId, MeshTransform Transform); /// A point or a direction in feet. +/// The X coordinate, in feet. +/// The Y coordinate, in feet. +/// The Z coordinate (up), in feet. public readonly record struct MeshVector(double X, double Y, double Z); /// @@ -90,6 +94,10 @@ public sealed class TriangleMesh /// A t holds the 3x3 matrix row by row and then the translation, so the /// basis vectors are its columns: BasisX = (t[0], t[3], t[6]), BasisY = (t[1], t[4], t[7]), BasisZ = (t[2], t[5], t[8]) and /// Origin = (t[9], t[10], t[11]) (see ). +/// The image of the family origin, in feet: (t[9], t[10], t[11]). +/// The image of the family X axis: the column (t[0], t[3], t[6]). +/// The image of the family Y axis: the column (t[1], t[4], t[7]). +/// The image of the family Z axis: the column (t[2], t[5], t[8]). public readonly record struct MeshTransform(MeshVector Origin, MeshVector BasisX, MeshVector BasisY, MeshVector BasisZ) { /// The transform of the 12 values of a . diff --git a/tests/RvtIO.Tests/ElementMeshesTests.cs b/tests/RvtIO.Tests/ElementMeshesTests.cs index d3212d2..4510c33 100644 --- a/tests/RvtIO.Tests/ElementMeshesTests.cs +++ b/tests/RvtIO.Tests/ElementMeshesTests.cs @@ -192,6 +192,43 @@ public void UntakenNodes_AreDroppedOnlyBesideTakenNodes() Assert.Equal(0.5 + 4.5, Area(ElementMeshes.Triangulate([taken, untaken, unnumbered], _ => 0).Meshes), 12); // Without a taken node the untaken one is all the element has. Assert.Equal(2, Area(ElementMeshes.Triangulate([untaken], _ => 0).Meshes), 12); + // Beside a ghost node (exported, with no polygon here) the IFC export keeps the untaken nodes too. + Assert.Equal(0.5 + 2 + 4.5, Area(ElementMeshes.Triangulate([taken, untaken, unnumbered], _ => 0, hasGhostNodes: true).Meshes), 12); + } + + [Fact] + public void ElementWithGhostNodes_KeepsItsUntakenNodesAndIsPartial() + { + var geometry = new GeometryData + { + Faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1], node: 1), Face([[0, 0, 5], [2, 0, 5], [2, 2, 5]], [0, 0, 1], node: -2)], + GhostNodes = [(3, 0, 0)], + }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry))).Elements); + + Assert.Equal(0.5 + 2, Area(element.Meshes), 12); + Assert.Equal(MeshCoverage.Partial, element.Coverage); + } + + [Fact] + public void SymbolPlacedInAnElementWithGhostNodes_IsNotShared() + { + // The symbol alone drops its untaken node; the element, which has a ghost node, keeps it. + List faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1], node: 1), Face([[0, 0, 5], [2, 0, 5], [2, 2, 5]], [0, 0, 1], node: -2)]; + var geometry = new GeometryData + { + Faces = faces, + ExactMappedInstances = [new GeoInstance { SymbolId = 5, Transform = Identity }], + GhostNodes = [(3, 0, 0)], + }; + var symbol = new Element { Id = 5, Geometry = new GeometryData { Faces = faces } }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry), symbol)).Elements); + + Assert.Empty(element.Placements); + Assert.Equal(0.5 + 2, Area(element.Meshes), 12); + Assert.Equal(MeshCoverage.Partial, element.Coverage); } // ---- materials ---- From 15fc47bb4d9bd1b50af007128c7186c4888d8666 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:51:06 +0300 Subject: [PATCH 07/11] Describe which files need R22Layout to decode their geometry The reader keeps its own Revit 2022 layout only for Revit 2022 files; every other file it reads by the file's own class schema and decodes no element geometry without the option. Measured on the Revit 2020 and 2025 samples, which the docs had left out by naming Revit 2023 and later. --- docs/getting-started.md | 6 ++++-- docs/public-api.md | 2 +- src/RvtIO/Meshes/MeshSet.cs | 5 +++-- src/RvtIO/RvtReaderOptions.cs | 7 +++++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index fb7356f..0abe3e4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -64,8 +64,10 @@ foreach (var element in meshes.Elements) } ``` -Open a file of Revit 2023 or later with `new RvtReaderOptions { R22Layout = true }` to decode its geometry; without it -`MeshSet.GeometryUndecoded` is true and the elements are listed as `MeshCoverage.Undecoded`. +Open a file of any release other than Revit 2022 with `new RvtReaderOptions { R22Layout = true }` to decode its geometry +(the reader keeps its own Revit 2022 layout only for Revit 2022 files; measured on samples of Revit 2020 and 2025, while the +Revit 2022 samples give the same meshes either way). Without it `MeshSet.GeometryUndecoded` is true and the elements are listed +as `MeshCoverage.Undecoded`. The command line tool sets the option for every IFC export. The library is silent by default. Set `RvtReaderOptions.DiagnosticOutput` for reading or `IfcExporter.DiagnosticOutput` for export to receive diagnostics through a `TextWriter`. diff --git a/docs/public-api.md b/docs/public-api.md index 382f7b3..f242e64 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -48,7 +48,7 @@ extension and label helpers carry the ids and names stored in the file format. |---|---| | `ElementMeshes` | `Extract(document, options)` returns the triangle meshes of the drawable model elements of an opened `RvtDocument`, from the geometry the document already holds (the file is not read again). Coordinates are in feet, in the coordinates of the document (Revit internal coordinates, Z up). | | `ElementMeshOptions` | `UseSymbolPlacements` (default true): an element that is exactly its family symbols placed rigidly is returned as placements of shared symbol meshes; false gives every element its own triangles. | -| `MeshSet` | `Elements` (one `ElementMesh` per drawable element, in document order), `Symbols` (symbol element id to its triangle meshes in the frame of the family, only for placed symbols) and `GeometryUndecoded` (a file of the 64-bit-id layout read without `RvtReaderOptions.R22Layout`: its elements are listed as `Undecoded`). | +| `MeshSet` | `Elements` (one `ElementMesh` per drawable element, in document order), `Symbols` (symbol element id to its triangle meshes in the frame of the family, only for placed symbols) and `GeometryUndecoded` (a file of a release other than Revit 2022, read by its own class schema, opened without `RvtReaderOptions.R22Layout`: its elements are listed as `Undecoded`). | | `ElementMesh` | `ElementId`, `Meshes` (the element's own triangles, one mesh per material), `Placements`, `Coverage` and `UntriangulatedFaces`. | | `TriangleMesh` | `Positions` (x, y, z per vertex), `Indices` (three per triangle, counter-clockwise around the normals), `Normals` (one unit normal per vertex, flat per face: vertices are not shared between faces) and `MaterialId` (the material element id, 0 for none). | | `SymbolPlacement`, `MeshTransform`, `MeshVector` | A symbol id with its transform. `MeshTransform` maps a family point p to `Origin + BasisX * p.X + BasisY * p.Y + BasisZ * p.Z`; the basis vectors are the columns of the row-major 3x3 matrix of `GeoInstance.Transform` (`BasisX = (t[0], t[3], t[6])`, `Origin = (t[9], t[10], t[11])`, see `FromRowMajor`). A negative `Determinant` is a mirror image. | diff --git a/src/RvtIO/Meshes/MeshSet.cs b/src/RvtIO/Meshes/MeshSet.cs index 31e6e98..1c8dbda 100644 --- a/src/RvtIO/Meshes/MeshSet.cs +++ b/src/RvtIO/Meshes/MeshSet.cs @@ -11,8 +11,9 @@ public sealed class MeshSet /// in the frame of the family. Only placed symbols are listed. public IReadOnlyDictionary> Symbols { get; init; } = new Dictionary>(); - /// The document was read without decoding its geometry (a file with 64-bit element ids read without - /// ); its elements are listed with . + /// The document was read without decoding its geometry: a file read by its own class schema (a release other than Revit + /// 2022, such as the Revit 2020 and 2025 samples) opened without . Its elements are listed + /// with . public bool GeometryUndecoded { get; init; } } diff --git a/src/RvtIO/RvtReaderOptions.cs b/src/RvtIO/RvtReaderOptions.cs index b79b3d9..2a03c4a 100644 --- a/src/RvtIO/RvtReaderOptions.cs +++ b/src/RvtIO/RvtReaderOptions.cs @@ -24,8 +24,11 @@ public class RvtReaderOptions /// are one rectangle with different holes. Off by default, which keeps the geometry the legacy profile export is built on. public bool SeparateFacesWithDistinctOpenings { get; set; } - /// Rewrite the partition streams of a file with a later release's class schema (Revit 2023 and later) into the Revit 2022 - /// layout, so that the geometry and element readers written for 2022 see them (see ). Off by default. + /// Rewrite the partition streams of a file that is read by its own class schema into the Revit 2022 layout, so that the + /// geometry and element readers written for 2022 see them (see ). The reader keeps the layout it + /// ships only for a file of Revit 2022 (or of an unknown release); any other file whose own schema it can read is read by that + /// schema, and its element geometry is decoded only with this option (measured on samples of Revit 2020 and 2025; the Revit 2022 + /// samples decode the same either way). Off by default; the command line tool turns it on for an IFC export. public bool R22Layout { get; set; } /// When several partition streams hold a copy of the same element, keep the current copy named by the file's document From 25b844a260cdab409cbc94bd17d7e0c010185d7f Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:51:06 +0300 Subject: [PATCH 08/11] Publish the NuGet packages before creating the GitHub release The release notes are extracted before anything is published, the push runs before gh release create so that a refused first publication can be fixed and the job run again, and a tag with a hyphen creates a pre-release. --- .github/workflows/release.yml | 40 ++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d70956..0b6a620 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,17 @@ jobs: if ($env:GITHUB_REF_NAME -cne "v$($version.Trim())") { throw "The release tag must match the package version v$($version.Trim())." } + # Before anything is published: a tag whose commit has no changelog section for the version fails here. + - name: Extract release notes + shell: pwsh + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 + $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) + if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } + $notes = @() + for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } + $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 - run: dotnet restore RvtIO.slnx - run: dotnet build RvtIO.slnx -c Release --no-restore - run: dotnet test RvtIO.slnx -c Release --no-build @@ -37,21 +48,8 @@ jobs: with: name: packages path: artifacts/* - - name: Create release - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $version = $env:GITHUB_REF_NAME.Substring(1) - $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 - $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) - if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } - $notes = @() - for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } - $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 - $files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName }) - gh release create $env:GITHUB_REF_NAME --title $env:GITHUB_REF_NAME --notes-file release-notes.md @files - if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" } + # The push comes before the GitHub release: a refused first publication of a package id can be fixed and the + # job run again, because --skip-duplicate makes the push repeatable and no release exists yet. # Trusted publishing: the NuGet policy for this repository and workflow file exchanges the GitHub OIDC token for a short-lived key. - uses: NuGet/login@v1 id: nuget-login @@ -64,3 +62,15 @@ jobs: dotnet nuget push $package.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($package.Name) with exit code $LASTEXITCODE" } } + - name: Create release + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + $files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName }) + # A version with a hyphen is a prerelease. + $flags = @() + if ($version.Contains('-')) { $flags += '--prerelease' } + gh release create $env:GITHUB_REF_NAME --title $env:GITHUB_REF_NAME --notes-file release-notes.md @flags @files + if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" } From 73b7ecc8070f0311e3d46e73dc746e8166a374f3 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:51:06 +0300 Subject: [PATCH 09/11] Update the counts of tests that run without the private fixtures --- docs/test-fixtures.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md index bcbb71d..35201a5 100644 --- a/docs/test-fixtures.md +++ b/docs/test-fixtures.md @@ -45,8 +45,9 @@ folder (`TestFiles.Scratch`); no test uses a fixed absolute path. ## What runs without the private files -With no private file at all, 9685 of the 10103 test cases run and pass and 418 are skipped: 412 need private files (listed -below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) and 6 need other inputs (two need symbolic links, three +With no private file at all, 10406 of the 10848 test cases run and pass on net8.0 and 442 are skipped (net10.0: 10410 of +10852, 442 skipped): 436 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) +and 6 need other inputs (two need symbolic links, three need `RVTIO_FREEFORM_FULL`, `RVTIO_CYL_FULL` or `RVTIO_MESH_FULL`, one is a known gap). The tests that run use synthetic data built in the tests and the small fixtures committed under `tests/RvtIO.Tests/Fixtures`. With the full private fixture folder the remaining cases run as well. @@ -55,7 +56,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: ## Tests that need the private files -281 test methods (413 cases, theory rows counted) are skipped without the files: +293 test methods (436 cases, theory rows counted) are skipped without the files: | Class | Methods | Cases | Files | |---|---|---|---| @@ -70,6 +71,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `CompactPropertySourcesTests` | 4 | 4 | Test_2022.rvt, TESTFILE.rvt | | `CompactSolidValueTests` | 5 | 6 | Test_2022.rvt, TESTFILE.rvt | | `CompactUserParametersTests` | 2 | 2 | Test_2022.rvt | +| `CompoundLayerFunctionTests` | 1 | 1 | rac_basic_sample_project.rvt | | `ContainerRewriterSampleTests` | 3 | 3 | any `.rvt` project files | | `CoverageCensusSampleTests` | 1 | 3 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt | | `CurrentCopiesDefaultTests` | 2 | 2 | TESTFILE.rvt, test_angled_wall.rvt | @@ -80,6 +82,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `EdgeBoundsTests` | 3 | 15 | TestData blocks | | `ElemTableAuthorityTests` | 2 | 2 | TESTFILE.rvt | | `ElementExportIdTests` | 3 | 6 | TESTFILE.rvt | +| `ElementHeaderBoundingBoxTests` | 2 | 13 | one `.rvt` project per release | | `ElementHeaderCategoryTest2022Tests` | 2 | 2 | TESTFILE.rvt, Test_2022.rvt | | `ElementHeaderCategoryTestFileTests` | 1 | 1 | TESTFILE.rvt | | `ElementMeshesSampleTests` | 5 | 5 | Test_2022.rvt, Test_2025.rvt, test_door_in_wall.rvt | @@ -94,7 +97,8 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `IfcCurrentSourcePlaneSlopeTests` | 1 | 4 | TestData blocks | | `IfcExporterPlainWallBodyCurrentTests` | 4 | 4 | TestData blocks | | `IfcExporterSkipReasonTests` | 1 | 1 | TESTFILE.rvt | -| `IfcExporterSlabQuantitiesSampleTests` | 2 | 2 | rstbasicsampleproject.rvt, struct25.rvt | +| `IfcExporterSlabQuantitiesSampleTests` | 3 | 3 | rstbasicsampleproject.rvt, struct25.rvt | +| `MultistoryStairCopyTests` | 1 | 1 | rac_advanced_sample_project.rvt | | `PageCodecSampleTests` | 2 | 2 | any `.rvt` project files | | `PartitionFramingFuzzTests` | 1 | 1 | TESTFILE.rvt | | `ProfileRoofAreaTests` | 3 | 11 | TestData blocks | @@ -108,11 +112,13 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `SchemaStreamEditorSampleTests` | 2 | 2 | any `.rvt` project files | | `SpaceBoundaryLiteralTests` | 5 | 5 | TestData blocks | | `StairComponentSampleTests` | 6 | 16 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt, rac_basic_sample_project.rvt | +| `SystemFamilyNameTests` | 1 | 1 | Test_2020.rvt | | `TfSchemaToolsTests` | 3 | 6 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt | | `UserParameterCatalogTests` | 1 | 1 | Test_2022.rvt, TESTFILE.rvt | | `ValidatedElementNamesTest2022Tests` | 2 | 2 | Test_2022.rvt, TESTFILE.rvt | | `ValidatedTests` | 72 | 88 | TESTFILE.rvt | | `VkConnectorBlockTests` | 6 | 6 | TestData blocks | +| `WallWithoutSolidAndTypeElementTests` | 1 | 1 | Test_2025.rvt | | `WideBlockWalkerTests` | 3 | 14 | Test_2025.rvt, TESTFILE.rvt | | `WideElementRecordsTests` | 2 | 2 | Test_2025.rvt, TESTFILE.rvt | From 152fc14c3798caafa33410cd538d0a413d4a3024 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:42:48 +0300 Subject: [PATCH 10/11] Report a placed symbol whose nested symbol has no geometry as partial coverage --- CHANGELOG.md | 2 +- docs/public-api.md | 2 +- src/RvtIO/Meshes/MeshSet.cs | 5 +- src/RvtIO/Model/Geometry.cs | 3 +- src/RvtIO/Parsing/ElementBuilder.cs | 22 ++++- .../ComputedSymbolGeometryTests.cs | 15 +++ tests/RvtIO.Tests/ElementMeshesTests.cs | 98 ++++++++++++++++++- 7 files changed, 134 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d82dce..9789136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ API between minor releases. ### Added -- API: `ElementMeshes.Extract` (namespace `RvtIO.Meshes`) returns the triangle meshes of the drawable model elements of an opened `RvtDocument`, in feet, without reading the file again: per element its own triangles (one mesh per material, flat normals, counter-clockwise around them) and, for an element that is exactly its family symbols placed rigidly, the placements of shared symbol meshes in the frame of the family, with a coverage per element (complete, partial, box in place of a rescaled symbol, no geometry, undecoded; partial also when a Geometry node that Revit exports has no decoded polygon). A cut or clipped instance, an instance with faces of its own and a scaled one keep their own triangles; a mirrored placement keeps the winding of its symbol and carries the mirror in its transform. The drawable elements follow a subset of the product rules of the IFC export of the current profile (listed in the `ElementMeshes` remarks, with those not applied, such as the "export to IFC" flag and topography, so the meshes can include elements the IFC leaves out); the face materials and the hidden Geometry nodes follow its rules. The types are listed in [the public API](docs/public-api.md). IFC and JSON output is unchanged. +- API: `ElementMeshes.Extract` (namespace `RvtIO.Meshes`) returns the triangle meshes of the drawable model elements of an opened `RvtDocument`, in feet, without reading the file again: per element its own triangles (one mesh per material, flat normals, counter-clockwise around them) and, for an element that is exactly its family symbols placed rigidly, the placements of shared symbol meshes in the frame of the family, with a coverage per element (complete, partial, box in place of a rescaled symbol, no geometry, undecoded; partial also when a placed symbol or a symbol nested in one has no geometry, or a Geometry node that Revit exports has no decoded polygon). A cut or clipped instance, an instance with faces of its own and a scaled one keep their own triangles; a mirrored placement keeps the winding of its symbol and carries the mirror in its transform. The drawable elements follow a subset of the product rules of the IFC export of the current profile (listed in the `ElementMeshes` remarks, with those not applied, such as the "export to IFC" flag and topography, so the meshes can include elements the IFC leaves out); the face materials and the hidden Geometry nodes follow its rules. The types are listed in [the public API](docs/public-api.md). IFC and JSON output is unchanged. - CLI and diagnostics: `--strict` exits with code 6 (`RvtExitCodes.PartialRead`) instead of 0 when a read finishes partial without exceeding a reader limit (a stream member failed to decode, was rebuilt by structural recovery, or decoded without a matching checksum, or `Global/ElemTable` decoded no row). Nothing is exported when exit code 6 is returned; `limits.strict` records the option and `issues[]` lists the affected streams and reasons (`failed-members`, `recovered-members`, `unverified-members`, `no-rows`) alongside `streams[]`. Default behavior without `--strict` is unchanged. (Closes #54) - Default output, current IFC profile: every beam with a straight drive curve gets `Pset_BeamCommon.Slope`, the angle in degrees of its drive curve to the horizontal (0 for a horizontal beam, never negative), whether or not BaseQuantities are exported and whatever its body; its quantities are unchanged. A beam without a straight drive curve gets no Slope. - Default output, current IFC profile: `Pset_BeamCommon.Span` of a beam without a stored length is the length of its straight drive curve; a beam without one has no Span. diff --git a/docs/public-api.md b/docs/public-api.md index aa67d47..f12a65a 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -52,7 +52,7 @@ extension and label helpers carry the ids and names stored in the file format. | `ElementMesh` | `ElementId`, `Meshes` (the element's own triangles, one mesh per material), `Placements`, `Coverage` and `UntriangulatedFaces`. | | `TriangleMesh` | `Positions` (x, y, z per vertex), `Indices` (three per triangle, counter-clockwise around the normals), `Normals` (one unit normal per vertex, flat per face: vertices are not shared between faces) and `MaterialId` (the material element id, 0 for none). | | `SymbolPlacement`, `MeshTransform`, `MeshVector` | A symbol id with its transform. `MeshTransform` maps a family point p to `Origin + BasisX * p.X + BasisY * p.Y + BasisZ * p.Z`; the basis vectors are the columns of the row-major 3x3 matrix of `GeoInstance.Transform` (`BasisX = (t[0], t[3], t[6])`, `Origin = (t[9], t[10], t[11])`, see `FromRowMajor`). A negative `Determinant` is a mirror image. | -| `MeshCoverage` | `Complete`, `Partial` (some faces did not triangulate, a placed symbol has no geometry, or a Geometry node that Revit exports has no decoded polygon), `BoxSubstitute` (a symbol placed at a rescaled placement was replaced by its box), `NoGeometry` (a geometry record with nothing to draw) and `Undecoded`. | +| `MeshCoverage` | `Complete`, `Partial` (some faces did not triangulate, a placed symbol or a symbol nested in one has no geometry, or a Geometry node that Revit exports has no decoded polygon), `BoxSubstitute` (a symbol placed at a rescaled placement was replaced by its box), `NoGeometry` (a geometry record with nothing to draw) and `Undecoded`. | A placement is given only when it is shown that the element is exactly its symbols placed: the element's geometry record holds no face of its own, the tight box of the element cut nothing off the placed symbols, every symbol resolved, no box was put in diff --git a/src/RvtIO/Meshes/MeshSet.cs b/src/RvtIO/Meshes/MeshSet.cs index 1c8dbda..7cf83c7 100644 --- a/src/RvtIO/Meshes/MeshSet.cs +++ b/src/RvtIO/Meshes/MeshSet.cs @@ -44,8 +44,9 @@ public enum MeshCoverage /// Every decoded face is triangulated. Complete, - /// Some faces could not be triangulated, a symbol the element places has no geometry, or a Geometry node that Revit - /// exports has no decoded polygon (display surfaces, or a duplicate of another node), so it may be missing. + /// Some faces could not be triangulated, a symbol the element places (or a symbol nested in one) has no geometry, or a + /// Geometry node that Revit exports has no decoded polygon (display surfaces, or a duplicate of another node), so it may be + /// missing. Partial, /// A symbol placed at a rescaled placement was replaced by its bounding box. diff --git a/src/RvtIO/Model/Geometry.cs b/src/RvtIO/Model/Geometry.cs index 028675b..6acdf68 100644 --- a/src/RvtIO/Model/Geometry.cs +++ b/src/RvtIO/Model/Geometry.cs @@ -71,7 +71,8 @@ internal bool HasCurvedFace(IReadOnlyCollection? nodes = null) => MeshNodes /// A component placed at a rescaled placement was replaced by its tight box (ElementBuilder.ScaledComponentFaceBudget). internal bool PlacedSymbolBox { get; set; } - /// A GInstance of this geometry placed nothing: its symbol has no geometry. + /// A GInstance of this geometry, or of a symbol it places at any depth, placed nothing: its symbol has no + /// geometry. internal bool UnresolvedSymbol { get; set; } /// Number of stored GFace and Edge objects in the GRep block this geometry was diff --git a/src/RvtIO/Parsing/ElementBuilder.cs b/src/RvtIO/Parsing/ElementBuilder.cs index c4f9489..7ee6d0f 100644 --- a/src/RvtIO/Parsing/ElementBuilder.cs +++ b/src/RvtIO/Parsing/ElementBuilder.cs @@ -19,10 +19,12 @@ private static void ExpandInstanceGeometry(Element elem, GRepData grep, int type int ownFaceCount = elem.Geometry.Faces.Count; var expandedTopLevel = new List(); var placedBox = new StrongBox(); + var unresolvedNested = new StrongBox(); foreach (var instance in grep.Instances) { var placed = BuildPlacedSymbolGeometry( - instance.SymbolElemId, instance.Transform, state, new HashSet(), placedBox: placedBox); + instance.SymbolElemId, instance.Transform, state, new HashSet(), placedBox: placedBox, + unresolvedNested: unresolvedNested); if (placed == null) { elem.Geometry.UnresolvedSymbol = true; @@ -44,6 +46,7 @@ private static void ExpandInstanceGeometry(Element elem, GRepData grep, int type } DeduplicateExactFaces(elem.Geometry); if (placedBox.Value) elem.Geometry.PlacedSymbolBox = true; + if (unresolvedNested.Value) elem.Geometry.UnresolvedSymbol = true; if (ownFaceCount == 0 && expandedTopLevel.Count > 0) elem.Geometry.ExactMappedInstances = expandedTopLevel; } @@ -59,7 +62,9 @@ private static void ExpandTransformOnlyInstanceGeometry( if (!state.FamilyInstanceTransforms.TryGetValue(elemId, out var fi)) return; if (fi.SymbolId <= 0 || fi.SymbolId == elem.Id) return; var placedBox = new StrongBox(); - var placed = BuildPlacedSymbolGeometry(fi.SymbolId, fi.Trf, state, new HashSet(), placedBox: placedBox); + var unresolvedNested = new StrongBox(); + var placed = BuildPlacedSymbolGeometry(fi.SymbolId, fi.Trf, state, new HashSet(), placedBox: placedBox, + unresolvedNested: unresolvedNested); if (placed == null) { if (elem.Geometry != null) elem.Geometry.UnresolvedSymbol = true; @@ -70,6 +75,7 @@ private static void ExpandTransformOnlyInstanceGeometry( bool hadOwnFaces = elem.Geometry.Faces.Count > 0; AppendGeometry(elem.Geometry, placed); if (placedBox.Value) elem.Geometry.PlacedSymbolBox = true; + if (unresolvedNested.Value) elem.Geometry.UnresolvedSymbol = true; elem.Geometry.Instances ??= new List(); elem.Geometry.Instances.Add(new GeoInstance { @@ -104,8 +110,11 @@ internal static void AttachComputedSymbolGeometry(Element elem, uint elemId, Par || !state.GrepBlocks.TryGetValue((uint)symbol.SymbolId, out var symbolGrep) || !symbolGrep.Faces.Any(face => face.GeometryNode > 0 && face.GInfoFlags is uint flags && (flags & ProductRules.FaceFillFlag) != 0)) return; - var placed = BuildPlacedSymbolGeometry(symbol.SymbolId, transform, state, new HashSet()); + var unresolvedNested = new StrongBox(); + var placed = BuildPlacedSymbolGeometry(symbol.SymbolId, transform, state, new HashSet(), + unresolvedNested: unresolvedNested); if (placed?.Faces is not { Count: > 0 }) return; + placed.UnresolvedSymbol = unresolvedNested.Value; placed.ExactMappedInstances = [new GeoInstance { SymbolId = symbol.SymbolId, Transform = (double[])transform.Clone() }]; // The placed symbol carries the tight box of the instance's decoded geometry; the header // outline on Element.BoundingBox is the API envelope. @@ -214,9 +223,11 @@ private static double[] TransformGeometryPoint(double[] point, double[] t) => ]; /// Set to true when this symbol or a nested one is replaced by its tight box. + /// Set to true when a symbol nested in this one, at any depth, placed nothing. internal static GeometryData? BuildPlacedSymbolGeometry( int symbolId, double[] transform, ParsingState state, HashSet path, - int scaledComponentFaceBudget = ScaledComponentFaceBudget, StrongBox? placedBox = null) + int scaledComponentFaceBudget = ScaledComponentFaceBudget, StrongBox? placedBox = null, + StrongBox? unresolvedNested = null) { if (symbolId <= 0 || transform.Length < 12 || !path.Add(symbolId)) return null; try @@ -251,8 +262,9 @@ static int MostFrequent(IEnumerable values) => values.GroupBy(value => valu { var childTransform = ComposeTransforms(transform, child.Transform); var placedChild = BuildPlacedSymbolGeometry( - child.SymbolElemId, childTransform, state, path, scaledComponentFaceBudget, placedBox); + child.SymbolElemId, childTransform, state, path, scaledComponentFaceBudget, placedBox, unresolvedNested); if (placedChild != null) AppendGeometry(result, placedChild, child.NodeOrder); + else if (unresolvedNested != null) unresolvedNested.Value = true; } if (result.Faces.Count == 0) return null; diff --git a/tests/RvtIO.Tests/ComputedSymbolGeometryTests.cs b/tests/RvtIO.Tests/ComputedSymbolGeometryTests.cs index 16061f9..2e72ab4 100644 --- a/tests/RvtIO.Tests/ComputedSymbolGeometryTests.cs +++ b/tests/RvtIO.Tests/ComputedSymbolGeometryTests.cs @@ -77,6 +77,21 @@ public void VoidHelperWithAFilledComputedSymbol_GetsTheSymbolPlacedByItsTransfor Assert.False(IfcExporter.HasOnlyUnfilledFacesCurrent(element)); } + [Fact] + public void ComputedSymbolThatPlacesASymbolWithoutGeometry_IsMarkedUnresolved() + { + var element = Instance(7, Unfilled); + ElementBuilder.AttachComputedSymbolGeometry(element, 7, State()); + Assert.False(element.SerializedComputedSymbolGeometry!.UnresolvedSymbol); + + var state = State(); + state.GrepBlocks[70].Instances = [new GRepInstance { SymbolElemId = 999, Transform = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0] }]; + ElementBuilder.AttachComputedSymbolGeometry(element, 7, state); + + Assert.Equal(4, element.SerializedComputedSymbolGeometry!.Faces.Count); + Assert.True(element.SerializedComputedSymbolGeometry.UnresolvedSymbol); + } + [Fact] public void InstanceWithAFilledFace_KeepsItsOwnGeometry() { diff --git a/tests/RvtIO.Tests/ElementMeshesTests.cs b/tests/RvtIO.Tests/ElementMeshesTests.cs index 4510c33..1e926b2 100644 --- a/tests/RvtIO.Tests/ElementMeshesTests.cs +++ b/tests/RvtIO.Tests/ElementMeshesTests.cs @@ -72,18 +72,38 @@ private static RvtDocument PlacedSymbol(double[] transform, double[]? instanceBo instance.BoundingBox = instanceBox; instance.Instances = [new GRepInstance { SymbolElemId = SymbolId, Transform = transform }]; state.GrepBlocks[InstanceId] = instance; + return Built(state, SymbolId); + } + + /// The symbols and then the instance, built from the blocks of by the + /// reader's own element builder; the instance is then made a drawable product. + private static RvtDocument Built(ParsingState state, params uint[] symbolIds) + { var info = new RevitFileInfo(); - ElementBuilder.BuildBlockOnlyElement(SymbolId, state, info); + foreach (uint symbolId in symbolIds) ElementBuilder.BuildBlockOnlyElement(symbolId, state, info); ElementBuilder.BuildBlockOnlyElement(InstanceId, state, info); - var element = info.Elements.Single(candidate => candidate.Id == InstanceId); + MakeProduct(info.Elements.Single(candidate => candidate.Id == InstanceId)); + return RvtDocument.FromInfo(info); + } + + /// Makes a built element a drawable product: a generic model that names its project phase. + private static void MakeProduct(Element element) + { element.IsBlockOnly = false; element.CategoryId = -2000151; element.Category = "OST_GenericModel"; element.Parameters.Add(new Parameter { Id = -1012100, Value = Phase }); element.SerializedCreatedPhaseId = Phase; - return RvtDocument.FromInfo(info); } + /// A symbol block of one triangle at that places the symbols where it + /// stands. + private static GRepData TriangleBlock(double x, params int[] nested) => new() + { + Faces = [new GRepFace { Vertices = [[x, 0, 0], [x + 1, 0, 0], [x, 1, 0]], Origin = [x, 0, 0], XVector = [0, 0, 1], GeometryNode = 1 }], + Instances = nested.Select(id => new GRepInstance { SymbolElemId = id, Transform = Identity }).ToList(), + }; + // ---- geometry checks ---- private static IEnumerable<(double[] A, double[] B, double[] C, double[] Normal)> Triangles(TriangleMesh mesh) @@ -474,6 +494,78 @@ public void UnresolvedSymbol_IsPartial() Assert.Equal(0, element.UntriangulatedFaces); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SymbolNestedInThePlacedSymbolWithoutGeometry_IsPartial(bool useSymbolPlacements) + { + // The placed symbol draws its triangle; the symbol it places in turn has no geometry block. + var state = new ParsingState(); + state.GrepBlocks[InstanceId] = new GRepData { Instances = [new GRepInstance { SymbolElemId = SymbolId, Transform = Identity }] }; + state.GrepBlocks[SymbolId] = TriangleBlock(0, 999); + + var element = Assert.Single(ElementMeshes.Extract(Built(state, SymbolId), + new ElementMeshOptions { UseSymbolPlacements = useSymbolPlacements }).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + Assert.Equal(useSymbolPlacements ? 1 : 0, element.Placements.Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SymbolTwoLevelsDownWithoutGeometry_IsPartial(bool useSymbolPlacements) + { + var state = new ParsingState(); + state.GrepBlocks[InstanceId] = new GRepData { Instances = [new GRepInstance { SymbolElemId = SymbolId, Transform = Identity }] }; + state.GrepBlocks[SymbolId] = TriangleBlock(0, 3); + state.GrepBlocks[3] = TriangleBlock(2, 999); + + var element = Assert.Single(ElementMeshes.Extract(Built(state, 3, SymbolId), + new ElementMeshOptions { UseSymbolPlacements = useSymbolPlacements }).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NestedSymbolsThatAllHaveGeometry_AreComplete(bool useSymbolPlacements) + { + var state = new ParsingState(); + state.GrepBlocks[InstanceId] = new GRepData { Instances = [new GRepInstance { SymbolElemId = SymbolId, Transform = Identity }] }; + state.GrepBlocks[SymbolId] = TriangleBlock(0, 3); + state.GrepBlocks[3] = TriangleBlock(2); + + var set = ElementMeshes.Extract(Built(state, 3, SymbolId), new ElementMeshOptions { UseSymbolPlacements = useSymbolPlacements }); + + var element = Assert.Single(set.Elements); + Assert.Equal(MeshCoverage.Complete, element.Coverage); + Assert.Equal(1, Area(useSymbolPlacements ? set.Symbols[SymbolId] : element.Meshes), 12); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TransformOnlyInstanceOfASymbolWithANestedSymbolWithoutGeometry_IsPartial(bool useSymbolPlacements) + { + // A family instance record with a placement and no geometry block of its own: its geometry is its symbol placed. + var state = new ParsingState(); + state.GrepBlocks[SymbolId] = TriangleBlock(0, 999); + state.Records[InstanceId] = new ElementRecord { ElemId = InstanceId }; + state.FamilyInstanceTransforms[InstanceId] = new FamilyInstanceData { SymbolId = SymbolId, Trf = Identity }; + var info = new RevitFileInfo(); + ElementBuilder.BuildBlockOnlyElement(SymbolId, state, info); + ElementBuilder.BuildDiscoveredElement(InstanceId, state, info); + MakeProduct(info.Elements.Single(candidate => candidate.Id == InstanceId)); + + var element = Assert.Single(ElementMeshes.Extract(RvtDocument.FromInfo(info), + new ElementMeshOptions { UseSymbolPlacements = useSymbolPlacements }).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + Assert.Equal(useSymbolPlacements ? 1 : 0, element.Placements.Count); + } + [Fact] public void UntriangulatedFace_IsPartial() { From 8ccee832f25d02094a8d668c22845a99e3ddbcca Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:42:48 +0300 Subject: [PATCH 11/11] Update the counts of tests that run without the private fixtures --- docs/test-fixtures.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md index 44bc254..e89ad49 100644 --- a/docs/test-fixtures.md +++ b/docs/test-fixtures.md @@ -45,8 +45,8 @@ folder (`TestFiles.Scratch`); no test uses a fixed absolute path. ## What runs without the private files -With no private file at all, 10415 of the 10858 test cases run and pass on net8.0 and 443 are skipped (net10.0: 10419 of -10862, 443 skipped): 437 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) +With no private file at all, 10424 of the 10867 test cases run and pass on net8.0 and 443 are skipped (net10.0: 10428 of +10871, 443 skipped): 437 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) and 6 need other inputs (two need symbolic links, three need `RVTIO_FREEFORM_FULL`, `RVTIO_CYL_FULL` or `RVTIO_MESH_FULL`, one is a known gap). The tests that run use synthetic data built in the tests and the small fixtures committed under `tests/RvtIO.Tests/Fixtures`. With the full private fixture folder the