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 9789136..21cd077 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ API between minor releases.
### Added
+- 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 placed symbol or a symbol nested in one has no geometry, or a Geometry node that Revit exports has no decoded polygon). A cut or clipped instance, an instance with faces of its own and a scaled one keep their own triangles; a mirrored placement keeps the winding of its symbol and carries the mirror in its transform. The drawable elements follow a subset of the product rules of the IFC export of the current profile (listed in the `ElementMeshes` remarks, with those not applied, such as the "export to IFC" flag and topography, so the meshes can include elements the IFC leaves out); the face materials and the hidden Geometry nodes follow its rules. The types are listed in [the public API](docs/public-api.md). IFC and JSON output is unchanged.
- CLI and diagnostics: `--strict` exits with code 6 (`RvtExitCodes.PartialRead`) instead of 0 when a read finishes partial without exceeding a reader limit (a stream member failed to decode, was rebuilt by structural recovery, or decoded without a matching checksum, or `Global/ElemTable` decoded no row). Nothing is exported when exit code 6 is returned; `limits.strict` records the option and `issues[]` lists the affected streams and reasons (`failed-members`, `recovered-members`, `unverified-members`, `no-rows`) alongside `streams[]`. Default behavior without `--strict` is unchanged. (Closes #54)
- Default output, current IFC profile: every beam with a straight drive curve gets `Pset_BeamCommon.Slope`, the angle in degrees of its drive curve to the horizontal (0 for a horizontal beam, never negative), whether or not BaseQuantities are exported and whatever its body; its quantities are unchanged. A beam without a straight drive curve gets no Slope.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c23f96d..623b92d 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/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..5415095
--- /dev/null
+++ b/docs/scene.md
@@ -0,0 +1,89 @@
+# 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. 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
+
+```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. `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`.
+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.
+
+## 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. |
+| `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. |
+| `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, 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 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. |
+
+The text a value is written with under a key is frozen with the key: changing it is a breaking release.
+
+## 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. |
+| `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. |
+| `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, 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 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/test-fixtures.md b/docs/test-fixtures.md
index e89ad49..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, 10424 of the 10867 test cases run and pass on net8.0 and 443 are skipped (net10.0: 10428 of
-10871, 443 skipped): 437 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`)
+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
@@ -56,7 +56,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell:
## Tests that need the private files
-294 test methods (437 cases, theory rows counted) are skipped without the files:
+295 test methods (438 cases, theory rows counted) are skipped without the files:
| Class | Methods | Cases | Files |
|---|---|---|---|
@@ -108,6 +108,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/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
diff --git a/src/RvtIO.Scene/RvtIO.Scene.csproj b/src/RvtIO.Scene/RvtIO.Scene.csproj
new file mode 100644
index 0000000..f25c728
--- /dev/null
+++ b/src/RvtIO.Scene/RvtIO.Scene.csproj
@@ -0,0 +1,37 @@
+
+
+
+ Turns a Revit project read by RvtIO into an IOScene scene document.
+ net8.0;net10.0
+ RvtIO.Scene
+ RvtIO.Scene
+ true
+
+ $(NoWarn);NU5104
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Pinned Include="@(_ProjectReferencesWithVersions)">[%(_ProjectReferencesWithVersions.ProjectVersion)]
+ <_ProjectReferencesWithVersions Remove="@(_ProjectReferencesWithVersions)" />
+ <_ProjectReferencesWithVersions Include="@(_Pinned)" />
+
+
+
+
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..08086db
--- /dev/null
+++ b/src/RvtIO.Scene/RvtSceneReader.cs
@@ -0,0 +1,360 @@
+using System.Buffers.Binary;
+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, IfcGuid the GlobalId Revit's IFC export gives it (its valid
+/// 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 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.
+///
+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. 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)
+ {
+ 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,
+ });
+ 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.
+ 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.
+ /// is null.
+ /// is null or empty.
+ public static RvtSceneResult Read(RvtDocument document, RvtSceneOptions? options)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ 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 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.
+ /// 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.
+ private 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,
+ IfcGuid = element != null ? IfcGuidOf(element) : null,
+ 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.
+ private 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 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;
+ if (DisplayText(parameter) is not { } text) 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;
+ }
+
+ /// 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, 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
+ && !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.
+ 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 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)
+ {
+ 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] != '-'
+ || !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);
+ 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; }
+}
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/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..450b9fb
--- /dev/null
+++ b/tests/RvtIO.Tests/RvtSceneIdentitySampleTests.cs
@@ -0,0 +1,31 @@
+using RvtIO.Scene;
+using Xunit;
+
+namespace RvtIO.Tests;
+
+/// 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]
+ 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("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());
+ Assert.Empty(result.Diagnostics);
+ }
+}
diff --git a/tests/RvtIO.Tests/RvtSceneReaderTests.cs b/tests/RvtIO.Tests/RvtSceneReaderTests.cs
new file mode 100644
index 0000000..395a628
--- /dev/null
+++ b/tests/RvtIO.Tests/RvtSceneReaderTests.cs
@@ -0,0 +1,502 @@
+using System.Globalization;
+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, 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;
+ 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, StorageType? storage = null) => new()
+ {
+ 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 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 = 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;
+ }
+
+ 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, StorageType.ElementId), .. parameters],
+ };
+
+ private static RvtDocument Document() => RvtDocument.FromInfo(new RevitFileInfo
+ {
+ Elements =
+ [
+ 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"),
+ 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",
+ 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.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);
+ 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 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);
+ 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]);
+ 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 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 = [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("Clear height", Assert.Single(set.Properties).Name);
+ Assert.Empty(document.Validate());
+ }
+
+ [Fact]
+ public void LengthShowsTheDisplayStringOfThePublicApiWithItsUnit()
+ {
+ 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, 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["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] }))["Clear height"]);
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = culture;
+ }
+ 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);
+ 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$_");
+ // 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("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",
+ MapOne(new Element { Id = 1, UniqueId = UniqueId, Parameters = [Bip(BuiltInParameter.IFC_GUID, invalid)] }).Elements[0].IfcGuid);
+ }
+
+ [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 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]
+ 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.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);
+ 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.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());
+ }
+
+ 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()
+ {
+ 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 = "" }));
+ 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]
+ 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());
+ }
+}
diff --git a/tools/pack-release.ps1 b/tools/pack-release.ps1
index b6046a4..e517ca1 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 -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"
+ }
+ }
+}
+
# 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"