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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ jobs:
if ($env:GITHUB_REF_NAME -cne "v$($version.Trim())") {
throw "The release tag must match the package version v$($version.Trim())."
}
# Before anything is published: a tag whose commit has no changelog section for the version fails here.
- name: Extract release notes
shell: pwsh
run: |
$version = $env:GITHUB_REF_NAME.Substring(1)
$changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8
$start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") })
if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" }
$notes = @()
for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] }
$notes | Set-Content -LiteralPath release-notes.md -Encoding utf8
- run: dotnet restore RvtIO.slnx
- run: dotnet build RvtIO.slnx -c Release --no-restore
- run: dotnet test RvtIO.slnx -c Release --no-build
Expand All @@ -37,21 +48,8 @@ jobs:
with:
name: packages
path: artifacts/*
- name: Create release
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$version = $env:GITHUB_REF_NAME.Substring(1)
$changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8
$start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") })
if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" }
$notes = @()
for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] }
$notes | Set-Content -LiteralPath release-notes.md -Encoding utf8
$files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName })
gh release create $env:GITHUB_REF_NAME --title $env:GITHUB_REF_NAME --notes-file release-notes.md @files
if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" }
# The push comes before the GitHub release: a refused first publication of a package id can be fixed and the
# job run again, because --skip-duplicate makes the push repeatable and no release exists yet.
# Trusted publishing: the NuGet policy for this repository and workflow file exchanges the GitHub OIDC token for a short-lived key.
- uses: NuGet/login@v1
id: nuget-login
Expand All @@ -64,3 +62,15 @@ jobs:
dotnet nuget push $package.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate
if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($package.Name) with exit code $LASTEXITCODE" }
}
- name: Create release
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$version = $env:GITHUB_REF_NAME.Substring(1)
$files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName })
# A version with a hyphen is a prerelease.
$flags = @()
if ($version.Contains('-')) { $flags += '--prerelease' }
gh release create $env:GITHUB_REF_NAME --title $env:GITHUB_REF_NAME --notes-file release-notes.md @flags @files
if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" }
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ API between minor releases.

### Added

- API: `ElementMeshes.Extract` (namespace `RvtIO.Meshes`) returns the triangle meshes of the drawable model elements of an opened `RvtDocument`, in feet, without reading the file again: per element its own triangles (one mesh per material, flat normals, counter-clockwise around them) and, for an element that is exactly its family symbols placed rigidly, the placements of shared symbol meshes in the frame of the family, with a coverage per element (complete, partial, box in place of a rescaled symbol, no geometry, undecoded; partial also when a placed symbol or a symbol nested in one has no geometry, or a Geometry node that Revit exports has no decoded polygon). A cut or clipped instance, an instance with faces of its own and a scaled one keep their own triangles; a mirrored placement keeps the winding of its symbol and carries the mirror in its transform. The drawable elements follow a subset of the product rules of the IFC export of the current profile (listed in the `ElementMeshes` remarks, with those not applied, such as the "export to IFC" flag and topography, so the meshes can include elements the IFC leaves out); the face materials and the hidden Geometry nodes follow its rules. The types are listed in [the public API](docs/public-api.md). IFC and JSON output is unchanged.
- CLI and diagnostics: `--strict` exits with code 6 (`RvtExitCodes.PartialRead`) instead of 0 when a read finishes partial without exceeding a reader limit (a stream member failed to decode, was rebuilt by structural recovery, or decoded without a matching checksum, or `Global/ElemTable` decoded no row). Nothing is exported when exit code 6 is returned; `limits.strict` records the option and `issues[]` lists the affected streams and reasons (`failed-members`, `recovered-members`, `unverified-members`, `no-rows`) alongside `streams[]`. Default behavior without `--strict` is unchanged. (Closes #54)
- Default output, current IFC profile: every beam with a straight drive curve gets `Pset_BeamCommon.Slope`, the angle in degrees of its drive curve to the horizontal (0 for a horizontal beam, never negative), whether or not BaseQuantities are exported and whatever its body; its quantities are unchanged. A beam without a straight drive curve gets no Slope.
- Default output, current IFC profile: `Pset_BeamCommon.Span` of a beam without a stored length is the length of its straight drive curve; a beam without one has no Span.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ RvtIO is a .NET library and command line tool that reads Autodesk Revit projects
- Queries elements through `FilteredElementCollector`.
- Exports IFC2X3 coordination view, with optional Revit parameters as property sets.
- Writes compact JSON or a full JSON dump with every parameter and geometry.
- Returns the triangle meshes of the model elements, with one shared mesh per family symbol for repeated instances (`ElementMeshes`).

## Install

Expand Down
22 changes: 22 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ foreach (var wall in walls)
new IfcExporter().Export(doc.RawData, "project.ifc");
```

### Triangle meshes

`ElementMeshes.Extract` returns the triangles of the model elements of an opened document, in feet. A repeated family instance
that is exactly its symbol is a placement of one shared symbol mesh:

```csharp
using RvtIO.Meshes;

var meshes = ElementMeshes.Extract(doc);
foreach (var element in meshes.Elements)
{
// element.Meshes: own triangles in the coordinates of the document, one mesh per material.
// element.Placements: symbols drawn by a transform; their triangles are meshes.Symbols[placement.SymbolId].
Console.WriteLine($"{element.ElementId}: {element.Meshes.Count} meshes, {element.Placements.Count} placements, {element.Coverage}");
}
```

Open a file of any release other than Revit 2022 with `new RvtReaderOptions { R22Layout = true }` to decode its geometry
(the reader keeps its own Revit 2022 layout only for Revit 2022 files; measured on samples of Revit 2020 and 2025, while the
Revit 2022 samples give the same meshes either way). Without it `MeshSet.GeometryUndecoded` is true and the elements are listed
as `MeshCoverage.Undecoded`. The command line tool sets the option for every IFC export.

The library is silent by default. Set `RvtReaderOptions.DiagnosticOutput` for reading or `IfcExporter.DiagnosticOutput` for export to receive diagnostics through a `TextWriter`.

The supported surface is listed in [Public API](public-api.md); every type is in the [API reference](api/RvtIO.yml).
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ RvtIO is a .NET library and command line tool that reads Autodesk Revit projects
- Queries elements through `FilteredElementCollector`.
- Exports IFC2X3 coordination view, with optional Revit parameters as property sets.
- Writes compact JSON or a full JSON dump with every parameter and geometry.
- Returns the triangle meshes of the model elements, with one shared mesh per family symbol for repeated instances (`ElementMeshes`).

The library targets .NET 8 and .NET 10, uses managed code only, and supports Windows, Linux and macOS.

Expand Down
24 changes: 24 additions & 0 deletions docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ extension and label helpers carry the ids and names stored in the file format.
| `CompactJsonExporter` | `Export(rvtPath, outputPath, readerOptions, options)` reads a file and writes the compact JSON. |
| `CompactJsonExportOptions` | `JsonClassNamePrefix`: text put in front of every class name in the `name` field. |

## Triangle meshes

| Type | Use |
|---|---|
| `ElementMeshes` | `Extract(document, options)` returns the triangle meshes of the drawable model elements of an opened `RvtDocument`, from the geometry the document already holds (the file is not read again). Coordinates are in feet, in the coordinates of the document (Revit internal coordinates, Z up). |
| `ElementMeshOptions` | `UseSymbolPlacements` (default true): an element that is exactly its family symbols placed rigidly is returned as placements of shared symbol meshes; false gives every element its own triangles. |
| `MeshSet` | `Elements` (one `ElementMesh` per drawable element, in document order), `Symbols` (symbol element id to its triangle meshes in the frame of the family, only for placed symbols) and `GeometryUndecoded` (a file of a release other than Revit 2022, read by its own class schema, opened without `RvtReaderOptions.R22Layout`: its elements are listed as `Undecoded`). |
| `ElementMesh` | `ElementId`, `Meshes` (the element's own triangles, one mesh per material), `Placements`, `Coverage` and `UntriangulatedFaces`. |
| `TriangleMesh` | `Positions` (x, y, z per vertex), `Indices` (three per triangle, counter-clockwise around the normals), `Normals` (one unit normal per vertex, flat per face: vertices are not shared between faces) and `MaterialId` (the material element id, 0 for none). |
| `SymbolPlacement`, `MeshTransform`, `MeshVector` | A symbol id with its transform. `MeshTransform` maps a family point p to `Origin + BasisX * p.X + BasisY * p.Y + BasisZ * p.Z`; the basis vectors are the columns of the row-major 3x3 matrix of `GeoInstance.Transform` (`BasisX = (t[0], t[3], t[6])`, `Origin = (t[9], t[10], t[11])`, see `FromRowMajor`). A negative `Determinant` is a mirror image. |
| `MeshCoverage` | `Complete`, `Partial` (some faces did not triangulate, a placed symbol or a symbol nested in one has no geometry, or a Geometry node that Revit exports has no decoded polygon), `BoxSubstitute` (a symbol placed at a rescaled placement was replaced by its box), `NoGeometry` (a geometry record with nothing to draw) and `Undecoded`. |

A placement is given only when it is shown that the element is exactly its symbols placed: the element's geometry record holds
no face of its own, the tight box of the element cut nothing off the placed symbols, every symbol resolved, no box was put in
the place of a component, and every transform is rigid (orthonormal, determinant 1 or -1). Any other element, a cut or clipped
instance included, gets its own triangles. The shared symbol triangles keep the winding of the family: a consumer that culls back
faces reverses the winding of a placement with a negative determinant. The own triangles of an element, those of a mirrored
instance included, are wound around the normals of their faces.

The drawable elements follow a subset of the product rules of the IFC export of the current profile, and the materials and the
hidden Geometry nodes follow its rules: they are listed in the documentation comment of `ElementMeshes`, with the product rules
that are not applied (the "export to IFC" flag, topography, walls that are not visible and others), so the meshes can include
elements that the IFC leaves out. Curved surfaces are the dense polygons the reader decodes for them.

## Diagnostics

The library never writes to the console. A diagnostic message is written only when the caller gives a `TextWriter`:
Expand Down
15 changes: 11 additions & 4 deletions docs/test-fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ folder (`TestFiles.Scratch`); no test uses a fixed absolute path.

## What runs without the private files

With no private file at all, 9686 of the 10105 test cases run and pass and 419 are skipped: 413 need private files (listed
below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`) and 6 need other inputs (two need symbolic links, three
With no private file at all, 10424 of the 10867 test cases run and pass on net8.0 and 443 are skipped (net10.0: 10428 of
10871, 443 skipped): 437 need private files (listed below; 17 of them only `bim_dump.txt` or `formats_latest_decompressed.bin`)
and 6 need other inputs (two need symbolic links, three
need `RVTIO_FREEFORM_FULL`, `RVTIO_CYL_FULL` or `RVTIO_MESH_FULL`, one is a known gap). The tests that run use synthetic data built
in the tests and the small fixtures committed under `tests/RvtIO.Tests/Fixtures`. With the full private fixture folder the
remaining cases run as well.
Expand All @@ -55,7 +56,7 @@ Run with the private files: `RVTIO_TEST_FILES=<folder> dotnet test` (PowerShell:

## Tests that need the private files

282 test methods (414 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 |
|---|---|---|---|
Expand All @@ -70,6 +71,7 @@ Run with the private files: `RVTIO_TEST_FILES=<folder> dotnet test` (PowerShell:
| `CompactPropertySourcesTests` | 4 | 4 | Test_2022.rvt, TESTFILE.rvt |
| `CompactSolidValueTests` | 5 | 6 | Test_2022.rvt, TESTFILE.rvt |
| `CompactUserParametersTests` | 2 | 2 | Test_2022.rvt |
| `CompoundLayerFunctionTests` | 1 | 1 | rac_basic_sample_project.rvt |
| `ContainerRewriterSampleTests` | 3 | 3 | any `.rvt` project files |
| `CoverageCensusSampleTests` | 1 | 3 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt |
| `CurrentCopiesDefaultTests` | 2 | 2 | TESTFILE.rvt, test_angled_wall.rvt |
Expand All @@ -80,8 +82,10 @@ Run with the private files: `RVTIO_TEST_FILES=<folder> dotnet test` (PowerShell:
| `EdgeBoundsTests` | 3 | 15 | TestData blocks |
| `ElemTableAuthorityTests` | 2 | 2 | TESTFILE.rvt |
| `ElementExportIdTests` | 3 | 6 | TESTFILE.rvt |
| `ElementHeaderBoundingBoxTests` | 2 | 13 | one `.rvt` project per release |
| `ElementHeaderCategoryTest2022Tests` | 2 | 2 | TESTFILE.rvt, Test_2022.rvt |
| `ElementHeaderCategoryTestFileTests` | 1 | 1 | TESTFILE.rvt |
| `ElementMeshesSampleTests` | 5 | 5 | Test_2022.rvt, Test_2025.rvt, test_door_in_wall.rvt |
| `EpisodeGuidTests` | 2 | 2 | TESTFILE.rvt |
| `ExportReportTests` | 8 | 13 | Test_2020.rvt, TESTFILE.rvt |
| `FaceAreaTests` | 2 | 2 | TestData blocks |
Expand All @@ -93,7 +97,8 @@ Run with the private files: `RVTIO_TEST_FILES=<folder> dotnet test` (PowerShell:
| `IfcCurrentSourcePlaneSlopeTests` | 1 | 4 | TestData blocks |
| `IfcExporterPlainWallBodyCurrentTests` | 4 | 4 | TestData blocks |
| `IfcExporterSkipReasonTests` | 1 | 1 | TESTFILE.rvt |
| `IfcExporterSlabQuantitiesSampleTests` | 2 | 2 | rstbasicsampleproject.rvt, struct25.rvt |
| `IfcExporterSlabQuantitiesSampleTests` | 3 | 3 | rstbasicsampleproject.rvt, struct25.rvt |
| `MultistoryStairCopyTests` | 1 | 1 | rac_advanced_sample_project.rvt |
| `OwnElementClassTests` | 1 | 1 | TESTFILE.rvt |
| `PageCodecSampleTests` | 2 | 2 | any `.rvt` project files |
| `PartitionFramingFuzzTests` | 1 | 1 | TESTFILE.rvt |
Expand All @@ -108,11 +113,13 @@ Run with the private files: `RVTIO_TEST_FILES=<folder> dotnet test` (PowerShell:
| `SchemaStreamEditorSampleTests` | 2 | 2 | any `.rvt` project files |
| `SpaceBoundaryLiteralTests` | 5 | 5 | TestData blocks |
| `StairComponentSampleTests` | 6 | 16 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt, rac_basic_sample_project.rvt |
| `SystemFamilyNameTests` | 1 | 1 | Test_2020.rvt |
| `TfSchemaToolsTests` | 3 | 6 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt |
| `UserParameterCatalogTests` | 1 | 1 | Test_2022.rvt, TESTFILE.rvt |
| `ValidatedElementNamesTest2022Tests` | 2 | 2 | Test_2022.rvt, TESTFILE.rvt |
| `ValidatedTests` | 72 | 88 | TESTFILE.rvt |
| `VkConnectorBlockTests` | 6 | 6 | TestData blocks |
| `WallWithoutSolidAndTypeElementTests` | 1 | 1 | Test_2025.rvt |
| `WideBlockWalkerTests` | 3 | 14 | Test_2025.rvt, TESTFILE.rvt |
| `WideElementRecordsTests` | 2 | 2 | Test_2025.rvt, TESTFILE.rvt |

Expand Down
20 changes: 5 additions & 15 deletions src/RvtIO/Ifc/IfcExporter.ExportPhase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,9 @@ namespace RvtIO.Ifc;

public partial class IfcExporter
{
/// <summary>The filter of the elements that do not exist in the export phase, null when there is none to apply. With a known
/// phase order it is the general rule (<see cref="ExportPhaseFilter"/>). Without it, the default export phase (the last one, no
/// explicit <see cref="ExportPhaseId"/>) still needs only the set of phases: an element demolished in any phase of the project
/// is demolished at or before the last one. That set is the ProjectPhase elements of the document; a document without any is
/// not filtered.</summary>
private ExportPhaseFilter? BuildExportPhaseFilter(RevitFileInfo info)
{
if (info.SerializedProjectPhaseIds is { } order)
return ResolveActivePhase(info) is int phase ? ExportPhaseFilter.For(order, phase) : null;
if (ExportPhaseId != null || ExportAllPhases || !CurrentProfile) return null;
var phases = info.Elements.Where(element => element.RootClassName == "ProjectPhase"
&& !(info.ElemTableIsExact && element.IsDiscovered))
.Select(element => element.Id).ToArray();
return phases.Length == 0 ? null : ExportPhaseFilter.OfLastPhase(phases);
}
/// <summary>The filter of the elements that do not exist in the export phase, null when there is none to apply: the rule of
/// <see cref="ProductRules.BuildExportPhaseFilter"/> for <see cref="ExportPhaseId"/>, with the last project phase as the default of the
/// current profile unless <see cref="ExportAllPhases"/> is set.</summary>
private ExportPhaseFilter? BuildExportPhaseFilter(RevitFileInfo info) =>
ProductRules.BuildExportPhaseFilter(info, ExportPhaseId, CurrentProfile && !ExportAllPhases);
}
14 changes: 3 additions & 11 deletions src/RvtIO/Ifc/IfcExporter.VoidInstancesCurrent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,7 @@ namespace RvtIO.Ifc;

public partial class IfcExporter
{
/// <summary>Bit 0x200 of the GInfo flags of a serialized Face. Every element has at least one
/// Face with it, except those whose Faces are all display (0x00200000) or auxiliary (0x04000000) surfaces, which never
/// reach the body.</summary>
internal const uint FaceFillFlag = 0x00000200;

/// <summary>Whether every decoded Face of the element lacks <see cref="FaceFillFlag"/>. The current profile writes no such instance:
/// an instance whose own geometry is null is not exported (for example the floor-hosted family
/// "Assignment - Rectangular opening (floor)"). Faces whose flags are not known (placed from a nested symbol) count as filled.</summary>
internal static bool HasOnlyUnfilledFacesCurrent(Element element) =>
element.Geometry?.Faces is { Count: > 0 } faces
&& faces.All(face => face.GInfoFlags is uint flags && (flags & FaceFillFlag) == 0);
/// <summary>Whether every decoded Face of the element is unfilled (<see cref="ProductRules.HasOnlyUnfilledFaces"/>). The current
/// profile writes no such instance.</summary>
internal static bool HasOnlyUnfilledFacesCurrent(Element element) => ProductRules.HasOnlyUnfilledFaces(element);
}
Loading
Loading