From 0e58a8f7b593a8349cf70612cd1330af7081a084 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:04:52 +0300 Subject: [PATCH 01/13] Add RvtIO.Scene, which turns a document into an IOScene scene RvtSceneReader maps the element meshes into one model: placed symbols as shared geometry with one instance per placement and mesh, an element's own meshes local to their lowest corner, lengths in metres, the UniqueId as the source id, names, categories, materials and the instance and type parameters as text property sets with revit: keys. Incomplete geometry and an undecoded file are reported as diagnostics. The project is not packable while the scene contract is a preview; it references IOScene with the range [0.1.0-preview.1, 0.2.0-0). --- Directory.Packages.props | 5 + RvtIO.slnx | 1 + src/RvtIO.Scene/RvtIO.Scene.csproj | 24 +++ src/RvtIO.Scene/RvtSceneOptions.cs | 10 ++ src/RvtIO.Scene/RvtSceneReader.cs | 261 +++++++++++++++++++++++++++++ src/RvtIO.Scene/RvtSceneResult.cs | 34 ++++ 6 files changed, 335 insertions(+) create mode 100644 src/RvtIO.Scene/RvtIO.Scene.csproj create mode 100644 src/RvtIO.Scene/RvtSceneOptions.cs create mode 100644 src/RvtIO.Scene/RvtSceneReader.cs create mode 100644 src/RvtIO.Scene/RvtSceneResult.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index c23f96d..5089a80 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,11 @@ + + + + + diff --git a/RvtIO.slnx b/RvtIO.slnx index 19ec40a..fab0412 100644 --- a/RvtIO.slnx +++ b/RvtIO.slnx @@ -2,6 +2,7 @@ + diff --git a/src/RvtIO.Scene/RvtIO.Scene.csproj b/src/RvtIO.Scene/RvtIO.Scene.csproj new file mode 100644 index 0000000..06c0cea --- /dev/null +++ b/src/RvtIO.Scene/RvtIO.Scene.csproj @@ -0,0 +1,24 @@ + + + + Turns a Revit project read by RvtIO into an IOScene scene document. + net8.0;net10.0 + RvtIO.Scene + RvtIO.Scene + + false + true + + $(NoWarn);NU5104 + + + + + + + + + + + + diff --git a/src/RvtIO.Scene/RvtSceneOptions.cs b/src/RvtIO.Scene/RvtSceneOptions.cs new file mode 100644 index 0000000..c3f9210 --- /dev/null +++ b/src/RvtIO.Scene/RvtSceneOptions.cs @@ -0,0 +1,10 @@ +namespace RvtIO.Scene; + +/// Options of . +public sealed class RvtSceneOptions +{ + /// The key of the model in the scene (SceneModel.Key). With the SourceId of an element it identifies the + /// element outside the scene, so it must stay the same for every conversion of one file. Null takes the file name without its + /// directory (with the extension) when a path is read; a document passed as it is has no file name and needs this key. + public string? ModelKey { get; set; } +} diff --git a/src/RvtIO.Scene/RvtSceneReader.cs b/src/RvtIO.Scene/RvtSceneReader.cs new file mode 100644 index 0000000..4e942a5 --- /dev/null +++ b/src/RvtIO.Scene/RvtSceneReader.cs @@ -0,0 +1,261 @@ +using System.Globalization; +using System.Reflection; +using IOScene; +using RvtIO.BuiltIns; +using RvtIO.Meshes; +using RvtIO.Model; + +namespace RvtIO.Scene; + +/// +/// Turns a Revit document into a scene (): one model, one element per drawable element of the document +/// with its triangle meshes (), its names and its parameters. +/// +/// +/// Lengths are converted from feet to metres (x 0.3048) into the internal coordinates of the document, Z up. A symbol that +/// elements place whole () is one geometry per mesh, in the frame of its family, and each placement +/// is one instance per mesh with the placement's transform; a mirrored placement keeps its transform, so the winding of the +/// symbol's triangles is not changed. A mesh of an element's own is a geometry used once: its vertices are relative to the +/// lowest corner of the element's own meshes, which is the translation of its instances. Normals and indices are those of +/// the meshes. A mesh with a material has the instance material of that material element: its name and, where the file +/// gives it, its colour (else a neutral grey). +/// An element's SourceId is its Revit UniqueId, Category the English name of its built-in category (else the +/// category the reader gives), TypeName the name of its type element. Its parameters are the property set +/// revit:instance; the parameters of its type are the property set revit:type, one set shared by every element of +/// the type. Values are text: the value the file stores, in Revit internal units, written with the invariant culture; the +/// key of a built-in parameter is revit:bip:<id>, other parameters have no key. Levels, typed values and IFC +/// GlobalIds are not written. +/// +public static class RvtSceneReader +{ + private const double MetresPerFoot = 0.3048; + private const int ModelId = 1; + + /// Reads the Revit file at with the options the RvtIO command line tool reads it with for an + /// IFC export (the Revit 2022 layout, English system family names, faces with distinct openings kept apart) and turns it + /// into a scene. + /// is empty, or is empty. + public static RvtSceneResult Read(string path, RvtSceneOptions? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(path); + string key = options?.ModelKey ?? Path.GetFileName(path); + if (key.Length == 0) throw new ArgumentException("The model key is empty.", nameof(options)); + var readDiagnostics = new RvtReadDiagnostics(); + var document = RvtDocument.Open(path, new RvtReaderOptions + { + R22Layout = true, + SystemFamilyNameLocale = "ENU", + SeparateFacesWithDistinctOpenings = true, + Diagnostics = readDiagnostics, + }); + // A cut read is what the command line tool refuses to export; here the scene is made and the cut is an error. + var cut = readDiagnostics.LimitHits.Select(hit => new SceneDiagnostic(SceneSeverity.Error, "read-cut", + $"The stream {hit.Stream} was cut by the reader limit {hit.Limit}: {hit.Detail}. Elements may be missing.")); + return Map(document, ElementMeshes.Extract(document), key, cut); + } + + /// Turns an opened document into a scene. + /// The document, opened by the caller; open a file of a release other than Revit 2022 with + /// , or its elements have no geometry. + /// The options; is required, as a document has no file name. + /// is null. + /// is null or empty. + public static RvtSceneResult Read(RvtDocument document, RvtSceneOptions? options) + { + ArgumentNullException.ThrowIfNull(document); + if (options?.ModelKey is not { Length: > 0 } key) + throw new ArgumentException("A document has no file name: give the model key in RvtSceneOptions.ModelKey.", nameof(options)); + return Map(document, ElementMeshes.Extract(document), key); + } + + /// The scene of the meshes of ; the element facts come from the document. + internal static RvtSceneResult Map(RvtDocument document, MeshSet meshes, string modelKey, IEnumerable? diagnostics = null) + { + var scene = new SceneDocument { Title = modelKey }; + scene.Models.Add(new SceneModel + { + Id = ModelId, Key = modelKey, Name = modelKey, SourceFormat = "rvt", SourceLengthUnit = "ft", Producer = Producer, + }); + var report = new List(diagnostics ?? []); + if (meshes.GeometryUndecoded) + report.Add(new(SceneSeverity.Error, "geometry-undecoded", + "The geometry of the file was not decoded: open it with RvtReaderOptions.R22Layout = true. Its elements have no geometry in the scene.")); + + var materials = new HashSet(); + int? MaterialOf(int id) + { + if (id <= 0) return null; + if (materials.Add(id)) scene.Materials.Add(Material(document, id)); + return id; + } + + var symbols = new Dictionary>(); + foreach (var (symbolId, symbolMeshes) in meshes.Symbols.OrderBy(symbol => symbol.Key)) + symbols[symbolId] = symbolMeshes.Select(mesh => (AddGeometry(scene, mesh, default), MaterialOf(mesh.MaterialId))).ToList(); + + var typeSets = new Dictionary(); + var coverage = new Dictionary(); + int withGeometry = 0, placements = 0, withoutUniqueId = 0; + foreach (var mesh in meshes.Elements) + { + var element = document.GetElement(mesh.ElementId); + var sceneElement = new SceneElement + { + Id = mesh.ElementId, + ModelId = ModelId, + SourceId = element?.UniqueId, + Kind = SceneNodeKind.Element, + Name = element?.Name ?? string.Empty, + Category = CategoryName(element), + TypeName = element != null ? document.GetTypeElement(element)?.Name ?? string.Empty : string.Empty, + }; + if (sceneElement.SourceId == null) withoutUniqueId++; + if (element != null) + { + if (AddPropertySet(scene, "Instance", "revit:instance", element.Parameters) is int instanceSet) + sceneElement.PropertySetIds.Add(instanceSet); + if (document.GetTypeElement(element) is { } type) + { + if (!typeSets.TryGetValue(type.Id, out int? typeSet)) + typeSets[type.Id] = typeSet = AddPropertySet(scene, "Type", "revit:type", type.Parameters); + if (typeSet is int set) sceneElement.PropertySetIds.Add(set); + } + } + scene.Elements.Add(sceneElement); + + int instancesBefore = scene.Instances.Count; + foreach (var placement in mesh.Placements) + { + placements++; + var t = placement.Transform; + var transform = new Transform3d(Vector(t.BasisX), Vector(t.BasisY), Vector(t.BasisZ), Vector(t.Origin) * MetresPerFoot); + foreach (var (geometryId, materialId) in symbols[placement.SymbolId]) + scene.Instances.Add(new SceneInstance { ElementId = mesh.ElementId, GeometryId = geometryId, Transform = transform, MaterialId = materialId }); + } + if (mesh.Meshes.Count > 0) + { + var origin = LowestCorner(mesh.Meshes); + var transform = Transform3d.Translate(Vector(origin) * MetresPerFoot); + foreach (var own in mesh.Meshes) + scene.Instances.Add(new SceneInstance + { + ElementId = mesh.ElementId, GeometryId = AddGeometry(scene, own, origin), Transform = transform, MaterialId = MaterialOf(own.MaterialId), + }); + } + if (scene.Instances.Count > instancesBefore) withGeometry++; + + coverage[mesh.Coverage] = coverage.GetValueOrDefault(mesh.Coverage) + 1; + if (CoverageDiagnostic(mesh, sceneElement.SourceId) is { } diagnostic) report.Add(diagnostic); + } + if (withoutUniqueId > 0) + report.Add(new(SceneSeverity.Warning, "no-source-id", + $"{withoutUniqueId} elements have no Revit UniqueId: their SourceId is empty and they cannot be identified outside this scene.")); + + return new RvtSceneResult + { + Document = scene, + Diagnostics = report, + ElementCount = scene.Elements.Count, + ElementsWithGeometry = withGeometry, + Coverage = coverage, + GeometryCount = scene.Geometries.Count, + InstanceCount = scene.Instances.Count, + PlacementCount = placements, + }; + } + + /// "RvtIO.Scene" and the version of this assembly without the source revision. + internal static string Producer { get; } = "RvtIO.Scene " + (typeof(RvtSceneReader).Assembly + .GetCustomAttribute()?.InformationalVersion.Split('+')[0] + ?? typeof(RvtSceneReader).Assembly.GetName().Version?.ToString(3)); + + private static SceneDiagnostic? CoverageDiagnostic(ElementMesh mesh, string? sourceId) + { + var (code, text) = mesh.Coverage switch + { + MeshCoverage.Partial => ("partial-geometry", + $"may miss parts of its geometry ({mesh.UntriangulatedFaces} faces not triangulated; a placed symbol or a Geometry node may have no decoded polygon)"), + MeshCoverage.BoxSubstitute => ("box-substitute", "places a rescaled symbol, which is drawn as its bounding box"), + MeshCoverage.NoGeometry => ("no-geometry", "has a geometry record with nothing that could be drawn; it has no geometry in the scene"), + MeshCoverage.Undecoded => ("undecoded", "has no geometry in the scene: the geometry of the file was not decoded"), + _ => (null, null), + }; + return code == null ? null : new SceneDiagnostic(SceneSeverity.Warning, code, $"Element {mesh.ElementId} {text}.", sourceId); + } + + private static string CategoryName(Element? element) => + element?.CategoryId is int id && ((BuiltInCategory)id).TryGetCategoryName(out string? name) ? name : element?.Category ?? string.Empty; + + /// Adds the set of the parameters that have a value; null when none has. + private static int? AddPropertySet(SceneDocument scene, string name, string key, IReadOnlyList parameters) + { + var properties = new List(); + foreach (var parameter in parameters) + { + string? text = parameter.Value switch + { + null => null, + string value => value, + IFormattable value => value.ToString(null, CultureInfo.InvariantCulture), + var value => value.ToString(), + }; + if (text == null) continue; + properties.Add(new SceneProperty + { + Name = parameter.Definition?.Name ?? parameter.Id.ToString(CultureInfo.InvariantCulture), + Key = parameter.Id < 0 ? "revit:bip:" + parameter.Id.ToString(CultureInfo.InvariantCulture) : null, + Value = SceneValue.FromText(text), + }); + } + if (properties.Count == 0) return null; + int id = scene.PropertySets.Count + 1; + scene.PropertySets.Add(new ScenePropertySet { Id = id, Name = name, Key = key, Properties = properties }); + return id; + } + + private static SceneMaterial Material(RvtDocument document, int id) + { + var element = document.GetElement(id); + var color = RgbaColor.Gray; + if (element?.LookupParameter(BuiltInParameter.MATERIAL_PARAM_COLOR)?.Value is int packed) + { + int transparency = element.LookupParameter(BuiltInParameter.MATERIAL_PARAM_TRANSPARENCY)?.Value is int percent ? Math.Clamp(percent, 0, 100) : 0; + // Red is the low byte, as the IFC export of RvtIO reads the parameter. + color = new RgbaColor((byte)packed, (byte)(packed >> 8), (byte)(packed >> 16), (byte)Math.Round(255 * (100 - transparency) / 100.0)); + } + return new SceneMaterial { Id = id, Name = element?.Name ?? $"Material {id}", Color = color }; + } + + /// Adds the mesh as a geometry in metres, its vertices relative to (feet). + private static int AddGeometry(SceneDocument scene, TriangleMesh mesh, MeshVector origin) + { + var vertices = new Vector3d[mesh.Positions.Length / 3]; + for (int vertex = 0; vertex < vertices.Length; vertex++) + vertices[vertex] = new Vector3d( + (mesh.Positions[3 * vertex] - origin.X) * MetresPerFoot, + (mesh.Positions[3 * vertex + 1] - origin.Y) * MetresPerFoot, + (mesh.Positions[3 * vertex + 2] - origin.Z) * MetresPerFoot); + var normals = new Vector3d[mesh.Normals.Length / 3]; + for (int vertex = 0; vertex < normals.Length; vertex++) + normals[vertex] = new Vector3d(mesh.Normals[3 * vertex], mesh.Normals[3 * vertex + 1], mesh.Normals[3 * vertex + 2]); + int id = scene.Geometries.Count + 1; + scene.Geometries.Add(new SceneGeometry { Id = id, Topology = SceneTopology.Triangles, Vertices = vertices, Indices = mesh.Indices, Normals = normals }); + return id; + } + + /// The lowest x, y and z of the vertices of , in feet. + private static MeshVector LowestCorner(IReadOnlyList meshes) + { + double x = double.PositiveInfinity, y = double.PositiveInfinity, z = double.PositiveInfinity; + foreach (var mesh in meshes) + for (int index = 0; index + 2 < mesh.Positions.Length; index += 3) + { + x = Math.Min(x, mesh.Positions[index]); + y = Math.Min(y, mesh.Positions[index + 1]); + z = Math.Min(z, mesh.Positions[index + 2]); + } + return double.IsFinite(x) ? new MeshVector(x, y, z) : default; + } + + private static Vector3d Vector(MeshVector vector) => new(vector.X, vector.Y, vector.Z); +} diff --git a/src/RvtIO.Scene/RvtSceneResult.cs b/src/RvtIO.Scene/RvtSceneResult.cs new file mode 100644 index 0000000..a45e457 --- /dev/null +++ b/src/RvtIO.Scene/RvtSceneResult.cs @@ -0,0 +1,34 @@ +using IOScene; +using RvtIO.Meshes; + +namespace RvtIO.Scene; + +/// The scene made from one Revit document by , what was lost on the way, and counts. +public sealed class RvtSceneResult +{ + /// The scene: one model, its elements, geometries, instances, materials and property sets, in metres. + public required SceneDocument Document { get; init; } + + /// What the conversion could not carry over: an error when the geometry of the file was not decoded or the read was + /// cut by a reader limit, and a warning for every element whose geometry is incomplete or missing. Empty when nothing was lost. + /// SourceId names the element (its Revit UniqueId) where there is one. + public IReadOnlyList Diagnostics { get; init; } = []; + + /// The number of elements in the scene: one per drawable element of the document (see ). + public int ElementCount { get; init; } + + /// The number of elements that have at least one instance. + public int ElementsWithGeometry { get; init; } + + /// The number of elements by the coverage of their meshes; a coverage that no element has is not listed. + public IReadOnlyDictionary Coverage { get; init; } = new Dictionary(); + + /// The number of geometries in the scene. + public int GeometryCount { get; init; } + + /// The number of instances in the scene. + public int InstanceCount { get; init; } + + /// The number of symbol placements read: each is one instance per mesh of its symbol. + public int PlacementCount { get; init; } +} From 020d814b28f7be00c0b2b8f7674f27a0e1dc1c84 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:04:52 +0300 Subject: [PATCH 02/13] Test the scene mapping and pin the source ids of a fixture file --- docs/test-fixtures.md | 7 +- tests/RvtIO.Tests/RvtIO.Tests.csproj | 2 + .../RvtSceneIdentitySampleTests.cs | 30 ++ tests/RvtIO.Tests/RvtSceneReaderTests.cs | 271 ++++++++++++++++++ 4 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs create mode 100644 tests/RvtIO.Tests/RvtSceneReaderTests.cs diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md index 35201a5..82130ae 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, 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`) +With no private file at all, 10421 of the 10864 test cases run and pass on net8.0 and 443 are skipped (net10.0: 10425 of +10868, 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 @@ -56,7 +56,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: ## Tests that need the private files -293 test methods (436 cases, theory rows counted) are skipped without the files: +294 test methods (437 cases, theory rows counted) are skipped without the files: | Class | Methods | Cases | Files | |---|---|---|---| @@ -107,6 +107,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `RailingQuantitySourceTests` | 4 | 18 | TestData blocks | | `RecordRoutingTests` | 1 | 1 | TESTFILE.rvt | | `ReferenceApiTests` | 18 | 18 | TESTFILE.rvt, bim_dump.txt | +| `RvtSceneIdentitySampleTests` | 1 | 1 | TESTFILE.rvt | | `SchemaModelTests` | 1 | 1 | Test_2022.rvt | | `SchemaReleaseTests` | 2 | 7 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt, TESTFILE.rvt | | `SchemaStreamEditorSampleTests` | 2 | 2 | any `.rvt` project files | diff --git a/tests/RvtIO.Tests/RvtIO.Tests.csproj b/tests/RvtIO.Tests/RvtIO.Tests.csproj index c9b32cd..8d698cd 100644 --- a/tests/RvtIO.Tests/RvtIO.Tests.csproj +++ b/tests/RvtIO.Tests/RvtIO.Tests.csproj @@ -21,6 +21,8 @@ + + diff --git a/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs b/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs new file mode 100644 index 0000000..0c440c2 --- /dev/null +++ b/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs @@ -0,0 +1,30 @@ +using RvtIO.Scene; +using Xunit; + +namespace RvtIO.Tests; + +/// The golden identity test of : the model key and the source ids it writes for a private +/// project file. They are what a scene's consumers store (with the key, the Revit UniqueId of each element), so a change here +/// is a breaking release of RvtIO.Scene. +public class RvtSceneIdentitySampleTests +{ + [SkippableFact] + public void KeyAndSourceIdsOfTheMainProjectFile() + { + var result = RvtSceneReader.Read(TestFiles.Find("TESTFILE.rvt")); + var document = result.Document; + + Assert.Equal("TESTFILE.rvt", Assert.Single(document.Models).Key); + Assert.Equal(28, document.Elements.Count); + var sourceIds = document.Elements.ToDictionary(element => element.Id, element => element.SourceId); + Assert.Equal("d45b1816-cbcc-4dc6-b0bb-007467f3c600-000405a0", sourceIds[263584]); + Assert.Equal("f706377f-2f93-446e-82a0-4cd1b2beb7df-00040635", sourceIds[263733]); + Assert.Equal("ea058a4c-6e91-4f27-ae6b-0b7a391f5df5-0004695e", sourceIds[289118]); + Assert.Equal("82bb02d4-53ca-4126-8494-065ee2a5477a-00046a06", sourceIds[289286]); + Assert.Equal("3bb25a7b-f8dd-4bc4-a888-6a94d89f7e13-00046d40", sourceIds[290112]); + // Every source id is the Revit UniqueId: the episode GUID and the element id in hex. + Assert.All(document.Elements, element => Assert.EndsWith($"-{element.Id:x8}", element.SourceId)); + Assert.Empty(document.Validate()); + Assert.Empty(result.Diagnostics); + } +} diff --git a/tests/RvtIO.Tests/RvtSceneReaderTests.cs b/tests/RvtIO.Tests/RvtSceneReaderTests.cs new file mode 100644 index 0000000..6bb0147 --- /dev/null +++ b/tests/RvtIO.Tests/RvtSceneReaderTests.cs @@ -0,0 +1,271 @@ +using IOScene; +using RvtIO.BuiltIns; +using RvtIO.Meshes; +using RvtIO.Model; +using RvtIO.Scene; +using Xunit; + +namespace RvtIO.Tests; + +/// The scene of on synthetic meshes and elements: instancing, mirrored placements, local +/// origins, feet to metres, element facts, property sets, materials and the diagnostics of incomplete geometry. +public class RvtSceneReaderTests +{ + private const double Foot = 0.3048; + private const int Wall = 100, Door = 101, Empty = 102, Broken = 103, Type = 200, Symbol = 300, Concrete = 500; + + private static Parameter Bip(BuiltInParameter bip, object value) => new() + { + Id = (int)bip, Value = value, Definition = new Definition { Id = (int)bip, BuiltInParameter = bip.ToString(), Name = bip.ToString() }, + }; + + private static Element Instance(int id, int categoryId, string category, params Parameter[] parameters) => new() + { + Id = id, UniqueId = $"doc-{id:x8}", CategoryId = categoryId, Category = category, + Parameters = [Bip(BuiltInParameter.ELEM_TYPE_PARAM, Type), .. parameters], + }; + + private static RvtDocument Document() => RvtDocument.FromInfo(new RevitFileInfo + { + Elements = + [ + Instance(Wall, (int)BuiltInCategory.OST_Walls, "OST_Walls", Bip(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5), + new Parameter { Id = 900, Value = "EI 60", Definition = new Definition { Id = 900, Name = "Fire rating" } }), + Instance(Door, (int)BuiltInCategory.OST_Doors, "OST_Doors"), + Instance(Empty, (int)BuiltInCategory.OST_Walls, "OST_Walls"), + Instance(Broken, (int)BuiltInCategory.OST_Walls, "OST_Walls"), + new Element { Id = Type, Name = "Generic 200", Parameters = [new Parameter { Id = 901, Value = 3, Definition = new Definition { Id = 901, Name = "Count" } }] }, + new Element + { + Id = Concrete, Name = "Concrete", CategoryId = (int)BuiltInCategory.OST_Materials, Category = "OST_Materials", + Parameters = [Bip(BuiltInParameter.MATERIAL_PARAM_COLOR, 0x30_20_10), Bip(BuiltInParameter.MATERIAL_PARAM_TRANSPARENCY, 20)], + }, + ], + }); + + /// One triangle at (x, y, z) + (0..1, 0..2, 0), normal +Z. + private static TriangleMesh Triangle(double x, double y, double z, int material = 0) => new() + { + Positions = [x, y, z, x + 1, y, z, x, y + 2, z], + Indices = [0, 1, 2], + Normals = [0, 0, 1, 0, 0, 1, 0, 0, 1], + MaterialId = material, + }; + + private static readonly MeshTransform Mirrored = new(new(10, 20, 0), new(-1, 0, 0), new(0, 1, 0), new(0, 0, 1)); + private static readonly MeshTransform Moved = new(new(0, 4, 0), new(0, 1, 0), new(-1, 0, 0), new(0, 0, 1)); + + private static MeshSet Meshes() => new() + { + Symbols = new Dictionary> { [Symbol] = [Triangle(0, 0, 0, Concrete)] }, + Elements = + [ + new ElementMesh { ElementId = Wall, Meshes = [Triangle(1000, 2000, 30), Triangle(1001, 2000, 31, Concrete)], Coverage = MeshCoverage.Complete }, + new ElementMesh { ElementId = Door, Placements = [new(Symbol, Mirrored), new(Symbol, Moved)], Coverage = MeshCoverage.Complete }, + new ElementMesh { ElementId = Empty, Coverage = MeshCoverage.NoGeometry }, + new ElementMesh { ElementId = Broken, Meshes = [Triangle(0, 0, 0)], Coverage = MeshCoverage.Partial, UntriangulatedFaces = 2 }, + ], + }; + + private static RvtSceneResult Map() => RvtSceneReader.Map(Document(), Meshes(), "model.rvt"); + + private static void AssertClose(Vector3d expected, Vector3d actual) + { + Assert.Equal(expected.X, actual.X, 9); + Assert.Equal(expected.Y, actual.Y, 9); + Assert.Equal(expected.Z, actual.Z, 9); + } + + [Fact] + public void ScenePassesValidation() + { + var result = Map(); + Assert.Empty(result.Document.Validate()); + Assert.Empty(SceneJson.Read(SceneJson.Write(result.Document)).Validate()); + } + + [Fact] + public void ModelCarriesKeyFormatUnitAndProducer() + { + var model = Assert.Single(Map().Document.Models); + Assert.Equal("model.rvt", model.Key); + Assert.Equal("rvt", model.SourceFormat); + Assert.Equal("ft", model.SourceLengthUnit); + Assert.NotNull(model.Producer); + Assert.StartsWith("RvtIO.Scene ", model.Producer); + Assert.DoesNotContain('+', model.Producer); + } + + [Fact] + public void SymbolIsOneGeometryPlacedOncePerPlacement() + { + var result = Map(); + var door = result.Document.Instances.Where(instance => instance.ElementId == Door).ToList(); + Assert.Equal(2, door.Count); + Assert.Single(door.Select(instance => instance.GeometryId).Distinct()); + Assert.Equal(2, result.PlacementCount); + // the symbol (1) and the three own meshes (two of the wall, one of the broken element) + Assert.Equal(4, result.GeometryCount); + Assert.Equal(5, result.InstanceCount); + Assert.All(door, instance => Assert.Equal(Concrete, instance.MaterialId)); + } + + [Fact] + public void MirroredPlacementKeepsItsTransformAndTheWinding() + { + var document = Map().Document; + var mirrored = document.Instances.First(instance => instance.ElementId == Door); + Assert.Equal(new Transform3d(new(-1, 0, 0), new(0, 1, 0), new(0, 0, 1), new(10 * Foot, 20 * Foot, 0)), mirrored.Transform); + var symbol = document.Geometries.Single(geometry => geometry.Id == mirrored.GeometryId); + Assert.Equal([0, 1, 2], symbol.Indices); + Assert.Equal([new(0, 0, 0), new(Foot, 0, 0), new(0, 2 * Foot, 0)], symbol.Vertices); + Assert.Equal([Vector3d.UnitZ, Vector3d.UnitZ, Vector3d.UnitZ], symbol.Normals!); + } + + [Fact] + public void PlacementTransformsSymbolPointsInMetres() + { + var document = Map().Document; + var moved = document.Instances.Where(instance => instance.ElementId == Door).ElementAt(1); + var symbol = document.Geometries.Single(geometry => geometry.Id == moved.GeometryId); + // (1, 0, 0) ft of the family maps to the origin (0, 4, 0) + BasisX (0, 1, 0) = (0, 5, 0) ft + AssertClose(new(0, 5 * Foot, 0), moved.Transform!.Value.Apply(symbol.Vertices[1])); + } + + [Fact] + public void OwnMeshesAreLocalToTheirLowestCornerInMetres() + { + var document = Map().Document; + var wall = document.Instances.Where(instance => instance.ElementId == Wall).ToList(); + Assert.Equal(2, wall.Count); + Assert.All(wall, instance => Assert.Equal(Transform3d.Translate(new(1000 * Foot, 2000 * Foot, 30 * Foot)), instance.Transform)); + var first = document.Geometries.Single(geometry => geometry.Id == wall[0].GeometryId); + Assert.Equal([new(0, 0, 0), new(Foot, 0, 0), new(0, 2 * Foot, 0)], first.Vertices); + var second = document.Geometries.Single(geometry => geometry.Id == wall[1].GeometryId); + AssertClose(new(1002 * Foot, 2000 * Foot, 31 * Foot), wall[1].Transform!.Value.Apply(second.Vertices[1])); + Assert.Null(wall[0].MaterialId); + Assert.Equal(Concrete, wall[1].MaterialId); + } + + [Fact] + public void ElementsCarryIdentityNamesAndCategories() + { + var elements = Map().Document.Elements; + Assert.Equal([Wall, Door, Empty, Broken], elements.Select(element => element.Id)); + var wall = elements[0]; + Assert.Equal("doc-00000064", wall.SourceId); + Assert.Equal(1, wall.ModelId); + Assert.Equal("Walls", wall.Category); + Assert.Equal("Doors", elements[1].Category); + Assert.Equal("Generic 200", wall.TypeName); + Assert.Null(wall.LevelId); + Assert.Null(wall.IfcGuid); + } + + [Fact] + public void ParametersAreTextPropertySetsWithRevitKeys() + { + var document = Map().Document; + var wall = document.Elements[0]; + var door = document.Elements[1]; + Assert.Equal(2, wall.PropertySetIds.Count); + var instance = document.PropertySets.Single(set => set.Id == wall.PropertySetIds[0]); + Assert.Equal("revit:instance", instance.Key); + var height = instance.Properties.Single(property => property.Name == "WALL_USER_HEIGHT_PARAM"); + Assert.Equal("revit:bip:-1001105", height.Key); + Assert.Equal(SceneValue.FromText("10.5"), height.Value); + var user = instance.Properties.Single(property => property.Name == "Fire rating"); + Assert.Null(user.Key); + Assert.Equal("EI 60", user.Value.Text); + // one type set, shared by every instance of the type + Assert.Equal(wall.PropertySetIds[1], door.PropertySetIds[1]); + var type = document.PropertySets.Single(set => set.Id == wall.PropertySetIds[1]); + Assert.Equal("revit:type", type.Key); + Assert.Equal("3", type.Properties.Single(property => property.Name == "Count").Value.Text); + Assert.Single(document.PropertySets, set => set.Key == "revit:type"); + } + + [Fact] + public void MaterialHasItsNameAndColour() + { + var material = Assert.Single(Map().Document.Materials); + Assert.Equal(Concrete, material.Id); + Assert.Equal("Concrete", material.Name); + Assert.Equal(new RgbaColor(0x10, 0x20, 0x30, 204), material.Color); + } + + [Fact] + public void MaterialWithoutColourIsGrey() + { + var info = new RevitFileInfo { Elements = [new Element { Id = Wall, UniqueId = "w" }, new Element { Id = Concrete, Name = "Plain" }] }; + var meshes = new MeshSet { Elements = [new ElementMesh { ElementId = Wall, Meshes = [Triangle(0, 0, 0, Concrete)] }] }; + var material = Assert.Single(RvtSceneReader.Map(RvtDocument.FromInfo(info), meshes, "m").Document.Materials); + Assert.Equal(RgbaColor.Gray, material.Color); + Assert.Equal("Plain", material.Name); + } + + [Fact] + public void IncompleteGeometryIsReported() + { + var result = Map(); + Assert.DoesNotContain(result.Document.Instances, instance => instance.ElementId == Empty); + Assert.Contains(result.Document.Elements, element => element.Id == Empty); + var empty = Assert.Single(result.Diagnostics, diagnostic => diagnostic.Code == "no-geometry"); + Assert.Equal(SceneSeverity.Warning, empty.Severity); + Assert.Equal("doc-00000066", empty.SourceId); + Assert.Equal("doc-00000067", Assert.Single(result.Diagnostics, diagnostic => diagnostic.Code == "partial-geometry").SourceId); + Assert.Equal(2, result.Diagnostics.Count); + Assert.Equal(4, result.ElementCount); + Assert.Equal(3, result.ElementsWithGeometry); + Assert.Equal(new Dictionary { [MeshCoverage.Complete] = 2, [MeshCoverage.NoGeometry] = 1, [MeshCoverage.Partial] = 1 }, result.Coverage); + } + + [Fact] + public void UndecodedDocumentKeepsItsElementsAndReportsAnError() + { + var meshes = new MeshSet + { + GeometryUndecoded = true, + Elements = [new ElementMesh { ElementId = Wall, Coverage = MeshCoverage.Undecoded }, new ElementMesh { ElementId = Door, Coverage = MeshCoverage.Undecoded }], + }; + var result = RvtSceneReader.Map(Document(), meshes, "old.rvt"); + var error = Assert.Single(result.Diagnostics, diagnostic => diagnostic.Severity == SceneSeverity.Error); + Assert.Equal("geometry-undecoded", error.Code); + Assert.Contains("R22Layout", error.Message); + Assert.Equal(2, result.Diagnostics.Count(diagnostic => diagnostic.Code == "undecoded")); + Assert.Equal(2, result.Document.Elements.Count); + Assert.Empty(result.Document.Instances); + Assert.Empty(result.Document.Geometries); + Assert.Equal(0, result.ElementsWithGeometry); + Assert.Empty(result.Document.Validate()); + } + + [Fact] + public void ElementWithoutUniqueIdIsReportedOnce() + { + var info = new RevitFileInfo { Elements = [new Element { Id = 1 }, new Element { Id = 2 }] }; + var meshes = new MeshSet { Elements = [new ElementMesh { ElementId = 1, Meshes = [Triangle(0, 0, 0)] }, new ElementMesh { ElementId = 2, Meshes = [Triangle(0, 0, 0)] }] }; + var result = RvtSceneReader.Map(RvtDocument.FromInfo(info), meshes, "m"); + Assert.All(result.Document.Elements, element => Assert.Null(element.SourceId)); + Assert.Contains("2 elements", Assert.Single(result.Diagnostics, diagnostic => diagnostic.Code == "no-source-id").Message); + } + + [Fact] + public void DocumentNeedsAModelKey() + { + var document = Document(); + Assert.Throws(() => RvtSceneReader.Read(document, null)); + Assert.Throws(() => RvtSceneReader.Read(document, new RvtSceneOptions { ModelKey = "" })); + Assert.Throws(() => RvtSceneReader.Read((RvtDocument)null!, new RvtSceneOptions { ModelKey = "k" })); + Assert.Throws(() => RvtSceneReader.Read("model.rvt", new RvtSceneOptions { ModelKey = "" })); + } + + [Fact] + public void DocumentIsReadWithTheGivenKey() + { + // None of these elements is drawable, so the scene has the model only. + var result = RvtSceneReader.Read(Document(), new RvtSceneOptions { ModelKey = "site/A.rvt" }); + Assert.Equal("site/A.rvt", Assert.Single(result.Document.Models).Key); + Assert.Empty(result.Document.Validate()); + } +} From 7975785d8352cc065e0e72d505a73c6d3a019579 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:04:53 +0300 Subject: [PATCH 03/13] Document the scene adapter and add it to the API reference docfx evaluates every project for net10.0 only: the adapter's reference to RvtIO otherwise loads its net8.0 build, whose System.IO.Hashing support warning fails the docs build. --- .github/workflows/docs.yml | 2 ++ CHANGELOG.md | 1 + docfx.json | 5 +-- docs/scene.md | 67 ++++++++++++++++++++++++++++++++++++++ docs/toc.yml | 2 ++ 5 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 docs/scene.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3a66b0b..3565cd0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,6 +8,7 @@ on: - 'docfx.json' - '.config/dotnet-tools.json' - 'src/RvtIO/**' + - 'src/RvtIO.Scene/**' - '.github/workflows/docs.yml' pull_request: paths: @@ -15,6 +16,7 @@ on: - 'docfx.json' - '.config/dotnet-tools.json' - 'src/RvtIO/**' + - 'src/RvtIO.Scene/**' - '.github/workflows/docs.yml' workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4601ea7..b0b2c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ API between minor releases. ### Added +- Preview project `src/RvtIO.Scene` (not published as a package): `RvtSceneReader` turns a document into a scene of the `IOScene` preview contract, with the element meshes in metres (shared symbols as instanced geometry), the Revit UniqueId as the source id, names, categories, materials and the instance and type parameters as text property sets. See [the scene adapter](docs/scene.md). - 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. diff --git a/docfx.json b/docfx.json index e743dbb..adf0255 100644 --- a/docfx.json +++ b/docfx.json @@ -2,10 +2,11 @@ "metadata": [ { "src": [ - { "src": "src/RvtIO", "files": ["RvtIO.csproj"] } + { "src": "src/RvtIO", "files": ["RvtIO.csproj"] }, + { "src": "src/RvtIO.Scene", "files": ["RvtIO.Scene.csproj"] } ], "dest": "docs/api", - "properties": { "TargetFramework": "net10.0" }, + "properties": { "TargetFramework": "net10.0", "TargetFrameworks": "net10.0" }, "outputFormat": "apiPage" } ], diff --git a/docs/scene.md b/docs/scene.md new file mode 100644 index 0000000..00d1e4f --- /dev/null +++ b/docs/scene.md @@ -0,0 +1,67 @@ +# Scene adapter + +`RvtIO.Scene` turns a Revit project read by RvtIO into a scene of the `IOScene` package: a `SceneDocument` with one +model, the drawable elements, their triangle meshes in metres, their names and their parameters. A viewer or another +consumer of the scene reads it without knowing Revit. The scene itself is specified in the contract of the `IOScene` +repository (`docs/contract.md`), and the property keys it writes are registered there in `docs/keys.md`. + +The adapter is a preview and is not published as a package yet: reference the project `src/RvtIO.Scene`. + +## Usage + +```csharp +using IOScene; +using RvtIO.Scene; + +var result = RvtSceneReader.Read("project.rvt"); +foreach (var diagnostic in result.Diagnostics) + Console.WriteLine($"{diagnostic.Severity} {diagnostic.Code}: {diagnostic.Message}"); +SceneJson.Save(result.Document, "project.ioscene.json"); +``` + +`Read(path)` opens the file as the command line tool opens it for an IFC export (`R22Layout`, English system family +names, faces with distinct openings kept apart). `Read(document, options)` takes a document you opened yourself; it +needs `RvtSceneOptions.ModelKey`, because a document does not know its file name. Open a file of a release other than +Revit 2022 with `R22Layout = true`, or its elements have no geometry and the result has the error `geometry-undecoded`. + +`RvtSceneResult` holds the `Document`, the `Diagnostics` and counts: elements, elements with geometry, elements by +coverage, geometries, instances and symbol placements. + +## What the scene holds + +| Scene | From | +|---|---| +| `SceneModel.Key` | `RvtSceneOptions.ModelKey`, else the file name without its directory (`project.rvt`). This derivation does not change. | +| `SceneModel` | `SourceFormat` `rvt`, `SourceLengthUnit` `ft`, `Producer` `RvtIO.Scene `. | +| `SceneElement` | One per drawable element of `ElementMeshes.Extract` (the elements the IFC export would consider products, see the remarks of `ElementMeshes`). `Id` is the element id. | +| `SourceId` | The Revit `UniqueId`. This derivation does not change; a golden test pins it on a fixture file. | +| `Name`, `Category`, `TypeName` | `Element.Name`; the English name of the built-in category (else `Element.Category`); the name of the type element. | +| `SceneGeometry`, `SceneInstance` | A symbol placed whole is one geometry per mesh in the frame of its family, and each placement is one instance per mesh with the placement's transform. A mirrored placement keeps its transform as it is: the winding of the triangles is not changed. A mesh of an element's own is a geometry used once, with vertices relative to the lowest corner of the element's meshes, which is the translation of its instances. All lengths are feet x 0.3048, in the internal coordinates of the document, Z up. | +| `SceneMaterial` | One per material element that a mesh uses: its name and its colour (`MATERIAL_PARAM_COLOR`, alpha from `MATERIAL_PARAM_TRANSPARENCY`), else a neutral grey. | +| `ScenePropertySet` | `revit:instance`: the parameters of the element. `revit:type`: the parameters of its type, one set shared by every element of the type. A parameter without a value is left out. | +| `SceneProperty` | Name: `Definition.Name` (for a built-in parameter its enum name, such as `WALL_USER_HEIGHT_PARAM`). Key: `revit:bip:` for a built-in parameter, none for the others. Value: text. | + +## Diagnostics + +| Code | Severity | Meaning | +|---|---|---| +| `geometry-undecoded` | error | The geometry of the file was not decoded; open it with `R22Layout`. | +| `read-cut` | error | A reader limit cut a stream while the file was read (`Read(path)` only); elements may be missing. | +| `undecoded` | warning | An element of such a file; it is in the scene without geometry. | +| `no-geometry` | warning | An element whose geometry record has nothing that could be drawn; it is in the scene without geometry. | +| `partial-geometry` | warning | Faces that could not be triangulated, or a placed symbol or Geometry node without a decoded polygon. | +| `box-substitute` | warning | A rescaled symbol drawn as its bounding box. | +| `no-source-id` | warning | Elements without a UniqueId; they cannot be identified outside the scene. | + +## Limits + +- Values are text only: the value the file stores, in Revit internal units (feet, radians, ...), written with the + invariant culture; an element id is its number. Typed values (numbers in SI units, booleans, element references) + wait for public RvtIO API. +- Shared, project and family parameters have no key yet; the format of their keys is registered with the first release. +- No levels (`LevelId` and the `Levels` table stay empty): RvtIO has no public level of an element. +- No `IfcGuid`: the GlobalId of the IFC export is not public API. +- No tree: every element is a top-level element of the model. No links, grids or views. +- Materials have a name and a colour; no roughness, metalness or textures. +- Element names are those of `RvtDocument`; the validated names the IFC export applies to files read by their own class + schema are not public API. diff --git a/docs/toc.yml b/docs/toc.yml index 43ac63d..0a1af61 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -5,6 +5,8 @@ items: - name: Public API href: public-api.md + - name: Scene adapter + href: scene.md - name: Export report and exit codes href: export-report.md - name: Coverage report From e8c4b42dbe2ea1019b8a33aec09dc952a4fe3a37 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:25:59 +0300 Subject: [PATCH 04/13] Leave the reader's approximate parameter values out of the scene Parameters flagged IsApproximate are estimates of the reader (such as the fixed thermal values of a wall), not values of the file, so the scene does not carry them under revit:bip keys. --- docs/scene.md | 2 +- docs/test-fixtures.md | 4 ++-- src/RvtIO.Scene/RvtSceneReader.cs | 7 +++++-- tests/RvtIO.Tests/RvtSceneReaderTests.cs | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/scene.md b/docs/scene.md index 00d1e4f..8d69297 100644 --- a/docs/scene.md +++ b/docs/scene.md @@ -38,7 +38,7 @@ coverage, geometries, instances and symbol placements. | `Name`, `Category`, `TypeName` | `Element.Name`; the English name of the built-in category (else `Element.Category`); the name of the type element. | | `SceneGeometry`, `SceneInstance` | A symbol placed whole is one geometry per mesh in the frame of its family, and each placement is one instance per mesh with the placement's transform. A mirrored placement keeps its transform as it is: the winding of the triangles is not changed. A mesh of an element's own is a geometry used once, with vertices relative to the lowest corner of the element's meshes, which is the translation of its instances. All lengths are feet x 0.3048, in the internal coordinates of the document, Z up. | | `SceneMaterial` | One per material element that a mesh uses: its name and its colour (`MATERIAL_PARAM_COLOR`, alpha from `MATERIAL_PARAM_TRANSPARENCY`), else a neutral grey. | -| `ScenePropertySet` | `revit:instance`: the parameters of the element. `revit:type`: the parameters of its type, one set shared by every element of the type. A parameter without a value is left out. | +| `ScenePropertySet` | `revit:instance`: the parameters of the element. `revit:type`: the parameters of its type, one set shared by every element of the type. A parameter without a value is left out, and so is one whose value the reader only estimates (`Parameter.IsApproximate`), such as the fixed thermal values of a wall. | | `SceneProperty` | Name: `Definition.Name` (for a built-in parameter its enum name, such as `WALL_USER_HEIGHT_PARAM`). Key: `revit:bip:` for a built-in parameter, none for the others. Value: text. | ## Diagnostics diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md index 82130ae..aacbc61 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, 10421 of the 10864 test cases run and pass on net8.0 and 443 are skipped (net10.0: 10425 of -10868, 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, 10422 of the 10865 test cases run and pass on net8.0 and 443 are skipped (net10.0: 10426 of +10869, 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 diff --git a/src/RvtIO.Scene/RvtSceneReader.cs b/src/RvtIO.Scene/RvtSceneReader.cs index 4e942a5..f55236a 100644 --- a/src/RvtIO.Scene/RvtSceneReader.cs +++ b/src/RvtIO.Scene/RvtSceneReader.cs @@ -22,7 +22,8 @@ namespace RvtIO.Scene; /// An element's SourceId is its Revit UniqueId, Category the English name of its built-in category (else the /// category the reader gives), TypeName the name of its type element. Its parameters are the property set /// revit:instance; the parameters of its type are the property set revit:type, one set shared by every element of -/// the type. Values are text: the value the file stores, in Revit internal units, written with the invariant culture; the +/// the type. A parameter whose value the reader only estimates () is left out. +/// Values are text: the value the file stores, in Revit internal units, written with the invariant culture; the /// key of a built-in parameter is revit:bip:<id>, other parameters have no key. Levels, typed values and IFC /// GlobalIds are not written. /// @@ -186,12 +187,14 @@ internal static RvtSceneResult Map(RvtDocument document, MeshSet meshes, string private static string CategoryName(Element? element) => element?.CategoryId is int id && ((BuiltInCategory)id).TryGetCategoryName(out string? name) ? name : element?.Category ?? string.Empty; - /// Adds the set of the parameters that have a value; null when none has. + /// Adds the set of the parameters that have a value the file stores; null when none has. private static int? AddPropertySet(SceneDocument scene, string name, string key, IReadOnlyList parameters) { var properties = new List(); foreach (var parameter in parameters) { + // An approximate value is the reader's estimate, not a value of the file: the scene never carries a guess. + if (parameter.IsApproximate) continue; string? text = parameter.Value switch { null => null, diff --git a/tests/RvtIO.Tests/RvtSceneReaderTests.cs b/tests/RvtIO.Tests/RvtSceneReaderTests.cs index 6bb0147..763863c 100644 --- a/tests/RvtIO.Tests/RvtSceneReaderTests.cs +++ b/tests/RvtIO.Tests/RvtSceneReaderTests.cs @@ -185,6 +185,20 @@ public void ParametersAreTextPropertySetsWithRevitKeys() Assert.Single(document.PropertySets, set => set.Key == "revit:type"); } + [Fact] + public void ApproximateParametersAreLeftOut() + { + var estimate = Bip(BuiltInParameter.ANALYTICAL_THERMAL_MASS, 453330.0); + estimate.IsApproximate = true; + var info = new RevitFileInfo + { + Elements = [new Element { Id = Wall, UniqueId = "w", Parameters = [Bip(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5), estimate] }], + }; + var meshes = new MeshSet { Elements = [new ElementMesh { ElementId = Wall, Meshes = [Triangle(0, 0, 0)] }] }; + var set = Assert.Single(RvtSceneReader.Map(RvtDocument.FromInfo(info), meshes, "m").Document.PropertySets); + Assert.Equal("WALL_USER_HEIGHT_PARAM", Assert.Single(set.Properties).Name); + } + [Fact] public void MaterialHasItsNameAndColour() { From 6c315c6457cd22b001481358c5e76c2e191d9c81 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:26:57 +0300 Subject: [PATCH 05/13] Pin only source ids that the tests already name in the golden scene test The golden test keeps the model key, the element count and the id suffix of every source id, and pins the two UniqueIds of the fixture file that tracked tests already contain. --- tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs b/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs index 0c440c2..baa75dd 100644 --- a/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs +++ b/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs @@ -18,10 +18,7 @@ public void KeyAndSourceIdsOfTheMainProjectFile() Assert.Equal(28, document.Elements.Count); var sourceIds = document.Elements.ToDictionary(element => element.Id, element => element.SourceId); Assert.Equal("d45b1816-cbcc-4dc6-b0bb-007467f3c600-000405a0", sourceIds[263584]); - Assert.Equal("f706377f-2f93-446e-82a0-4cd1b2beb7df-00040635", sourceIds[263733]); - Assert.Equal("ea058a4c-6e91-4f27-ae6b-0b7a391f5df5-0004695e", sourceIds[289118]); - Assert.Equal("82bb02d4-53ca-4126-8494-065ee2a5477a-00046a06", sourceIds[289286]); - Assert.Equal("3bb25a7b-f8dd-4bc4-a888-6a94d89f7e13-00046d40", sourceIds[290112]); + Assert.Equal("d45b1816-cbcc-4dc6-b0bb-007467f3c600-000405c7", sourceIds[263623]); // Every source id is the Revit UniqueId: the episode GUID and the element id in hex. Assert.All(document.Elements, element => Assert.EndsWith($"-{element.Id:x8}", element.SourceId)); Assert.Empty(document.Validate()); From e69a461ec6d8615ac9d1c360bc6678db684a7ac1 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:28:30 +0300 Subject: [PATCH 06/13] Take the meshes in a public Read overload and drop InternalsVisibleTo RvtSceneReader.Read(document, meshes, options) maps meshes the caller extracted, and the tests use it, so the adapter no longer opens its internals to the test project. --- docs/scene.md | 4 +++- src/RvtIO.Scene/RvtIO.Scene.csproj | 4 ---- src/RvtIO.Scene/RvtSceneReader.cs | 25 +++++++++++++++++++----- tests/RvtIO.Tests/RvtSceneReaderTests.cs | 13 +++++++----- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/docs/scene.md b/docs/scene.md index 8d69297..777be50 100644 --- a/docs/scene.md +++ b/docs/scene.md @@ -21,7 +21,9 @@ SceneJson.Save(result.Document, "project.ioscene.json"); `Read(path)` opens the file as the command line tool opens it for an IFC export (`R22Layout`, English system family names, faces with distinct openings kept apart). `Read(document, options)` takes a document you opened yourself; it -needs `RvtSceneOptions.ModelKey`, because a document does not know its file name. Open a file of a release other than +needs `RvtSceneOptions.ModelKey`, because a document does not know its file name. `Read(document, meshes, options)` also +takes the meshes you extracted from that document yourself, for example with `ElementMeshes.Extract` and options of your +own. Open a file of a release other than Revit 2022 with `R22Layout = true`, or its elements have no geometry and the result has the error `geometry-undecoded`. `RvtSceneResult` holds the `Document`, the `Diagnostics` and counts: elements, elements with geometry, elements by diff --git a/src/RvtIO.Scene/RvtIO.Scene.csproj b/src/RvtIO.Scene/RvtIO.Scene.csproj index 06c0cea..0b40874 100644 --- a/src/RvtIO.Scene/RvtIO.Scene.csproj +++ b/src/RvtIO.Scene/RvtIO.Scene.csproj @@ -12,10 +12,6 @@ $(NoWarn);NU5104 - - - - diff --git a/src/RvtIO.Scene/RvtSceneReader.cs b/src/RvtIO.Scene/RvtSceneReader.cs index f55236a..08fe17b 100644 --- a/src/RvtIO.Scene/RvtSceneReader.cs +++ b/src/RvtIO.Scene/RvtSceneReader.cs @@ -64,13 +64,28 @@ public static RvtSceneResult Read(string path, RvtSceneOptions? options = null) public static RvtSceneResult Read(RvtDocument document, RvtSceneOptions? options) { ArgumentNullException.ThrowIfNull(document); - if (options?.ModelKey is not { Length: > 0 } key) - throw new ArgumentException("A document has no file name: give the model key in RvtSceneOptions.ModelKey.", nameof(options)); - return Map(document, ElementMeshes.Extract(document), key); + return Map(document, ElementMeshes.Extract(document), RequiredKey(options)); } + /// Turns an opened document into a scene with meshes the caller extracted from it, for example with + /// and options of its own. + /// The document the meshes come from; the element facts are read from it. + /// The meshes of the drawable elements of . + /// The options; is required, as a document has no file name. + /// or is null. + /// is null or empty. + public static RvtSceneResult Read(RvtDocument document, MeshSet meshes, RvtSceneOptions? options) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(meshes); + return Map(document, meshes, RequiredKey(options)); + } + + private static string RequiredKey(RvtSceneOptions? options) => options?.ModelKey is { Length: > 0 } key ? key + : throw new ArgumentException("A document has no file name: give the model key in RvtSceneOptions.ModelKey.", nameof(options)); + /// The scene of the meshes of ; the element facts come from the document. - internal static RvtSceneResult Map(RvtDocument document, MeshSet meshes, string modelKey, IEnumerable? diagnostics = null) + private static RvtSceneResult Map(RvtDocument document, MeshSet meshes, string modelKey, IEnumerable? diagnostics = null) { var scene = new SceneDocument { Title = modelKey }; scene.Models.Add(new SceneModel @@ -166,7 +181,7 @@ internal static RvtSceneResult Map(RvtDocument document, MeshSet meshes, string } /// "RvtIO.Scene" and the version of this assembly without the source revision. - internal static string Producer { get; } = "RvtIO.Scene " + (typeof(RvtSceneReader).Assembly + private static string Producer { get; } = "RvtIO.Scene " + (typeof(RvtSceneReader).Assembly .GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? typeof(RvtSceneReader).Assembly.GetName().Version?.ToString(3)); diff --git a/tests/RvtIO.Tests/RvtSceneReaderTests.cs b/tests/RvtIO.Tests/RvtSceneReaderTests.cs index 763863c..c83d694 100644 --- a/tests/RvtIO.Tests/RvtSceneReaderTests.cs +++ b/tests/RvtIO.Tests/RvtSceneReaderTests.cs @@ -67,7 +67,7 @@ private static RvtDocument Document() => RvtDocument.FromInfo(new RevitFileInfo ], }; - private static RvtSceneResult Map() => RvtSceneReader.Map(Document(), Meshes(), "model.rvt"); + private static RvtSceneResult Map() => RvtSceneReader.Read(Document(), Meshes(), new RvtSceneOptions { ModelKey = "model.rvt" }); private static void AssertClose(Vector3d expected, Vector3d actual) { @@ -195,7 +195,7 @@ public void ApproximateParametersAreLeftOut() Elements = [new Element { Id = Wall, UniqueId = "w", Parameters = [Bip(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5), estimate] }], }; var meshes = new MeshSet { Elements = [new ElementMesh { ElementId = Wall, Meshes = [Triangle(0, 0, 0)] }] }; - var set = Assert.Single(RvtSceneReader.Map(RvtDocument.FromInfo(info), meshes, "m").Document.PropertySets); + var set = Assert.Single(RvtSceneReader.Read(RvtDocument.FromInfo(info), meshes, new RvtSceneOptions { ModelKey = "m" }).Document.PropertySets); Assert.Equal("WALL_USER_HEIGHT_PARAM", Assert.Single(set.Properties).Name); } @@ -213,7 +213,7 @@ public void MaterialWithoutColourIsGrey() { var info = new RevitFileInfo { Elements = [new Element { Id = Wall, UniqueId = "w" }, new Element { Id = Concrete, Name = "Plain" }] }; var meshes = new MeshSet { Elements = [new ElementMesh { ElementId = Wall, Meshes = [Triangle(0, 0, 0, Concrete)] }] }; - var material = Assert.Single(RvtSceneReader.Map(RvtDocument.FromInfo(info), meshes, "m").Document.Materials); + var material = Assert.Single(RvtSceneReader.Read(RvtDocument.FromInfo(info), meshes, new RvtSceneOptions { ModelKey = "m" }).Document.Materials); Assert.Equal(RgbaColor.Gray, material.Color); Assert.Equal("Plain", material.Name); } @@ -242,7 +242,7 @@ public void UndecodedDocumentKeepsItsElementsAndReportsAnError() GeometryUndecoded = true, Elements = [new ElementMesh { ElementId = Wall, Coverage = MeshCoverage.Undecoded }, new ElementMesh { ElementId = Door, Coverage = MeshCoverage.Undecoded }], }; - var result = RvtSceneReader.Map(Document(), meshes, "old.rvt"); + var result = RvtSceneReader.Read(Document(), meshes, new RvtSceneOptions { ModelKey = "old.rvt" }); var error = Assert.Single(result.Diagnostics, diagnostic => diagnostic.Severity == SceneSeverity.Error); Assert.Equal("geometry-undecoded", error.Code); Assert.Contains("R22Layout", error.Message); @@ -259,7 +259,7 @@ public void ElementWithoutUniqueIdIsReportedOnce() { var info = new RevitFileInfo { Elements = [new Element { Id = 1 }, new Element { Id = 2 }] }; var meshes = new MeshSet { Elements = [new ElementMesh { ElementId = 1, Meshes = [Triangle(0, 0, 0)] }, new ElementMesh { ElementId = 2, Meshes = [Triangle(0, 0, 0)] }] }; - var result = RvtSceneReader.Map(RvtDocument.FromInfo(info), meshes, "m"); + var result = RvtSceneReader.Read(RvtDocument.FromInfo(info), meshes, new RvtSceneOptions { ModelKey = "m" }); Assert.All(result.Document.Elements, element => Assert.Null(element.SourceId)); Assert.Contains("2 elements", Assert.Single(result.Diagnostics, diagnostic => diagnostic.Code == "no-source-id").Message); } @@ -272,6 +272,9 @@ public void DocumentNeedsAModelKey() Assert.Throws(() => RvtSceneReader.Read(document, new RvtSceneOptions { ModelKey = "" })); Assert.Throws(() => RvtSceneReader.Read((RvtDocument)null!, new RvtSceneOptions { ModelKey = "k" })); Assert.Throws(() => RvtSceneReader.Read("model.rvt", new RvtSceneOptions { ModelKey = "" })); + Assert.Throws(() => RvtSceneReader.Read(document, Meshes(), null)); + Assert.Throws(() => RvtSceneReader.Read(document, null!, new RvtSceneOptions { ModelKey = "k" })); + Assert.Throws(() => RvtSceneReader.Read(null!, Meshes(), new RvtSceneOptions { ModelKey = "k" })); } [Fact] From a27c3d686aab35f31a41e4d0ca120d3d729f2136 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:22:20 +0300 Subject: [PATCH 07/13] Write the display text of parameters and the IFC GlobalId of elements in the scene Property text is the display string of the public parameter API, and values without an exact one are left out. IfcGuid is the element's valid IfcGUID parameter, else the export id of its UniqueId. The IOScene range now starts at 0.1.0-preview.2. --- CHANGELOG.md | 2 +- Directory.Packages.props | 2 +- docs/scene.md | 23 ++- src/RvtIO.Scene/RvtSceneReader.cs | 77 +++++++-- .../RvtSceneIdentitySampleTests.cs | 10 +- tests/RvtIO.Tests/RvtSceneReaderTests.cs | 156 ++++++++++++++++-- 6 files changed, 234 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0b2c77..77457dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ API between minor releases. ### Added -- Preview project `src/RvtIO.Scene` (not published as a package): `RvtSceneReader` turns a document into a scene of the `IOScene` preview contract, with the element meshes in metres (shared symbols as instanced geometry), the Revit UniqueId as the source id, names, categories, materials and the instance and type parameters as text property sets. See [the scene adapter](docs/scene.md). +- Preview project `src/RvtIO.Scene` (not published as a package): `RvtSceneReader` turns a document into a scene of the `IOScene` preview contract, with the element meshes in metres (shared symbols as instanced geometry), the Revit UniqueId as the source id, the IFC GlobalId of Revit's IFC export as the IfcGuid, names, categories, materials and the instance and type parameters as property sets of their display text. See [the scene adapter](docs/scene.md). - 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. diff --git a/Directory.Packages.props b/Directory.Packages.props index 5089a80..623b92d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,7 +12,7 @@ - + diff --git a/docs/scene.md b/docs/scene.md index 777be50..30174ad 100644 --- a/docs/scene.md +++ b/docs/scene.md @@ -37,11 +37,26 @@ coverage, geometries, instances and symbol placements. | `SceneModel` | `SourceFormat` `rvt`, `SourceLengthUnit` `ft`, `Producer` `RvtIO.Scene `. | | `SceneElement` | One per drawable element of `ElementMeshes.Extract` (the elements the IFC export would consider products, see the remarks of `ElementMeshes`). `Id` is the element id. | | `SourceId` | The Revit `UniqueId`. This derivation does not change; a golden test pins it on a fixture file. | +| `IfcGuid` | The GlobalId Revit's IFC export gives the element: the value of its IfcGUID parameter (the built-in `IFC_GUID`, else a parameter named `IfcGUID`) when it is a valid 22-character GlobalId; else the export id of the `UniqueId` (the episode GUID with its last 32 bits XORed with the element id the `UniqueId` ends with) in the IFC base-64 form. It depends on the element only, never on export order or options. This derivation does not change; the golden test pins it. | | `Name`, `Category`, `TypeName` | `Element.Name`; the English name of the built-in category (else `Element.Category`); the name of the type element. | | `SceneGeometry`, `SceneInstance` | A symbol placed whole is one geometry per mesh in the frame of its family, and each placement is one instance per mesh with the placement's transform. A mirrored placement keeps its transform as it is: the winding of the triangles is not changed. A mesh of an element's own is a geometry used once, with vertices relative to the lowest corner of the element's meshes, which is the translation of its instances. All lengths are feet x 0.3048, in the internal coordinates of the document, Z up. | | `SceneMaterial` | One per material element that a mesh uses: its name and its colour (`MATERIAL_PARAM_COLOR`, alpha from `MATERIAL_PARAM_TRANSPARENCY`), else a neutral grey. | | `ScenePropertySet` | `revit:instance`: the parameters of the element. `revit:type`: the parameters of its type, one set shared by every element of the type. A parameter without a value is left out, and so is one whose value the reader only estimates (`Parameter.IsApproximate`), such as the fixed thermal values of a wall. | -| `SceneProperty` | Name: `Definition.Name` (for a built-in parameter its enum name, such as `WALL_USER_HEIGHT_PARAM`). Key: `revit:bip:` for a built-in parameter, none for the others. Value: text. | +| `SceneProperty` | Name: `Definition.Name` (for a built-in parameter its enum name, such as `WALL_USER_HEIGHT_PARAM`). Key: `revit:bip:` for a built-in parameter, none for the others. Value: text, the display string of the public parameter API (see below). | + +### Property text + +A value is written as the text `Parameter.AsValueString` shows, with the invariant culture, and only when that text is +the one Revit shows; any other value is left out. By storage type: + +| Storage | Text | +|---|---| +| String | The stored text. | +| Double | The value converted to its display unit and the unit symbol, as `AsValueString` writes it from `UnitSymbol` and `ConversionFactor` (`3200.4 mm`), when the unit is mm, m², m³, ° or A: the units whose factor converts Revit's internal unit (foot, square foot, cubic foot, radian, ampere). Left out: a value without a resolved unit (`AsValueString` only guesses its unit from the parameter name), a unit the reader converts with a factor of 1 that is not Revit's conversion (W, V, °C, Pa, ...), and an area per unit length, which the reader shows in m². | +| Integer | A shared, project or family parameter: the number in decimal; such an integer is a plain integer or a Yes/No, which `AsValueString` shows as `1` or `0`. A built-in integer is left out: it may be an enumeration or a Yes/No, and the public API cannot tell it from a number. | +| ElementId | Left out: the public API does not give the name Revit shows for the referenced element. | + +The text a value is written with under a key is frozen with the key: changing it is a breaking release. ## Diagnostics @@ -57,12 +72,10 @@ coverage, geometries, instances and symbol placements. ## Limits -- Values are text only: the value the file stores, in Revit internal units (feet, radians, ...), written with the - invariant culture; an element id is its number. Typed values (numbers in SI units, booleans, element references) - wait for public RvtIO API. +- Values are text only, and only those whose display string the public API gives (see Property text). Typed values + (numbers in SI units, booleans, element references) wait for public RvtIO API. - Shared, project and family parameters have no key yet; the format of their keys is registered with the first release. - No levels (`LevelId` and the `Levels` table stay empty): RvtIO has no public level of an element. -- No `IfcGuid`: the GlobalId of the IFC export is not public API. - No tree: every element is a top-level element of the model. No links, grids or views. - Materials have a name and a colour; no roughness, metalness or textures. - Element names are those of `RvtDocument`; the validated names the IFC export applies to files read by their own class diff --git a/src/RvtIO.Scene/RvtSceneReader.cs b/src/RvtIO.Scene/RvtSceneReader.cs index 08fe17b..0d8272b 100644 --- a/src/RvtIO.Scene/RvtSceneReader.cs +++ b/src/RvtIO.Scene/RvtSceneReader.cs @@ -1,3 +1,4 @@ +using System.Buffers.Binary; using System.Globalization; using System.Reflection; using IOScene; @@ -19,13 +20,18 @@ namespace RvtIO.Scene; /// lowest corner of the element's own meshes, which is the translation of its instances. Normals and indices are those of /// the meshes. A mesh with a material has the instance material of that material element: its name and, where the file /// gives it, its colour (else a neutral grey). -/// An element's SourceId is its Revit UniqueId, Category the English name of its built-in category (else the +/// An element's SourceId is its Revit UniqueId, IfcGuid the GlobalId Revit's IFC export gives it (its valid +/// IfcGUID parameter, else the export id of its UniqueId), Category the English name of its built-in category (else the /// category the reader gives), TypeName the name of its type element. Its parameters are the property set /// revit:instance; the parameters of its type are the property set revit:type, one set shared by every element of -/// the type. A parameter whose value the reader only estimates () is left out. -/// Values are text: the value the file stores, in Revit internal units, written with the invariant culture; the -/// key of a built-in parameter is revit:bip:<id>, other parameters have no key. Levels, typed values and IFC -/// GlobalIds are not written. +/// the type. A parameter whose value the reader only estimates () is left out. The key of +/// a built-in parameter is revit:bip:<id>, other parameters have no key. +/// Values are text: the display string of , with the invariant culture, and only +/// where it is the text Revit shows. A string is the stored text; a double is its value in the display unit and the unit symbol +/// (3200.4 mm) when that unit is mm, m², m³, ° or A, the units whose factor converts Revit's internal unit, and is left +/// out otherwise (no unit resolved, or a unit the reader converts with a factor that is not Revit's); an integer of a shared, project or family +/// parameter (a plain integer or a Yes/No, shown as 1 or 0) is the number in decimal; a built-in integer, which may be an +/// enumeration, and an element id are left out. Levels and typed values are not written. /// public static class RvtSceneReader { @@ -120,6 +126,7 @@ private static RvtSceneResult Map(RvtDocument document, MeshSet meshes, string m Id = mesh.ElementId, ModelId = ModelId, SourceId = element?.UniqueId, + IfcGuid = element != null ? IfcGuidOf(element) : null, Kind = SceneNodeKind.Element, Name = element?.Name ?? string.Empty, Category = CategoryName(element), @@ -210,14 +217,7 @@ private static string CategoryName(Element? element) => { // An approximate value is the reader's estimate, not a value of the file: the scene never carries a guess. if (parameter.IsApproximate) continue; - string? text = parameter.Value switch - { - null => null, - string value => value, - IFormattable value => value.ToString(null, CultureInfo.InvariantCulture), - var value => value.ToString(), - }; - if (text == null) continue; + if (DisplayText(parameter) is not { } text) continue; properties.Add(new SceneProperty { Name = parameter.Definition?.Name ?? parameter.Id.ToString(CultureInfo.InvariantCulture), @@ -231,6 +231,57 @@ private static string CategoryName(Element? element) => return id; } + /// The display string of the value as gives it, with the invariant culture; + /// null when the public API gives no display string that is exactly the one Revit shows. + private static string? DisplayText(Parameter parameter) => parameter.StorageType switch + { + StorageType.String => parameter.Value as string, + // The unit form of AsValueString; its fallback by parameter name is a guess and is left out. + StorageType.Double when parameter.Value is double value && parameter.UnitSymbol is { } unit + && ExactUnits.TryGetValue(unit, out double exact) && parameter.ConversionFactor == exact + && parameter.SpecType?.Contains(":surfaceAreaPerUnitLength-", StringComparison.Ordinal) != true + => string.Create(CultureInfo.InvariantCulture, $"{value * exact:G6} {unit}"), + // A user parameter that stores an integer is an Integer or a Yes/No, both shown by AsValueString as the number. A + // built-in one may be an enumeration, which the public API cannot tell from a number, so it is left out. + StorageType.Integer when parameter.Id > 0 && parameter.Value is int value => value.ToString(CultureInfo.InvariantCulture), + // An ElementId: the public API does not give the name Revit shows for the referenced element. + _ => null, + }; + + /// The display units whose factor converts Revit's internal unit (foot, square foot, cubic foot, radian, ampere) to + /// them (the cubic one to ten digits, beyond the six the text shows). The reader gives other units (W, V, °C, Pa, ...) a + /// factor of 1, which is not Revit's conversion, and gives an area per unit length the unit m²: such values are left out. + private static readonly Dictionary ExactUnits = new(StringComparer.Ordinal) + { + ["mm"] = 304.8, ["m²"] = 0.09290304, ["m³"] = 0.0283168466, ["°"] = 180.0 / Math.PI, ["A"] = 1.0, + }; + + private const string IfcGuidAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$"; + + /// The IFC GlobalId Revit's IFC export gives the element: the value of its IfcGUID parameter (the built-in one, + /// else a parameter named IfcGUID) when it is a valid GlobalId, else the export id of its UniqueId (the episode GUID with + /// its last 32 bits XORed with the element id the UniqueId ends with) in the IFC base-64 form; null without either. + private static string? IfcGuidOf(Element element) + { + var overrides = element.Parameters.Where(parameter => parameter.Id == (int)BuiltInParameter.IFC_GUID) + .Concat(element.Parameters.Where(parameter => parameter.Id > 0 && parameter.Definition?.Name == "IfcGUID")); + if (overrides.Select(parameter => parameter.Value as string).FirstOrDefault(IsIfcGuid) is { } stored) return stored; + + // A UniqueId is the episode GUID, '-' and the original element id as eight hex digits. + if (element.UniqueId is not { Length: 45 } uniqueId || uniqueId[36] != '-' + || !Guid.TryParseExact(uniqueId.AsSpan(0, 36), "D", out var episode) + || !uint.TryParse(uniqueId.AsSpan(37), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out uint originalId)) + return null; + UInt128 bits = BinaryPrimitives.ReadUInt128BigEndian(episode.ToByteArray(bigEndian: true)) ^ originalId; + var text = new char[22]; + for (int index = 21; index >= 0; index--, bits >>= 6) text[index] = IfcGuidAlphabet[(int)(bits & 63)]; + return new string(text); + } + + // 22 characters of the IFC base-64 alphabet; the first one carries two bits, so it is 0 to 3. + private static bool IsIfcGuid(string? value) => + value is { Length: 22 } && value[0] is >= '0' and <= '3' && value.All(c => IfcGuidAlphabet.Contains(c)); + private static SceneMaterial Material(RvtDocument document, int id) { var element = document.GetElement(id); diff --git a/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs b/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs index baa75dd..450b9fb 100644 --- a/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs +++ b/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs @@ -3,9 +3,9 @@ namespace RvtIO.Tests; -/// The golden identity test of : the model key and the source ids it writes for a private -/// project file. They are what a scene's consumers store (with the key, the Revit UniqueId of each element), so a change here -/// is a breaking release of RvtIO.Scene. +/// The golden identity test of : the model key, the source ids and the IFC GlobalIds it writes +/// for a private project file. They are what a scene's consumers store (with the key, the Revit UniqueId of each element; for BCF, +/// the IfcGuid), so a change here is a breaking release of RvtIO.Scene. public class RvtSceneIdentitySampleTests { [SkippableFact] @@ -19,6 +19,10 @@ public void KeyAndSourceIdsOfTheMainProjectFile() var sourceIds = document.Elements.ToDictionary(element => element.Id, element => element.SourceId); Assert.Equal("d45b1816-cbcc-4dc6-b0bb-007467f3c600-000405a0", sourceIds[263584]); Assert.Equal("d45b1816-cbcc-4dc6-b0bb-007467f3c600-000405c7", sourceIds[263623]); + // The IFC GlobalIds Revit 2022 wrote for these two walls when it exported the file to IFC. + var ifcGuids = document.Elements.ToDictionary(element => element.Id, element => element.IfcGuid); + Assert.Equal("3KMnWMoynDnh2x07HdzyEW", ifcGuids[263584]); + Assert.Equal("3KMnWMoynDnh2x07HdzyF7", ifcGuids[263623]); // Every source id is the Revit UniqueId: the episode GUID and the element id in hex. Assert.All(document.Elements, element => Assert.EndsWith($"-{element.Id:x8}", element.SourceId)); Assert.Empty(document.Validate()); diff --git a/tests/RvtIO.Tests/RvtSceneReaderTests.cs b/tests/RvtIO.Tests/RvtSceneReaderTests.cs index c83d694..896e3ac 100644 --- a/tests/RvtIO.Tests/RvtSceneReaderTests.cs +++ b/tests/RvtIO.Tests/RvtSceneReaderTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using IOScene; using RvtIO.BuiltIns; using RvtIO.Meshes; @@ -8,33 +9,54 @@ namespace RvtIO.Tests; /// The scene of on synthetic meshes and elements: instancing, mirrored placements, local -/// origins, feet to metres, element facts, property sets, materials and the diagnostics of incomplete geometry. +/// origins, feet to metres, element facts, IFC GlobalIds, property sets and their display text, materials and the diagnostics of +/// incomplete geometry. public class RvtSceneReaderTests { private const double Foot = 0.3048; private const int Wall = 100, Door = 101, Empty = 102, Broken = 103, Type = 200, Symbol = 300, Concrete = 500; - private static Parameter Bip(BuiltInParameter bip, object value) => new() + private static Parameter Bip(BuiltInParameter bip, object value, StorageType? storage = null) => new() { - Id = (int)bip, Value = value, Definition = new Definition { Id = (int)bip, BuiltInParameter = bip.ToString(), Name = bip.ToString() }, + Id = (int)bip, Value = value, StorageType = storage ?? StorageOf(value), + Definition = new Definition { Id = (int)bip, BuiltInParameter = bip.ToString(), Name = bip.ToString() }, }; + private static Parameter User(int id, string name, object value, StorageType? storage = null) => new() + { + Id = id, Value = value, StorageType = storage ?? StorageOf(value), Definition = new Definition { Id = id, Name = name }, + }; + + private static StorageType StorageOf(object value) => value switch + { + double => StorageType.Double, string => StorageType.String, _ => StorageType.Integer, + }; + + /// A length in feet, displayed in millimetres as the reader resolves a length parameter. + private static Parameter Length(BuiltInParameter bip, double feet) + { + var parameter = Bip(bip, feet); + parameter.UnitSymbol = "mm"; + parameter.ConversionFactor = 304.8; + return parameter; + } + private static Element Instance(int id, int categoryId, string category, params Parameter[] parameters) => new() { Id = id, UniqueId = $"doc-{id:x8}", CategoryId = categoryId, Category = category, - Parameters = [Bip(BuiltInParameter.ELEM_TYPE_PARAM, Type), .. parameters], + Parameters = [Bip(BuiltInParameter.ELEM_TYPE_PARAM, Type, StorageType.ElementId), .. parameters], }; private static RvtDocument Document() => RvtDocument.FromInfo(new RevitFileInfo { Elements = [ - Instance(Wall, (int)BuiltInCategory.OST_Walls, "OST_Walls", Bip(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5), - new Parameter { Id = 900, Value = "EI 60", Definition = new Definition { Id = 900, Name = "Fire rating" } }), + Instance(Wall, (int)BuiltInCategory.OST_Walls, "OST_Walls", Length(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5), + User(900, "Fire rating", "EI 60")), Instance(Door, (int)BuiltInCategory.OST_Doors, "OST_Doors"), Instance(Empty, (int)BuiltInCategory.OST_Walls, "OST_Walls"), Instance(Broken, (int)BuiltInCategory.OST_Walls, "OST_Walls"), - new Element { Id = Type, Name = "Generic 200", Parameters = [new Parameter { Id = 901, Value = 3, Definition = new Definition { Id = 901, Name = "Count" } }] }, + new Element { Id = Type, Name = "Generic 200", Parameters = [User(901, "Count", 3)] }, new Element { Id = Concrete, Name = "Concrete", CategoryId = (int)BuiltInCategory.OST_Materials, Category = "OST_Materials", @@ -69,6 +91,14 @@ private static RvtDocument Document() => RvtDocument.FromInfo(new RevitFileInfo private static RvtSceneResult Map() => RvtSceneReader.Read(Document(), Meshes(), new RvtSceneOptions { ModelKey = "model.rvt" }); + /// The scene of one drawable element. + private static SceneDocument MapOne(Element element) => RvtSceneReader.Read(RvtDocument.FromInfo(new RevitFileInfo { Elements = [element] }), + new MeshSet { Elements = [new ElementMesh { ElementId = element.Id, Meshes = [Triangle(0, 0, 0)] }] }, new RvtSceneOptions { ModelKey = "m" }).Document; + + /// The text of each property of the scene, by property name. + private static Dictionary Texts(SceneDocument document) => + document.PropertySets.SelectMany(set => set.Properties).ToDictionary(property => property.Name, property => property.Value.Text); + private static void AssertClose(Vector3d expected, Vector3d actual) { Assert.Equal(expected.X, actual.X, 9); @@ -173,12 +203,12 @@ public void ParametersAreTextPropertySetsWithRevitKeys() Assert.Equal("revit:instance", instance.Key); var height = instance.Properties.Single(property => property.Name == "WALL_USER_HEIGHT_PARAM"); Assert.Equal("revit:bip:-1001105", height.Key); - Assert.Equal(SceneValue.FromText("10.5"), height.Value); + Assert.Equal(SceneValue.FromText("3200.4 mm"), height.Value); var user = instance.Properties.Single(property => property.Name == "Fire rating"); Assert.Null(user.Key); Assert.Equal("EI 60", user.Value.Text); - // one type set, shared by every instance of the type - Assert.Equal(wall.PropertySetIds[1], door.PropertySetIds[1]); + // one type set, shared by every instance of the type; the door's only instance parameter is its type, an element id + Assert.Equal(wall.PropertySetIds[1], Assert.Single(door.PropertySetIds)); var type = document.PropertySets.Single(set => set.Id == wall.PropertySetIds[1]); Assert.Equal("revit:type", type.Key); Assert.Equal("3", type.Properties.Single(property => property.Name == "Count").Value.Text); @@ -192,11 +222,108 @@ public void ApproximateParametersAreLeftOut() estimate.IsApproximate = true; var info = new RevitFileInfo { - Elements = [new Element { Id = Wall, UniqueId = "w", Parameters = [Bip(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5), estimate] }], + Elements = [new Element { Id = Wall, UniqueId = "w", Parameters = [Length(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5), estimate] }], }; var meshes = new MeshSet { Elements = [new ElementMesh { ElementId = Wall, Meshes = [Triangle(0, 0, 0)] }] }; - var set = Assert.Single(RvtSceneReader.Read(RvtDocument.FromInfo(info), meshes, new RvtSceneOptions { ModelKey = "m" }).Document.PropertySets); + var document = RvtSceneReader.Read(RvtDocument.FromInfo(info), meshes, new RvtSceneOptions { ModelKey = "m" }).Document; + var set = Assert.Single(document.PropertySets); Assert.Equal("WALL_USER_HEIGHT_PARAM", Assert.Single(set.Properties).Name); + Assert.Empty(document.Validate()); + } + + [Fact] + public void LengthShowsTheDisplayStringOfThePublicApiWithItsUnit() + { + var height = Length(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5); + // Without a resolved unit the public API only guesses by the parameter name: the value is left out. + var guessed = Bip(BuiltInParameter.WALL_BASE_OFFSET, 1.25); + var plain = User(902, "Factor", 0.5); + var document = MapOne(new Element { Id = Wall, UniqueId = "w", Parameters = [height, guessed, plain] }); + + var texts = Texts(document); + Assert.Equal("3200.4 mm", Assert.Single(texts).Value); + var culture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + Assert.Equal(height.AsValueString(), texts["WALL_USER_HEIGHT_PARAM"]); + // The text does not follow the culture of the machine. + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("ru-RU"); + Assert.Equal("3200.4 mm", Texts(MapOne(new Element { Id = Wall, UniqueId = "w", Parameters = [height] }))["WALL_USER_HEIGHT_PARAM"]); + } + finally + { + CultureInfo.CurrentCulture = culture; + } + Assert.Empty(document.Validate()); + } + + [Fact] + public void UnitWhoseFactorIsNotRevitsConversionIsLeftOut() + { + var angle = User(907, "Slope angle", Math.PI / 4); + angle.UnitSymbol = "\u00B0"; + angle.ConversionFactor = 180 / Math.PI; + // Revit stores power in kg ft^2/s^3, not in watts: a factor of 1 does not give the watts Revit shows. + var power = User(908, "Load", 120.0); + power.UnitSymbol = "W"; + power.ConversionFactor = 1; + var perLength = User(909, "Surface per length", 2.0); + perLength.UnitSymbol = "m\u00B2"; + perLength.ConversionFactor = 0.09290304; + perLength.SpecType = "autodesk.spec.aec:surfaceAreaPerUnitLength-1.0.0"; + var document = MapOne(new Element { Id = Wall, UniqueId = "w", Parameters = [angle, power, perLength] }); + Assert.Equal("45 \u00B0", Assert.Single(Texts(document)).Value); + Assert.Empty(document.Validate()); + } + + [Fact] + public void ElementIdValueIsLeftOut() + { + var level = Bip(BuiltInParameter.WALL_BASE_CONSTRAINT, 311, StorageType.ElementId); + var material = User(903, "Finish", Concrete, StorageType.ElementId); + var document = MapOne(new Element { Id = Wall, UniqueId = "w", Parameters = [level, material, User(900, "Fire rating", "EI 60")] }); + Assert.Equal(["Fire rating"], Texts(document).Keys); + Assert.Empty(document.Validate()); + } + + [Fact] + public void YesNoOfAUserParameterIsTheNumberTheApiShows() + { + var external = User(904, "Is external", 1); + // A built-in integer may be an enumeration, which the public API cannot tell from a number or a Yes/No: left out. + var roomBounding = Bip(BuiltInParameter.WALL_ATTR_ROOM_BOUNDING, 1); + var document = MapOne(new Element { Id = Wall, UniqueId = "w", Parameters = [external, roomBounding, User(905, "Count", -12)] }); + var texts = Texts(document); + Assert.Equal(external.AsValueString(), texts["Is external"]); + Assert.Equal("1", texts["Is external"]); + Assert.Equal("-12", texts["Count"]); + Assert.DoesNotContain("WALL_ATTR_ROOM_BOUNDING", texts.Keys); + Assert.Empty(document.Validate()); + } + + [Fact] + public void IfcGuidIsTheExportIdOfTheUniqueId() + { + // The UniqueId and GlobalId of an element of a fixture file, as Revit 2022 exported it to IFC. + var element = MapOne(new Element { Id = 263584, UniqueId = "d45b1816-cbcc-4dc6-b0bb-007467f3c600-000405a0" }).Elements[0]; + Assert.Equal("3KMnWMoynDnh2x07HdzyEW", element.IfcGuid); + Assert.Null(MapOne(new Element { Id = 1, UniqueId = "d45b1816-cbcc-4dc6-b0bb-007467f3c600-0405a0" }).Elements[0].IfcGuid); + Assert.Null(MapOne(new Element { Id = 1 }).Elements[0].IfcGuid); + } + + [Fact] + public void ValidIfcGuidParameterOverridesTheExportId() + { + const string UniqueId = "d45b1816-cbcc-4dc6-b0bb-007467f3c600-000405a0"; + var builtIn = Bip(BuiltInParameter.IFC_GUID, "2abcdefghijklmnopqrs$_"); + var shared = User(906, "IfcGUID", "0123456789ABCDEFGHIJKL"); + Assert.Equal("2abcdefghijklmnopqrs$_", MapOne(new Element { Id = 1, UniqueId = UniqueId, Parameters = [shared, builtIn] }).Elements[0].IfcGuid); + Assert.Equal("0123456789ABCDEFGHIJKL", MapOne(new Element { Id = 1, UniqueId = UniqueId, Parameters = [shared] }).Elements[0].IfcGuid); + // Not a GlobalId: empty, too short, a character outside the alphabet, a first character above 3. + foreach (string invalid in new[] { "", "2abcdefghijklmnopqrs$", "2abcdefghijklmnopqrs-_", "4abcdefghijklmnopqrs$_" }) + Assert.Equal("3KMnWMoynDnh2x07HdzyEW", + MapOne(new Element { Id = 1, UniqueId = UniqueId, Parameters = [Bip(BuiltInParameter.IFC_GUID, invalid)] }).Elements[0].IfcGuid); } [Fact] @@ -213,9 +340,11 @@ public void MaterialWithoutColourIsGrey() { var info = new RevitFileInfo { Elements = [new Element { Id = Wall, UniqueId = "w" }, new Element { Id = Concrete, Name = "Plain" }] }; var meshes = new MeshSet { Elements = [new ElementMesh { ElementId = Wall, Meshes = [Triangle(0, 0, 0, Concrete)] }] }; - var material = Assert.Single(RvtSceneReader.Read(RvtDocument.FromInfo(info), meshes, new RvtSceneOptions { ModelKey = "m" }).Document.Materials); + var document = RvtSceneReader.Read(RvtDocument.FromInfo(info), meshes, new RvtSceneOptions { ModelKey = "m" }).Document; + var material = Assert.Single(document.Materials); Assert.Equal(RgbaColor.Gray, material.Color); Assert.Equal("Plain", material.Name); + Assert.Empty(document.Validate()); } [Fact] @@ -262,6 +391,7 @@ public void ElementWithoutUniqueIdIsReportedOnce() var result = RvtSceneReader.Read(RvtDocument.FromInfo(info), meshes, new RvtSceneOptions { ModelKey = "m" }); Assert.All(result.Document.Elements, element => Assert.Null(element.SourceId)); Assert.Contains("2 elements", Assert.Single(result.Diagnostics, diagnostic => diagnostic.Code == "no-source-id").Message); + Assert.Empty(result.Document.Validate()); } [Fact] From b4cb10767cd5ca51e99754d8a72586d681a89aa9 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:46:15 +0300 Subject: [PATCH 08/13] Leave out doubles without a unit spec and take only the built-in IfcGUID override The reader guesses the unit of every built-in double from its name, which gives the factor TEXT_WIDTH_SCALE the unit mm, so a double is written only when its unit comes from its measurement spec. The IfcGuid override is the built-in IFC_GUID parameter only, as in RvtIO's IFC export. --- docs/scene.md | 4 +- src/RvtIO.Scene/RvtSceneReader.cs | 25 ++++----- tests/RvtIO.Tests/RvtSceneReaderTests.cs | 66 +++++++++++++++++------- 3 files changed, 62 insertions(+), 33 deletions(-) diff --git a/docs/scene.md b/docs/scene.md index 30174ad..1dda0e7 100644 --- a/docs/scene.md +++ b/docs/scene.md @@ -37,7 +37,7 @@ coverage, geometries, instances and symbol placements. | `SceneModel` | `SourceFormat` `rvt`, `SourceLengthUnit` `ft`, `Producer` `RvtIO.Scene `. | | `SceneElement` | One per drawable element of `ElementMeshes.Extract` (the elements the IFC export would consider products, see the remarks of `ElementMeshes`). `Id` is the element id. | | `SourceId` | The Revit `UniqueId`. This derivation does not change; a golden test pins it on a fixture file. | -| `IfcGuid` | The GlobalId Revit's IFC export gives the element: the value of its IfcGUID parameter (the built-in `IFC_GUID`, else a parameter named `IfcGUID`) when it is a valid 22-character GlobalId; else the export id of the `UniqueId` (the episode GUID with its last 32 bits XORed with the element id the `UniqueId` ends with) in the IFC base-64 form. It depends on the element only, never on export order or options. This derivation does not change; the golden test pins it. | +| `IfcGuid` | The GlobalId Revit's IFC export gives the element: the value of its built-in IfcGUID parameter (`IFC_GUID`, matched by id as RvtIO's IFC export matches it) when it is a valid 22-character GlobalId; else the export id of the `UniqueId` (the episode GUID with its last 32 bits XORed with the element id the `UniqueId` ends with) in the IFC base-64 form. It depends on the element only, never on export order or options. This derivation does not change; the golden test pins it. | | `Name`, `Category`, `TypeName` | `Element.Name`; the English name of the built-in category (else `Element.Category`); the name of the type element. | | `SceneGeometry`, `SceneInstance` | A symbol placed whole is one geometry per mesh in the frame of its family, and each placement is one instance per mesh with the placement's transform. A mirrored placement keeps its transform as it is: the winding of the triangles is not changed. A mesh of an element's own is a geometry used once, with vertices relative to the lowest corner of the element's meshes, which is the translation of its instances. All lengths are feet x 0.3048, in the internal coordinates of the document, Z up. | | `SceneMaterial` | One per material element that a mesh uses: its name and its colour (`MATERIAL_PARAM_COLOR`, alpha from `MATERIAL_PARAM_TRANSPARENCY`), else a neutral grey. | @@ -52,7 +52,7 @@ the one Revit shows; any other value is left out. By storage type: | Storage | Text | |---|---| | String | The stored text. | -| Double | The value converted to its display unit and the unit symbol, as `AsValueString` writes it from `UnitSymbol` and `ConversionFactor` (`3200.4 mm`), when the unit is mm, m², m³, ° or A: the units whose factor converts Revit's internal unit (foot, square foot, cubic foot, radian, ampere). Left out: a value without a resolved unit (`AsValueString` only guesses its unit from the parameter name), a unit the reader converts with a factor of 1 that is not Revit's conversion (W, V, °C, Pa, ...), and an area per unit length, which the reader shows in m². | +| Double | The value converted to its display unit and the unit symbol, as `AsValueString` writes it from `UnitSymbol` and `ConversionFactor` (`3200.4 mm`), when the unit was resolved from the parameter's measurement spec (`SpecType`) and is mm, m², m³, ° or A: the units whose factor converts Revit's internal unit (foot, square foot, cubic foot, radian, ampere). Left out: a value without a spec, which is every built-in double (the reader then guesses `UnitSymbol` from the parameter name, which gives the factor `TEXT_WIDTH_SCALE` the unit mm), a unit the reader converts with a factor of 1 that is not Revit's conversion (W, V, °C, Pa, ...), and an area per unit length, which the reader shows in m². | | Integer | A shared, project or family parameter: the number in decimal; such an integer is a plain integer or a Yes/No, which `AsValueString` shows as `1` or `0`. A built-in integer is left out: it may be an enumeration or a Yes/No, and the public API cannot tell it from a number. | | ElementId | Left out: the public API does not give the name Revit shows for the referenced element. | diff --git a/src/RvtIO.Scene/RvtSceneReader.cs b/src/RvtIO.Scene/RvtSceneReader.cs index 0d8272b..0ea79a9 100644 --- a/src/RvtIO.Scene/RvtSceneReader.cs +++ b/src/RvtIO.Scene/RvtSceneReader.cs @@ -21,15 +21,16 @@ namespace RvtIO.Scene; /// the meshes. A mesh with a material has the instance material of that material element: its name and, where the file /// gives it, its colour (else a neutral grey). /// An element's SourceId is its Revit UniqueId, IfcGuid the GlobalId Revit's IFC export gives it (its valid -/// IfcGUID parameter, else the export id of its UniqueId), Category the English name of its built-in category (else the +/// built-in IfcGUID parameter, else the export id of its UniqueId), Category the English name of its built-in category (else the /// category the reader gives), TypeName the name of its type element. Its parameters are the property set /// revit:instance; the parameters of its type are the property set revit:type, one set shared by every element of /// the type. A parameter whose value the reader only estimates () is left out. The key of /// a built-in parameter is revit:bip:<id>, other parameters have no key. /// Values are text: the display string of , with the invariant culture, and only /// where it is the text Revit shows. A string is the stored text; a double is its value in the display unit and the unit symbol -/// (3200.4 mm) when that unit is mm, m², m³, ° or A, the units whose factor converts Revit's internal unit, and is left -/// out otherwise (no unit resolved, or a unit the reader converts with a factor that is not Revit's); an integer of a shared, project or family +/// (3200.4 mm) when the unit comes from the parameter's measurement spec and is mm, m², m³, ° or A, the units whose +/// factor converts Revit's internal unit, and is left out otherwise (no spec, as for every built-in double, whose unit the +/// reader only guesses from the parameter name, or a unit the reader converts with a factor that is not Revit's); an integer of a shared, project or family /// parameter (a plain integer or a Yes/No, shown as 1 or 0) is the number in decimal; a built-in integer, which may be an /// enumeration, and an element id are left out. Levels and typed values are not written. /// @@ -236,10 +237,11 @@ private static string CategoryName(Element? element) => private static string? DisplayText(Parameter parameter) => parameter.StorageType switch { StorageType.String => parameter.Value as string, - // The unit form of AsValueString; its fallback by parameter name is a guess and is left out. - StorageType.Double when parameter.Value is double value && parameter.UnitSymbol is { } unit + // The unit form of AsValueString, with a unit resolved from the parameter's measurement spec. Without a spec (every + // built-in double) the reader guesses the unit from the parameter name, as for the factor TEXT_WIDTH_SCALE: left out. + StorageType.Double when parameter.Value is double value && parameter.SpecType is { } spec && parameter.UnitSymbol is { } unit && ExactUnits.TryGetValue(unit, out double exact) && parameter.ConversionFactor == exact - && parameter.SpecType?.Contains(":surfaceAreaPerUnitLength-", StringComparison.Ordinal) != true + && !spec.Contains(":surfaceAreaPerUnitLength-", StringComparison.Ordinal) => string.Create(CultureInfo.InvariantCulture, $"{value * exact:G6} {unit}"), // A user parameter that stores an integer is an Integer or a Yes/No, both shown by AsValueString as the number. A // built-in one may be an enumeration, which the public API cannot tell from a number, so it is left out. @@ -258,14 +260,13 @@ StorageType.Double when parameter.Value is double value && parameter.UnitSymbol private const string IfcGuidAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$"; - /// The IFC GlobalId Revit's IFC export gives the element: the value of its IfcGUID parameter (the built-in one, - /// else a parameter named IfcGUID) when it is a valid GlobalId, else the export id of its UniqueId (the episode GUID with - /// its last 32 bits XORed with the element id the UniqueId ends with) in the IFC base-64 form; null without either. + /// The IFC GlobalId Revit's IFC export gives the element: the value of its built-in IfcGUID parameter (IFC_GUID) + /// when it is a valid GlobalId, else the export id of its UniqueId (the episode GUID with its last 32 bits XORed with the + /// element id the UniqueId ends with) in the IFC base-64 form; null without either. private static string? IfcGuidOf(Element element) { - var overrides = element.Parameters.Where(parameter => parameter.Id == (int)BuiltInParameter.IFC_GUID) - .Concat(element.Parameters.Where(parameter => parameter.Id > 0 && parameter.Definition?.Name == "IfcGUID")); - if (overrides.Select(parameter => parameter.Value as string).FirstOrDefault(IsIfcGuid) is { } stored) return stored; + if (element.Parameters.Where(parameter => parameter.Id == (int)BuiltInParameter.IFC_GUID) + .Select(parameter => parameter.Value as string).FirstOrDefault(IsIfcGuid) is { } stored) return stored; // A UniqueId is the episode GUID, '-' and the original element id as eight hex digits. if (element.UniqueId is not { Length: 45 } uniqueId || uniqueId[36] != '-' diff --git a/tests/RvtIO.Tests/RvtSceneReaderTests.cs b/tests/RvtIO.Tests/RvtSceneReaderTests.cs index 896e3ac..c2bc153 100644 --- a/tests/RvtIO.Tests/RvtSceneReaderTests.cs +++ b/tests/RvtIO.Tests/RvtSceneReaderTests.cs @@ -32,10 +32,21 @@ public class RvtSceneReaderTests double => StorageType.Double, string => StorageType.String, _ => StorageType.Integer, }; - /// A length in feet, displayed in millimetres as the reader resolves a length parameter. - private static Parameter Length(BuiltInParameter bip, double feet) + /// A user length in feet, displayed in millimetres as the reader resolves a parameter with a length spec. + private static Parameter Length(int id, string name, double feet) { - var parameter = Bip(bip, feet); + var parameter = User(id, name, feet); + parameter.SpecType = "autodesk.spec.aec:length-2.0.0"; + parameter.UnitSymbol = "mm"; + parameter.ConversionFactor = 304.8; + return parameter; + } + + /// A built-in double as the reader resolves it: no spec, the unit guessed from the name (mm for a name with WIDTH, + /// HEIGHT, OFFSET, ...). + private static Parameter GuessedLength(BuiltInParameter bip, double value) + { + var parameter = Bip(bip, value); parameter.UnitSymbol = "mm"; parameter.ConversionFactor = 304.8; return parameter; @@ -51,8 +62,8 @@ private static RvtDocument Document() => RvtDocument.FromInfo(new RevitFileInfo { Elements = [ - Instance(Wall, (int)BuiltInCategory.OST_Walls, "OST_Walls", Length(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5), - User(900, "Fire rating", "EI 60")), + Instance(Wall, (int)BuiltInCategory.OST_Walls, "OST_Walls", Length(910, "Clear height", 10.5), + User(900, "Fire rating", "EI 60"), Bip(BuiltInParameter.ALL_MODEL_INSTANCE_COMMENTS, "Outer leaf")), Instance(Door, (int)BuiltInCategory.OST_Doors, "OST_Doors"), Instance(Empty, (int)BuiltInCategory.OST_Walls, "OST_Walls"), Instance(Broken, (int)BuiltInCategory.OST_Walls, "OST_Walls"), @@ -201,12 +212,13 @@ public void ParametersAreTextPropertySetsWithRevitKeys() Assert.Equal(2, wall.PropertySetIds.Count); var instance = document.PropertySets.Single(set => set.Id == wall.PropertySetIds[0]); Assert.Equal("revit:instance", instance.Key); - var height = instance.Properties.Single(property => property.Name == "WALL_USER_HEIGHT_PARAM"); - Assert.Equal("revit:bip:-1001105", height.Key); + var comments = instance.Properties.Single(property => property.Name == "ALL_MODEL_INSTANCE_COMMENTS"); + Assert.Equal("revit:bip:-1010106", comments.Key); + Assert.Equal(SceneValue.FromText("Outer leaf"), comments.Value); + var height = instance.Properties.Single(property => property.Name == "Clear height"); + Assert.Null(height.Key); Assert.Equal(SceneValue.FromText("3200.4 mm"), height.Value); - var user = instance.Properties.Single(property => property.Name == "Fire rating"); - Assert.Null(user.Key); - Assert.Equal("EI 60", user.Value.Text); + Assert.Equal("EI 60", instance.Properties.Single(property => property.Name == "Fire rating").Value.Text); // one type set, shared by every instance of the type; the door's only instance parameter is its type, an element id Assert.Equal(wall.PropertySetIds[1], Assert.Single(door.PropertySetIds)); var type = document.PropertySets.Single(set => set.Id == wall.PropertySetIds[1]); @@ -222,23 +234,22 @@ public void ApproximateParametersAreLeftOut() estimate.IsApproximate = true; var info = new RevitFileInfo { - Elements = [new Element { Id = Wall, UniqueId = "w", Parameters = [Length(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5), estimate] }], + Elements = [new Element { Id = Wall, UniqueId = "w", Parameters = [Length(910, "Clear height", 10.5), estimate] }], }; var meshes = new MeshSet { Elements = [new ElementMesh { ElementId = Wall, Meshes = [Triangle(0, 0, 0)] }] }; var document = RvtSceneReader.Read(RvtDocument.FromInfo(info), meshes, new RvtSceneOptions { ModelKey = "m" }).Document; var set = Assert.Single(document.PropertySets); - Assert.Equal("WALL_USER_HEIGHT_PARAM", Assert.Single(set.Properties).Name); + Assert.Equal("Clear height", Assert.Single(set.Properties).Name); Assert.Empty(document.Validate()); } [Fact] public void LengthShowsTheDisplayStringOfThePublicApiWithItsUnit() { - var height = Length(BuiltInParameter.WALL_USER_HEIGHT_PARAM, 10.5); - // Without a resolved unit the public API only guesses by the parameter name: the value is left out. - var guessed = Bip(BuiltInParameter.WALL_BASE_OFFSET, 1.25); + var height = Length(910, "Clear height", 10.5); + // A double without a resolved unit is left out. var plain = User(902, "Factor", 0.5); - var document = MapOne(new Element { Id = Wall, UniqueId = "w", Parameters = [height, guessed, plain] }); + var document = MapOne(new Element { Id = Wall, UniqueId = "w", Parameters = [height, plain] }); var texts = Texts(document); Assert.Equal("3200.4 mm", Assert.Single(texts).Value); @@ -246,10 +257,10 @@ public void LengthShowsTheDisplayStringOfThePublicApiWithItsUnit() try { CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; - Assert.Equal(height.AsValueString(), texts["WALL_USER_HEIGHT_PARAM"]); + Assert.Equal(height.AsValueString(), texts["Clear height"]); // The text does not follow the culture of the machine. CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("ru-RU"); - Assert.Equal("3200.4 mm", Texts(MapOne(new Element { Id = Wall, UniqueId = "w", Parameters = [height] }))["WALL_USER_HEIGHT_PARAM"]); + Assert.Equal("3200.4 mm", Texts(MapOne(new Element { Id = Wall, UniqueId = "w", Parameters = [height] }))["Clear height"]); } finally { @@ -258,14 +269,30 @@ public void LengthShowsTheDisplayStringOfThePublicApiWithItsUnit() Assert.Empty(document.Validate()); } + [Fact] + public void BuiltInDoubleWhoseUnitIsGuessedFromItsNameIsLeftOut() + { + // The reader gives every built-in double a unit by its name; for the text width factor 1.0 that is "304.8 mm". + var widthFactor = GuessedLength(BuiltInParameter.TEXT_WIDTH_SCALE, 1.0); + var baseOffset = GuessedLength(BuiltInParameter.WALL_BASE_OFFSET, 1.25); + var document = MapOne(new Element + { + Id = Wall, UniqueId = "w", Parameters = [widthFactor, baseOffset, User(900, "Fire rating", "EI 60")], + }); + Assert.Equal(["Fire rating"], Texts(document).Keys); + Assert.Empty(document.Validate()); + } + [Fact] public void UnitWhoseFactorIsNotRevitsConversionIsLeftOut() { var angle = User(907, "Slope angle", Math.PI / 4); + angle.SpecType = "autodesk.spec.aec:angle-2.0.0"; angle.UnitSymbol = "\u00B0"; angle.ConversionFactor = 180 / Math.PI; // Revit stores power in kg ft^2/s^3, not in watts: a factor of 1 does not give the watts Revit shows. var power = User(908, "Load", 120.0); + power.SpecType = "autodesk.spec.aec.electrical:power-1.0.0"; power.UnitSymbol = "W"; power.ConversionFactor = 1; var perLength = User(909, "Surface per length", 2.0); @@ -317,9 +344,10 @@ public void ValidIfcGuidParameterOverridesTheExportId() { const string UniqueId = "d45b1816-cbcc-4dc6-b0bb-007467f3c600-000405a0"; var builtIn = Bip(BuiltInParameter.IFC_GUID, "2abcdefghijklmnopqrs$_"); + // Only the built-in parameter overrides, as in RvtIO's IFC export; a user parameter of the same name does not. var shared = User(906, "IfcGUID", "0123456789ABCDEFGHIJKL"); Assert.Equal("2abcdefghijklmnopqrs$_", MapOne(new Element { Id = 1, UniqueId = UniqueId, Parameters = [shared, builtIn] }).Elements[0].IfcGuid); - Assert.Equal("0123456789ABCDEFGHIJKL", MapOne(new Element { Id = 1, UniqueId = UniqueId, Parameters = [shared] }).Elements[0].IfcGuid); + Assert.Equal("3KMnWMoynDnh2x07HdzyEW", MapOne(new Element { Id = 1, UniqueId = UniqueId, Parameters = [shared] }).Elements[0].IfcGuid); // Not a GlobalId: empty, too short, a character outside the alphabet, a first character above 3. foreach (string invalid in new[] { "", "2abcdefghijklmnopqrs$", "2abcdefghijklmnopqrs-_", "4abcdefghijklmnopqrs$_" }) Assert.Equal("3KMnWMoynDnh2x07HdzyEW", From 337efdaa0aca0ab6750ca1045252efad0feb821a Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:46:15 +0300 Subject: [PATCH 09/13] Update the test counts for the new scene reader tests --- 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 aacbc61..63e779e 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, 10422 of the 10865 test cases run and pass on net8.0 and 443 are skipped (net10.0: 10426 of -10869, 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, 10429 of the 10872 test cases run and pass on net8.0 and 443 are skipped (net10.0: 10433 of +10876, 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 From e216bbf549150a1c90a48c1a5f9d1af9df444c7e Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:09:45 +0300 Subject: [PATCH 10/13] Publish the scene adapter on the engine's release tag --- CHANGELOG.md | 3 ++- docs/scene.md | 3 ++- src/RvtIO.Scene/RvtIO.Scene.csproj | 17 +++++++++++++-- tools/pack-release.ps1 | 35 ++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77457dc..2adf4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ API between minor releases. ### Added -- Preview project `src/RvtIO.Scene` (not published as a package): `RvtSceneReader` turns a document into a scene of the `IOScene` preview contract, with the element meshes in metres (shared symbols as instanced geometry), the Revit UniqueId as the source id, the IFC GlobalId of Revit's IFC export as the IfcGuid, names, categories, materials and the instance and type parameters as property sets of their display text. See [the scene adapter](docs/scene.md). +- Preview project `src/RvtIO.Scene`: `RvtSceneReader` turns a document into a scene of the `IOScene` preview contract, with the element meshes in metres (shared symbols as instanced geometry), the Revit UniqueId as the source id, the IFC GlobalId of Revit's IFC export as the IfcGuid, names, categories, materials and the instance and type parameters as property sets of their display text. See [the scene adapter](docs/scene.md). +- Package `RvtIO.Scene`: the scene adapter is now published on the RvtIO release tag, depends on exactly the same RvtIO version and needs IOScene 0.1.0 (a release that would depend on an IOScene preview fails). - 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. diff --git a/docs/scene.md b/docs/scene.md index 1dda0e7..8024b0b 100644 --- a/docs/scene.md +++ b/docs/scene.md @@ -5,7 +5,8 @@ model, the drawable elements, their triangle meshes in metres, their names and t consumer of the scene reads it without knowing Revit. The scene itself is specified in the contract of the `IOScene` repository (`docs/contract.md`), and the property keys it writes are registered there in `docs/keys.md`. -The adapter is a preview and is not published as a package yet: reference the project `src/RvtIO.Scene`. +The adapter is a preview. It is published as the package `RvtIO.Scene` on each RvtIO release tag, depends on exactly +the same RvtIO version and needs IOScene 0.1.0 or a later 0.1 release. ## Usage diff --git a/src/RvtIO.Scene/RvtIO.Scene.csproj b/src/RvtIO.Scene/RvtIO.Scene.csproj index 0b40874..31751f6 100644 --- a/src/RvtIO.Scene/RvtIO.Scene.csproj +++ b/src/RvtIO.Scene/RvtIO.Scene.csproj @@ -5,8 +5,6 @@ net8.0;net10.0 RvtIO.Scene RvtIO.Scene - - false true $(NoWarn);NU5104 @@ -17,4 +15,19 @@ + + + + + + + + + + <_Pinned Include="@(_ProjectReferencesWithVersions)">[%(_ProjectReferencesWithVersions.ProjectVersion)] + <_ProjectReferencesWithVersions Remove="@(_ProjectReferencesWithVersions)" /> + <_ProjectReferencesWithVersions Include="@(_Pinned)" /> + + + diff --git a/tools/pack-release.ps1 b/tools/pack-release.ps1 index b6046a4..06f3187 100644 --- a/tools/pack-release.ps1 +++ b/tools/pack-release.ps1 @@ -34,6 +34,29 @@ function New-Zip([string]$SourceDir, [string]$ZipPath) { Compress-Archive -Path (Join-Path $SourceDir '*') -DestinationPath $ZipPath } +# Throws unless the RvtIO.Scene nuspec depends on RvtIO exactly at $Version and on a released IOScene. +function Assert-SceneDependencies([xml]$Nuspec, [string]$Version) { + $dependencies = @($Nuspec.SelectNodes("//*[local-name()='dependency']")) + # (a) Guards the PinProjectReferencesExactly target, which hooks a private NuGet target and would fail silently if an SDK renamed it. + $engine = @($dependencies | Where-Object { $_.GetAttribute('id') -eq 'RvtIO' }) + if ($engine.Count -eq 0) { throw "RvtIO.Scene has no dependency on RvtIO" } + foreach ($dependency in $engine) { + if ($dependency.GetAttribute('version') -cne "[$Version]") { + throw "RvtIO.Scene depends on RvtIO $($dependency.GetAttribute('version')), expected exactly [$Version]" + } + } + # (b) Replaces the NU5104 check that the IOScene range switches off: no adapter built on a preview contract reaches nuget.org. + $scene = @($dependencies | Where-Object { $_.GetAttribute('id') -eq 'IOScene' }) + if ($scene.Count -eq 0) { throw "RvtIO.Scene has no dependency on IOScene" } + foreach ($dependency in $scene) { + $range = $dependency.GetAttribute('version') + $lower = $range.TrimStart('[', '(').Split(',')[0].Trim() + if ($lower.Contains('-')) { + throw "RvtIO.Scene depends on IOScene $range, whose lower bound is a preview; release on a published IOScene 0.1.0 or later" + } + } +} + # 1. RvtIO Library assemblies (net8.0 and net10.0 with all dependencies) Write-Host "Publishing RvtIO library..." $libStage = Join-Path $stage 'lib' @@ -74,6 +97,18 @@ New-Zip $cli10Stage (Join-Path $Output "RvtIO-Cli-$version-net10.0.zip") Write-Host "Packing NuGet packages..." Invoke-DotNet pack (Join-Path $root 'RvtIO.slnx') -c Release --no-build -o $Output +Add-Type -AssemblyName System.IO.Compression.FileSystem +$scenePackage = [System.IO.Compression.ZipFile]::OpenRead((Join-Path $Output "RvtIO.Scene.$version.nupkg")) +try { + $entry = $scenePackage.GetEntry('RvtIO.Scene.nuspec') + if (-not $entry) { throw "RvtIO.Scene.$version.nupkg has no RvtIO.Scene.nuspec" } + $reader = New-Object System.IO.StreamReader($entry.Open()) + try { $nuspec = [xml]$reader.ReadToEnd() } finally { $reader.Dispose() } +} finally { + $scenePackage.Dispose() +} +Assert-SceneDependencies $nuspec $version + # 5. Source code archive Write-Host "Creating source code archive..." $sourceZip = Join-Path $Output "RvtIO-$version-source.zip" From 25e24e5df60001e79574ef15c23229e9560f7f14 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:15:34 +0300 Subject: [PATCH 11/13] Fail the release when the IOScene dependency has no lower bound --- tools/pack-release.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/pack-release.ps1 b/tools/pack-release.ps1 index 06f3187..e517ca1 100644 --- a/tools/pack-release.ps1 +++ b/tools/pack-release.ps1 @@ -51,8 +51,8 @@ function Assert-SceneDependencies([xml]$Nuspec, [string]$Version) { foreach ($dependency in $scene) { $range = $dependency.GetAttribute('version') $lower = $range.TrimStart('[', '(').Split(',')[0].Trim() - if ($lower.Contains('-')) { - throw "RvtIO.Scene depends on IOScene $range, whose lower bound is a preview; release on a published IOScene 0.1.0 or later" + if ($lower -eq '' -or $lower.Contains('-')) { + throw "RvtIO.Scene depends on IOScene '$range', whose lower bound is missing or a preview; release on a published IOScene 0.1.0 or later" } } } From 105df5fd5b845caf456d1a744b4b6ced75c3be31 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:47:32 +0300 Subject: [PATCH 12/13] Report members of partition streams that did not decode in the scene diagnostics Read(path) now adds, after read-cut and in stream order, one diagnostic per stream and kind by the rules of the export report: failed-members (error), recovered-members and unverified-members (warnings). The element table rows of the report's no-rows issue have no public API, so that issue is not reported. The document overloads open no file; their docs say to read RvtReaderOptions.Diagnostics. --- docs/scene.md | 6 +++ src/RvtIO.Scene/RvtIO.Scene.csproj | 4 ++ src/RvtIO.Scene/RvtSceneReader.cs | 37 +++++++++++++-- tests/RvtIO.Tests/CliExitCodeTests.cs | 2 +- tests/RvtIO.Tests/RvtSceneReaderTests.cs | 60 +++++++++++++++++++++++- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/docs/scene.md b/docs/scene.md index 8024b0b..5415095 100644 --- a/docs/scene.md +++ b/docs/scene.md @@ -26,6 +26,9 @@ needs `RvtSceneOptions.ModelKey`, because a document does not know its file name takes the meshes you extracted from that document yourself, for example with `ElementMeshes.Extract` and options of your own. Open a file of a release other than Revit 2022 with `R22Layout = true`, or its elements have no geometry and the result has the error `geometry-undecoded`. +Only `Read(path)` reports the diagnostics of the read (`read-cut`, `failed-members`, `recovered-members`, +`unverified-members`), as only it opens the file; if you open the document yourself, read them from the +`RvtReaderOptions.Diagnostics` you opened it with. `RvtSceneResult` holds the `Document`, the `Diagnostics` and counts: elements, elements with geometry, elements by coverage, geometries, instances and symbol placements. @@ -65,6 +68,9 @@ The text a value is written with under a key is frozen with the key: changing it |---|---|---| | `geometry-undecoded` | error | The geometry of the file was not decoded; open it with `R22Layout`. | | `read-cut` | error | A reader limit cut a stream while the file was read (`Read(path)` only); elements may be missing. | +| `failed-members` | error | Gzip members of a stream failed to decode (`Read(path)` only); elements may be missing. One per stream. | +| `recovered-members` | warning | Gzip members of a stream were rebuilt by structural recovery (`Read(path)` only); their elements may be incomplete or wrong. One per stream. | +| `unverified-members` | warning | Gzip members of a stream decoded without a matching checksum, not counting the rebuilt ones (`Read(path)` only); their elements may be incomplete or wrong. One per stream. | | `undecoded` | warning | An element of such a file; it is in the scene without geometry. | | `no-geometry` | warning | An element whose geometry record has nothing that could be drawn; it is in the scene without geometry. | | `partial-geometry` | warning | Faces that could not be triangulated, or a placed symbol or Geometry node without a decoded polygon. | diff --git a/src/RvtIO.Scene/RvtIO.Scene.csproj b/src/RvtIO.Scene/RvtIO.Scene.csproj index 31751f6..f25c728 100644 --- a/src/RvtIO.Scene/RvtIO.Scene.csproj +++ b/src/RvtIO.Scene/RvtIO.Scene.csproj @@ -10,6 +10,10 @@ $(NoWarn);NU5104 + + + + diff --git a/src/RvtIO.Scene/RvtSceneReader.cs b/src/RvtIO.Scene/RvtSceneReader.cs index 0ea79a9..08086db 100644 --- a/src/RvtIO.Scene/RvtSceneReader.cs +++ b/src/RvtIO.Scene/RvtSceneReader.cs @@ -41,7 +41,9 @@ public static class RvtSceneReader /// Reads the Revit file at with the options the RvtIO command line tool reads it with for an /// IFC export (the Revit 2022 layout, English system family names, faces with distinct openings kept apart) and turns it - /// into a scene. + /// into a scene. The diagnostics also report the read: a stream a reader limit cut (read-cut) and the members of a + /// stream that failed to decode, were rebuilt by structural recovery or decoded without a matching checksum + /// (failed-members, recovered-members, unverified-members). /// is empty, or is empty. public static RvtSceneResult Read(string path, RvtSceneOptions? options = null) { @@ -56,13 +58,38 @@ public static RvtSceneResult Read(string path, RvtSceneOptions? options = null) SeparateFacesWithDistinctOpenings = true, Diagnostics = readDiagnostics, }); + return Map(document, ElementMeshes.Extract(document), key, ReadDiagnostics(readDiagnostics)); + } + + /// The scene diagnostics of a read: read-cut for each stream a reader limit cut, then for each stream read, in + /// the order read, the members that failed to decode, were rebuilt by structural recovery or decoded without a matching + /// checksum, by the rules of the issues of the export report. + internal static IEnumerable ReadDiagnostics(RvtReadDiagnostics diagnostics) + { // A cut read is what the command line tool refuses to export; here the scene is made and the cut is an error. - var cut = readDiagnostics.LimitHits.Select(hit => new SceneDiagnostic(SceneSeverity.Error, "read-cut", - $"The stream {hit.Stream} was cut by the reader limit {hit.Limit}: {hit.Detail}. Elements may be missing.")); - return Map(document, ElementMeshes.Extract(document), key, cut); + foreach (var hit in diagnostics.LimitHits) + yield return new(SceneSeverity.Error, "read-cut", + $"The stream {hit.Stream} was cut by the reader limit {hit.Limit}: {hit.Detail}. Elements may be missing."); + foreach (var stream in diagnostics.Streams) + { + // A member that failed to decode is lost, as a cut is: an error. + if (stream.Failed > 0) + yield return new(SceneSeverity.Error, "failed-members", + $"The stream {stream.Name}: {stream.Failed} of {stream.Members} member(s) failed to decode. Elements may be missing."); + if (stream.Recovered > 0) + yield return new(SceneSeverity.Warning, "recovered-members", + $"The stream {stream.Name}: {stream.Recovered} of {stream.Members} member(s) were rebuilt by structural recovery. Elements may be incomplete or wrong."); + // A rebuilt member is also counted unverified; it is reported once, as rebuilt. + int unverified = stream.Unverified - stream.Recovered; + if (unverified > 0) + yield return new(SceneSeverity.Warning, "unverified-members", + $"The stream {stream.Name}: {unverified} of {stream.Members} member(s) decoded without a matching checksum. Elements may be incomplete or wrong."); + } } /// Turns an opened document into a scene. + /// The diagnostics of the read are reported only by , which opens the + /// file; a caller who opens the document reads them from . /// The document, opened by the caller; open a file of a release other than Revit 2022 with /// , or its elements have no geometry. /// The options; is required, as a document has no file name. @@ -76,6 +103,8 @@ public static RvtSceneResult Read(RvtDocument document, RvtSceneOptions? options /// Turns an opened document into a scene with meshes the caller extracted from it, for example with /// and options of its own. + /// The diagnostics of the read are reported only by , which opens the + /// file; a caller who opens the document reads them from . /// The document the meshes come from; the element facts are read from it. /// The meshes of the drawable elements of . /// The options; is required, as a document has no file name. diff --git a/tests/RvtIO.Tests/CliExitCodeTests.cs b/tests/RvtIO.Tests/CliExitCodeTests.cs index 48133a3..27c88db 100644 --- a/tests/RvtIO.Tests/CliExitCodeTests.cs +++ b/tests/RvtIO.Tests/CliExitCodeTests.cs @@ -73,7 +73,7 @@ private static (int ExitCode, string StdErr) RunWithOutput(params string[] argum return (process.ExitCode, error.Result); } - private static void WriteSyntheticRvt(string path, byte[]? extraPartition = null) + internal static void WriteSyntheticRvt(string path, byte[]? extraPartition = null) { using var elemTable = new MemoryStream(); using (var writer = new BinaryWriter(elemTable)) diff --git a/tests/RvtIO.Tests/RvtSceneReaderTests.cs b/tests/RvtIO.Tests/RvtSceneReaderTests.cs index c2bc153..395a628 100644 --- a/tests/RvtIO.Tests/RvtSceneReaderTests.cs +++ b/tests/RvtIO.Tests/RvtSceneReaderTests.cs @@ -9,8 +9,8 @@ namespace RvtIO.Tests; /// The scene of on synthetic meshes and elements: instancing, mirrored placements, local -/// origins, feet to metres, element facts, IFC GlobalIds, property sets and their display text, materials and the diagnostics of -/// incomplete geometry. +/// origins, feet to metres, element facts, IFC GlobalIds, property sets and their display text, materials, the diagnostics of +/// incomplete geometry and those of a damaged read. public class RvtSceneReaderTests { private const double Foot = 0.3048; @@ -422,6 +422,62 @@ public void ElementWithoutUniqueIdIsReportedOnce() Assert.Empty(result.Document.Validate()); } + private static readonly string[] MemberCodes = ["failed-members", "recovered-members", "unverified-members"]; + + /// The scene of a synthetic project file read from disk; is a second partition stream. + private static RvtSceneResult ReadSyntheticFile(byte[]? extraPartition = null) + { + string path = Path.Combine(Path.GetTempPath(), "rvtio-scene-" + Guid.NewGuid().ToString("N") + ".rvt"); + try + { + CliExitCodeTests.WriteSyntheticRvt(path, extraPartition); + return RvtSceneReader.Read(path); + } + finally { File.Delete(path); } + } + + [Fact] + public void MembersThatFailedToDecodeAreAnErrorOfTheirStream() + { + // A partition stream without a gzip header: one member, and it does not decode. + byte[] damaged = new byte[512]; + new Random(11).NextBytes(damaged); + damaged[0] = 0; damaged[1] = 1; + var result = ReadSyntheticFile(damaged); + var error = Assert.Single(result.Diagnostics, diagnostic => diagnostic.Severity == SceneSeverity.Error); + Assert.Equal("failed-members", error.Code); + Assert.Equal("The stream Partitions/2: 1 of 1 member(s) failed to decode. Elements may be missing.", error.Message); + Assert.DoesNotContain(result.Diagnostics, diagnostic => diagnostic.Code is "recovered-members" or "unverified-members"); + } + + [Fact] + public void IntactFileHasNoMemberDiagnostics() => + Assert.DoesNotContain(ReadSyntheticFile().Diagnostics, diagnostic => MemberCodes.Contains(diagnostic.Code)); + + [Fact] + public void ReadDiagnosticsFollowTheIssuesOfTheExportReport() + { + var read = new RvtReadDiagnostics(); + read.LimitHits.Add(new ReaderLimitHit("Partitions/7", "partition-bytes", 1000, true, "the stream is longer than the limit")); + read.Streams.Add(new StreamRecord("Global/ElemTable", 100, 1, 1, 0, 0, 0)); + // A rebuilt member is also unverified: it is reported as rebuilt only. + read.Streams.Add(new StreamRecord("Partitions/0", 1000, 4, 3, 1, 1, 1)); + read.Streams.Add(new StreamRecord("Partitions/1", 1000, 5, 5, 0, 1, 3)); + + var diagnostics = RvtSceneReader.ReadDiagnostics(read).ToList(); + + Assert.Equal( + [ + (SceneSeverity.Error, "read-cut"), (SceneSeverity.Error, "failed-members"), (SceneSeverity.Warning, "recovered-members"), + (SceneSeverity.Warning, "recovered-members"), (SceneSeverity.Warning, "unverified-members"), + ], + diagnostics.Select(diagnostic => (diagnostic.Severity, diagnostic.Code))); + Assert.Equal("The stream Partitions/0: 1 of 4 member(s) failed to decode. Elements may be missing.", diagnostics[1].Message); + Assert.Equal("The stream Partitions/0: 1 of 4 member(s) were rebuilt by structural recovery. Elements may be incomplete or wrong.", diagnostics[2].Message); + Assert.Contains("Partitions/1: 1 of 5 member(s) were rebuilt", diagnostics[3].Message); + Assert.Equal("The stream Partitions/1: 2 of 5 member(s) decoded without a matching checksum. Elements may be incomplete or wrong.", diagnostics[4].Message); + } + [Fact] public void DocumentNeedsAModelKey() { From ae614890ee93de588d2864fa7b203d636ad2bf69 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:13:14 +0300 Subject: [PATCH 13/13] Update the test counts for the merged tree --- 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 fa7ea69..feb9b09 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, 10438 of the 10882 test cases run and pass on net8.0 and 444 are skipped (net10.0: 10442 of -10886, 444 skipped): 438 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) +With no private file at all, 10450 of the 10894 test cases run and pass on net8.0 and 444 are skipped (net10.0: 10454 of +10898, 444 skipped): 438 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