diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d70956..0b6a620 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,17 @@ jobs: if ($env:GITHUB_REF_NAME -cne "v$($version.Trim())") { throw "The release tag must match the package version v$($version.Trim())." } + # Before anything is published: a tag whose commit has no changelog section for the version fails here. + - name: Extract release notes + shell: pwsh + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 + $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) + if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } + $notes = @() + for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } + $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 - run: dotnet restore RvtIO.slnx - run: dotnet build RvtIO.slnx -c Release --no-restore - run: dotnet test RvtIO.slnx -c Release --no-build @@ -37,21 +48,8 @@ jobs: with: name: packages path: artifacts/* - - name: Create release - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $version = $env:GITHUB_REF_NAME.Substring(1) - $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 - $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) - if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } - $notes = @() - for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } - $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 - $files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName }) - gh release create $env:GITHUB_REF_NAME --title $env:GITHUB_REF_NAME --notes-file release-notes.md @files - if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" } + # The push comes before the GitHub release: a refused first publication of a package id can be fixed and the + # job run again, because --skip-duplicate makes the push repeatable and no release exists yet. # Trusted publishing: the NuGet policy for this repository and workflow file exchanges the GitHub OIDC token for a short-lived key. - uses: NuGet/login@v1 id: nuget-login @@ -64,3 +62,15 @@ jobs: dotnet nuget push $package.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($package.Name) with exit code $LASTEXITCODE" } } + - name: Create release + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + $files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName }) + # A version with a hyphen is a prerelease. + $flags = @() + if ($version.Contains('-')) { $flags += '--prerelease' } + gh release create $env:GITHUB_REF_NAME --title $env:GITHUB_REF_NAME --notes-file release-notes.md @flags @files + if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a644ac..9789136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 21ddad7..cb1a139 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ RvtIO is a .NET library and command line tool that reads Autodesk Revit projects - Queries elements through `FilteredElementCollector`. - Exports IFC2X3 coordination view, with optional Revit parameters as property sets. - Writes compact JSON or a full JSON dump with every parameter and geometry. +- Returns the triangle meshes of the model elements, with one shared mesh per family symbol for repeated instances (`ElementMeshes`). ## Install diff --git a/docs/getting-started.md b/docs/getting-started.md index ecaa74d..0abe3e4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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). diff --git a/docs/index.md b/docs/index.md index 4c17510..6cefe0d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,7 @@ RvtIO is a .NET library and command line tool that reads Autodesk Revit projects - Queries elements through `FilteredElementCollector`. - Exports IFC2X3 coordination view, with optional Revit parameters as property sets. - Writes compact JSON or a full JSON dump with every parameter and geometry. +- Returns the triangle meshes of the model elements, with one shared mesh per family symbol for repeated instances (`ElementMeshes`). The library targets .NET 8 and .NET 10, uses managed code only, and supports Windows, Linux and macOS. diff --git a/docs/public-api.md b/docs/public-api.md index 0dfcd0c..f12a65a 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -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`: diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md index 7dc2511..e89ad49 100644 --- a/docs/test-fixtures.md +++ b/docs/test-fixtures.md @@ -45,8 +45,9 @@ folder (`TestFiles.Scratch`); no test uses a fixed absolute path. ## What runs without the private files -With no private file at all, 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. @@ -55,7 +56,7 @@ Run with the private files: `RVTIO_TEST_FILES= 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 | |---|---|---|---| @@ -70,6 +71,7 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `CompactPropertySourcesTests` | 4 | 4 | Test_2022.rvt, TESTFILE.rvt | | `CompactSolidValueTests` | 5 | 6 | Test_2022.rvt, TESTFILE.rvt | | `CompactUserParametersTests` | 2 | 2 | Test_2022.rvt | +| `CompoundLayerFunctionTests` | 1 | 1 | rac_basic_sample_project.rvt | | `ContainerRewriterSampleTests` | 3 | 3 | any `.rvt` project files | | `CoverageCensusSampleTests` | 1 | 3 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt | | `CurrentCopiesDefaultTests` | 2 | 2 | TESTFILE.rvt, test_angled_wall.rvt | @@ -80,8 +82,10 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `EdgeBoundsTests` | 3 | 15 | TestData blocks | | `ElemTableAuthorityTests` | 2 | 2 | TESTFILE.rvt | | `ElementExportIdTests` | 3 | 6 | TESTFILE.rvt | +| `ElementHeaderBoundingBoxTests` | 2 | 13 | one `.rvt` project per release | | `ElementHeaderCategoryTest2022Tests` | 2 | 2 | TESTFILE.rvt, Test_2022.rvt | | `ElementHeaderCategoryTestFileTests` | 1 | 1 | TESTFILE.rvt | +| `ElementMeshesSampleTests` | 5 | 5 | Test_2022.rvt, Test_2025.rvt, test_door_in_wall.rvt | | `EpisodeGuidTests` | 2 | 2 | TESTFILE.rvt | | `ExportReportTests` | 8 | 13 | Test_2020.rvt, TESTFILE.rvt | | `FaceAreaTests` | 2 | 2 | TestData blocks | @@ -93,7 +97,8 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `IfcCurrentSourcePlaneSlopeTests` | 1 | 4 | TestData blocks | | `IfcExporterPlainWallBodyCurrentTests` | 4 | 4 | TestData blocks | | `IfcExporterSkipReasonTests` | 1 | 1 | TESTFILE.rvt | -| `IfcExporterSlabQuantitiesSampleTests` | 2 | 2 | rstbasicsampleproject.rvt, struct25.rvt | +| `IfcExporterSlabQuantitiesSampleTests` | 3 | 3 | rstbasicsampleproject.rvt, struct25.rvt | +| `MultistoryStairCopyTests` | 1 | 1 | rac_advanced_sample_project.rvt | | `OwnElementClassTests` | 1 | 1 | TESTFILE.rvt | | `PageCodecSampleTests` | 2 | 2 | any `.rvt` project files | | `PartitionFramingFuzzTests` | 1 | 1 | TESTFILE.rvt | @@ -108,11 +113,13 @@ Run with the private files: `RVTIO_TEST_FILES= dotnet test` (PowerShell: | `SchemaStreamEditorSampleTests` | 2 | 2 | any `.rvt` project files | | `SpaceBoundaryLiteralTests` | 5 | 5 | TestData blocks | | `StairComponentSampleTests` | 6 | 16 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt, rac_basic_sample_project.rvt | +| `SystemFamilyNameTests` | 1 | 1 | Test_2020.rvt | | `TfSchemaToolsTests` | 3 | 6 | Test_2020.rvt, Test_2022.rvt, Test_2025.rvt | | `UserParameterCatalogTests` | 1 | 1 | Test_2022.rvt, TESTFILE.rvt | | `ValidatedElementNamesTest2022Tests` | 2 | 2 | Test_2022.rvt, TESTFILE.rvt | | `ValidatedTests` | 72 | 88 | TESTFILE.rvt | | `VkConnectorBlockTests` | 6 | 6 | TestData blocks | +| `WallWithoutSolidAndTypeElementTests` | 1 | 1 | Test_2025.rvt | | `WideBlockWalkerTests` | 3 | 14 | Test_2025.rvt, TESTFILE.rvt | | `WideElementRecordsTests` | 2 | 2 | Test_2025.rvt, TESTFILE.rvt | diff --git a/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs b/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs index ddd85a4..e253749 100644 --- a/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs +++ b/src/RvtIO/Ifc/IfcExporter.ExportPhase.cs @@ -4,19 +4,9 @@ namespace RvtIO.Ifc; public partial class IfcExporter { - /// The filter of the elements that do not exist in the export phase, null when there is none to apply. With a known - /// phase order it is the general rule (). Without it, the default export phase (the last one, no - /// explicit ) still needs only the set of phases: an element demolished in any phase of the project - /// is demolished at or before the last one. That set is the ProjectPhase elements of the document; a document without any is - /// not filtered. - 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); - } + /// The filter of the elements that do not exist in the export phase, null when there is none to apply: the rule of + /// for , with the last project phase as the default of the + /// current profile unless is set. + private ExportPhaseFilter? BuildExportPhaseFilter(RevitFileInfo info) => + ProductRules.BuildExportPhaseFilter(info, ExportPhaseId, CurrentProfile && !ExportAllPhases); } diff --git a/src/RvtIO/Ifc/IfcExporter.VoidInstancesCurrent.cs b/src/RvtIO/Ifc/IfcExporter.VoidInstancesCurrent.cs index 692057d..5ba8100 100644 --- a/src/RvtIO/Ifc/IfcExporter.VoidInstancesCurrent.cs +++ b/src/RvtIO/Ifc/IfcExporter.VoidInstancesCurrent.cs @@ -4,15 +4,7 @@ namespace RvtIO.Ifc; public partial class IfcExporter { - /// Bit 0x200 of the GInfo flags of a serialized Face. Every element has at least one - /// Face with it, except those whose Faces are all display (0x00200000) or auxiliary (0x04000000) surfaces, which never - /// reach the body. - internal const uint FaceFillFlag = 0x00000200; - - /// Whether every decoded Face of the element lacks . The current profile writes no such instance: - /// an instance whose own geometry is null is not exported (for example the floor-hosted family - /// "Assignment - Rectangular opening (floor)"). Faces whose flags are not known (placed from a nested symbol) count as filled. - internal static bool HasOnlyUnfilledFacesCurrent(Element element) => - element.Geometry?.Faces is { Count: > 0 } faces - && faces.All(face => face.GInfoFlags is uint flags && (flags & FaceFillFlag) == 0); + /// Whether every decoded Face of the element is unfilled (). The current + /// profile writes no such instance. + internal static bool HasOnlyUnfilledFacesCurrent(Element element) => ProductRules.HasOnlyUnfilledFaces(element); } diff --git a/src/RvtIO/Ifc/IfcExporter.cs b/src/RvtIO/Ifc/IfcExporter.cs index eb6f89c..30dfef7 100644 --- a/src/RvtIO/Ifc/IfcExporter.cs +++ b/src/RvtIO/Ifc/IfcExporter.cs @@ -722,22 +722,9 @@ string SiteCoordinate(double feet) => object? value = e.Parameters.FirstOrDefault(p => p.Id == id)?.Value; return value is int n ? n : null; } - static bool HasProjectPhaseReference(Element e) - { - int? phase = IntParam(e, -1012100); // PHASE_CREATED - // Id 0 is a phase of Revit 2023 and later (the first project phase, a row of its - // element table); the earlier layouts number phases from 1. - return phase is > 0 && (e.References?.Contains(phase.Value) == true || e.SerializedCreatedPhaseId == phase.Value) - || phase == 0 && e.SerializedCreatedPhaseId == 0; - } - // Element types can carry preview/type geometry in the RVT stream, but they are definitions // rather than placed products. - var typeElementIds = new HashSet(); - foreach (var e in info.Elements) - foreach (var p in e.Parameters) - if (p.Id is -1002050 or -1002052 && p.Value is int typeId && typeId > 0) - typeElementIds.Add(typeId); + var typeElementIds = ProductRules.TypeElementIds(info.Elements); // A placed curtain panel/mullion references its parent wall. var curtainWallIds = new HashSet(); @@ -770,7 +757,7 @@ static bool HasProjectPhaseReference(Element e) .Where(e => { int rich = e.Geometry?.Faces?.Count(f => f.OuterVertices is { Count: >= 3 }) ?? 0; - return e.Location != null || rich >= 3 || HasProjectPhaseReference(e); + return e.Location != null || rich >= 3 || ProductRules.HasProjectPhaseReference(e); }) .Select(e => e.Id) .ToHashSet(); @@ -1043,7 +1030,7 @@ element.SuperInstanceId is not > 0 } if (!builtExactSourceWallProfilesMappedBreakpoint && doorOrWindow - && HostedFillingProductCanBeEmitted(cutter, elemById, storeyIds, HasProjectPhaseReference(cutter)) + && HostedFillingProductCanBeEmitted(cutter, elemById, storeyIds, ProductRules.HasProjectPhaseReference(cutter)) && TryBuildOwnPrimaryFamilyCutout(cutter, elemById[hostId], out var ownPrimaryOpening)) ownPrimaryFamilyWriterOperands[(hostId, cutter.Id)] = ownPrimaryOpening; if (precomputeDiagnostics) @@ -1216,7 +1203,7 @@ element.SuperInstanceId is not > 0 || cutter.ExplicitHostIds?.Contains(hostId) == true; bool visibleFillingProductIsValidated = doorOrWindow && directHost - && HostedFillingProductCanBeEmitted(cutter, elemById, storeyIds, HasProjectPhaseReference(cutter)); + && HostedFillingProductCanBeEmitted(cutter, elemById, storeyIds, ProductRules.HasProjectPhaseReference(cutter)); HostedOpeningGeometry writerOpening = usesExactCompleteOpenTenFaceQuarteredReveal ? targetedOpening @@ -1396,7 +1383,7 @@ element.SuperInstanceId is not > 0 // future Boolean operand. bool transformOnlyFamily = opening.Transform != null && opening.TypeId > 0 && opening.CategoryId is null or -2000080 or -2001000 or -2001100; - bool hasProjectPhaseReference = HasProjectPhaseReference(opening); + bool hasProjectPhaseReference = ProductRules.HasProjectPhaseReference(opening); bool standaloneDiagnostics = Environment.GetEnvironmentVariable( "RVTIO_RECT_BREP_GATE_DIAG") == "1"; bool RejectStandaloneWriterPath(string stage) @@ -1649,13 +1636,12 @@ bool HasCompetingExactHostedWriterAuthority( continue; } if (CurrentProfile && HasOnlyUnfilledFacesCurrent(elem)) { Skip(elem, "only-unfilled-faces"); continue; } - bool transformOnlyFamily = elem.Transform != null && elem.TypeId > 0 - && elem.CategoryId is null or -2000080 or -2001000 or -2001100; + bool transformOnlyFamily = ProductRules.IsTransformOnlyFamily(elem); // Current profile: a wall record that is itself the type of another element (it names no project phase) // is a type definition; the type is written from the instances that use it. The legacy profile keeps // wall records, as wall type propagation is not reliable enough there. if ((CurrentProfile || elem.CategoryId != -2000011) && typeElementIds.Contains(elem.Id) - && !HasProjectPhaseReference(elem) && !transformOnlyFamily) + && !ProductRules.HasProjectPhaseReference(elem) && !transformOnlyFamily) { Skip(elem, "type-element"); continue; } if (IsNonPhysicalCategory(elem.Category)) { Skip(elem, "non-physical-category"); continue; } @@ -1663,7 +1649,7 @@ bool HasCompetingExactHostedWriterAuthority( // Preview/type records inherit PHASE_CREATED during parameter propagation but do not // reference that phase in their own record graph. if (elem.CategoryId != -2000011 - && !HasProjectPhaseReference(elem) && !transformOnlyFamily) + && !ProductRules.HasProjectPhaseReference(elem) && !transformOnlyFamily) { Skip(elem, "no-project-phase-reference"); continue; } // A placed family instance must reference a project level when its serialized level @@ -63688,10 +63674,7 @@ ifcType is "IFCWALL" or "IFCWALLSTANDARDCASE" /// The wall export skips a StackedWall parent, not its source record or children. internal static bool IsValidatedStackedWallContainer( Element element, IReadOnlyDictionary elementsById) => - element.CategoryId == -2000011 && element.FamilyTypeId > 0 - && elementsById.TryGetValue(element.FamilyTypeId, out var type) - && type.Id == element.FamilyTypeId && type.CategoryId == -2003500 - && type.IsSerializedStackedWallType; + ProductRules.IsValidatedStackedWallContainer(element, elementsById); /// /// A positive Element.m_unplacedOwnerId is an authored ownership edge to another record, so the @@ -65131,8 +65114,7 @@ internal static HashSet ApplyDoorWindowOpeningIdentities( : null; } - private static bool IsWallRectangularOpening(Element element) => - element.Category?.Contains("OST_SWallRectOpening", StringComparison.OrdinalIgnoreCase) == true; + private static bool IsWallRectangularOpening(Element element) => ProductRules.IsWallRectangularOpening(element); // The door sub-element rule (= WindowOpeningRelVoid). internal const int DoorOpeningRelVoid = 20; @@ -68751,7 +68733,6 @@ private Dictionary WriteSpaces( private static readonly (string Name, int ParamId)[] SpaceCoveringParams = [("FloorCovering", -1006903), ("WallCovering", -1006904), ("CeilingCovering", -1006905)]; // ROOM_FINISH_FLOOR, _WALL, _CEILING private const int AreaSchemeIdParam = -1012704; // RoomElem.m_areaSchemeId - private const int PhaseCategoryId = -2000112; // OST_Phases /// The phase whose rooms the current profile exports. private int? ResolveActiveRoomPhase(RevitFileInfo info) @@ -68765,14 +68746,7 @@ private static readonly (string Name, int ParamId)[] SpaceCoveringParams = private int? ResolveActivePhase(RevitFileInfo info) { if (ExportPhaseId != null) return ExportPhaseId; - if (!CurrentProfile || ExportAllPhases || info.SerializedProjectPhaseIds is not { Length: > 0 } order) return null; - // a 64-bit-id file can have a phase whose category is not resolved, so the class counts too - var phases = info.Elements.Where(element => (element.CategoryId == PhaseCategoryId || element.RootClassName == "ProjectPhase") - && !(info.ElemTableIsExact && element.IsDiscovered)) - .Select(element => element.Id).ToHashSet(); - for (int index = order.Length - 1; index >= 0; index--) - if (phases.Contains(order[index])) return order[index]; - return null; + return CurrentProfile && !ExportAllPhases ? ProductRules.LastProjectPhase(info) : null; } private void WriteAreaSchemeGroups( @@ -69702,30 +69676,7 @@ List Groups(IEnumerable values) => values /// Returns true if the Category string indicates a non-physical element (annotation, link, /// symbol, etc). /// - private static bool IsNonPhysicalCategory(string? category) - { - if (string.IsNullOrEmpty(category)) return false; // null category handled by MapCategoryToIfcType - // Annotations, symbols, links, views, grids, etc. - return category.Contains("Annotation", StringComparison.OrdinalIgnoreCase) - || category.Contains("RvtLink", StringComparison.OrdinalIgnoreCase) - || category.Contains("Symbol", StringComparison.OrdinalIgnoreCase) - || category.Contains("Tag", StringComparison.OrdinalIgnoreCase) - || category.Contains("Schedule", StringComparison.OrdinalIgnoreCase) - || category.Contains("Legend", StringComparison.OrdinalIgnoreCase) - || category.Contains("Titleblock", StringComparison.OrdinalIgnoreCase) - || category.Contains("Detail", StringComparison.OrdinalIgnoreCase) - || category.Contains("Camera", StringComparison.OrdinalIgnoreCase) - || category.Contains("Analytical", StringComparison.OrdinalIgnoreCase) - // Spatial/compound sub-elements are represented by their parent IFC entity, not as - // independent building-element proxies. - || category.Contains("OST_Rooms", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_MEPSpaces", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_RailingTopRail", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_RailingHandRail", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_StairsTrisers", StringComparison.OrdinalIgnoreCase) - || category.Contains("OST_StairsRailingBaluster", StringComparison.OrdinalIgnoreCase) - || category.Equals("OST_Parts", StringComparison.OrdinalIgnoreCase); - } + private static bool IsNonPhysicalCategory(string? category) => ProductRules.IsNonPhysicalCategory(category); /// diff --git a/src/RvtIO/Meshes/ElementMeshOptions.cs b/src/RvtIO/Meshes/ElementMeshOptions.cs new file mode 100644 index 0000000..d4f90ad --- /dev/null +++ b/src/RvtIO/Meshes/ElementMeshOptions.cs @@ -0,0 +1,9 @@ +namespace RvtIO.Meshes; + +/// Options of . +public sealed class ElementMeshOptions +{ + /// Returns an element that is exactly its symbols placed as of shared symbol meshes + /// (true, the default). False gives every element its own triangles and leaves empty. + public bool UseSymbolPlacements { get; set; } = true; +} diff --git a/src/RvtIO/Meshes/ElementMeshes.cs b/src/RvtIO/Meshes/ElementMeshes.cs new file mode 100644 index 0000000..119fbd5 --- /dev/null +++ b/src/RvtIO/Meshes/ElementMeshes.cs @@ -0,0 +1,333 @@ +using RvtIO.Geometry.Meshing; +using RvtIO.Model; +using RvtIO.Parsing; + +namespace RvtIO.Meshes; + +/// +/// Triangle meshes of the drawable model elements of an opened document, in feet, in the coordinates of the document (Revit +/// internal coordinates, Z up). The document is not read again: the meshes are made from the decoded faces it holds. +/// +/// +/// An element is drawable when the IFC export of the current profile would consider it a product by these of its rules: +/// it is not a symbol or type definition (a record kept only for its geometry, an element that other elements name as their type +/// and that names no project phase), not a group template, not in a secondary design option, +/// not outside the last project phase (demolished or not yet created), not a reveal, not a rectangular wall opening (a void), not +/// the container of a stacked wall (its member walls are drawn), not a record outside an exact element table, not owned by a +/// family, not of a non-physical category (annotations, rooms, spaces, ...), names its project phase (walls and family instances +/// of a generic category excepted), and not a void helper whose faces are all unfilled. An element whose geometry is exactly +/// other drawable elements placed (a stair of its runs and landings, a model group of its members) is left out, as those +/// elements are listed themselves; a placement of a drawable element is likewise left out of an element's placements. A +/// drawable element is listed when it has a geometry record, or, in a document whose geometry was not decoded, a bounding box. +/// Other product rules of the IFC export are not applied, so the meshes can include elements that the IFC leaves out: the +/// "export to IFC" flag, topography, walls that are not visible, curtain panels that are walls, family instances whose level is +/// not a project level, curtain children without bounds, openings of hidden walls, and the rebar rules. +/// Faces of Geometry nodes that Revit does not take (another detail level) are left out when the element has faces of +/// nodes it takes and no ghost node (a node Revit exports that has no decoded polygon, which also makes the element +/// ). Planar faces with holes are triangulated in their plane; curved surfaces are the dense polygons the reader +/// decoded for them. Every triangle is counter-clockwise around the normal of its face. +/// The material of a face is its own material, else the material of the symbol face it was placed from, else the material +/// of the graphics style of its Geometry node, else none (0). Only elements of the Materials category count as materials. +/// +public static class ElementMeshes +{ + private const int MaterialsCategoryId = -2000700; + private const int RevealsCategoryId = -2000182; + private const int WallsCategoryId = -2000011; + private const double RigidTolerance = 1e-6; + + /// The meshes of the drawable elements of . + /// is null. + public static MeshSet Extract(RvtDocument document, ElementMeshOptions? options = null) + { + ArgumentNullException.ThrowIfNull(document); + options ??= new ElementMeshOptions(); + var info = document.RawData; + var drawable = DrawableElements(info); + var drawableIds = drawable.Select(item => item.Element.Id).ToHashSet(); + var symbolCache = new Dictionary(); + var placedSymbols = new Dictionary>(); + + bool IsMaterial(int id) => document.GetElement(id)?.CategoryId == MaterialsCategoryId; + int MaterialOf(GeoFace face) + { + if (face.RenderStyleId is > 0 and int own && IsMaterial(own)) return own; + if (face.NestedRenderStyleId is > 0 and int nested && IsMaterial(nested)) return nested; + if (face.GeometryStyle > 0 && info.GraphicsStyles?.TryGetValue(face.GeometryStyle, out var style) == true + && style.Material > 0 && IsMaterial(style.Material)) + return style.Material; + return 0; + } + + SymbolMeshes? SymbolOf(int symbolId) + { + if (symbolCache.TryGetValue(symbolId, out var cached)) return cached; + SymbolMeshes? symbol = null; + // The symbol element holds the symbol in the frame of its family; it is the placed symbol only when its own nested + // symbols were placed whole. + if (document.GetElement(symbolId)?.Geometry is { PlacedSymbolClipped: false, PlacedSymbolBox: false } geometry) + { + bool ghosts = geometry.GhostNodes is { Count: > 0 }; + var (meshes, failed) = Triangulate(geometry.Faces, MaterialOf, ghosts); + if (meshes.Count > 0) + symbol = new SymbolMeshes(meshes, failed, DropsUntakenNodes(geometry.Faces, ghosts), + geometry.Faces.Any(face => face.GeometryNode < 0), ghosts); + } + symbolCache[symbolId] = symbol; + return symbol; + } + + // The placements of an element whose geometry is exactly its symbols placed, or null. + (List Placements, int Failed, bool Ghosts)? PlacementsOf(GeometryData geometry) + { + if (geometry.ExactMappedInstances is not { Count: > 0 } mapped || geometry.PlacedSymbolClipped || geometry.PlacedSymbolBox) + return null; + bool dropsUntaken = DropsUntakenNodes(geometry.Faces, geometry.GhostNodes is { Count: > 0 }); + var placements = new List(mapped.Count); + int failed = 0; + bool ghosts = false; + foreach (var instance in mapped) + { + if (drawableIds.Contains(instance.SymbolId)) continue; // drawn as an element of its own + if (!ElementBuilder.IsRigidPlacement(instance.Transform)) return null; + var transform = MeshTransform.FromRowMajor(instance.Transform); + if (Math.Abs(Math.Abs(transform.Determinant) - 1) > RigidTolerance) return null; + if (SymbolOf(instance.SymbolId) is not { } symbol) return null; + // The hidden nodes of the symbol must be dropped in the element exactly when they are in the symbol alone. + if (symbol.HasHiddenNode && symbol.DropsUntakenNodes != dropsUntaken) return null; + placements.Add(new SymbolPlacement(instance.SymbolId, transform)); + failed += symbol.Failed; + ghosts |= symbol.HasGhostNodes; + } + return placements.Count > 0 ? (placements, failed, ghosts) : null; + } + + var elements = new List(); + foreach (var (element, body) in drawable) + { + if (body == null) + { + elements.Add(new ElementMesh { ElementId = element.Id, Coverage = MeshCoverage.Undecoded }); + continue; + } + if (options.UseSymbolPlacements && ReferenceEquals(body, element.Geometry) && PlacementsOf(body) is var (placements, failedInSymbols, symbolGhosts)) + { + foreach (var placement in placements) + placedSymbols.TryAdd(placement.SymbolId, symbolCache[placement.SymbolId]!.Meshes); + elements.Add(new ElementMesh + { + ElementId = element.Id, + Placements = placements, + Coverage = CoverageOf(true, body, failedInSymbols, symbolGhosts), + UntriangulatedFaces = failedInSymbols, + }); + continue; + } + var (meshes, failed) = Triangulate(body.Faces, MaterialOf, body.GhostNodes is { Count: > 0 }); + elements.Add(new ElementMesh + { + ElementId = element.Id, + Meshes = meshes, + Coverage = CoverageOf(meshes.Count > 0, body, failed, false), + UntriangulatedFaces = failed, + }); + } + return new MeshSet { Elements = elements, Symbols = placedSymbols, GeometryUndecoded = info.WideLayoutNotRewritten }; + } + + // A ghost node is a Geometry node Revit exports that has no polygon here (display surfaces, or a duplicate of another node). + private static MeshCoverage CoverageOf(bool drawn, GeometryData geometry, int failed, bool symbolGhosts) => + !drawn ? MeshCoverage.NoGeometry + : geometry.PlacedSymbolBox ? MeshCoverage.BoxSubstitute + : failed > 0 || geometry.UnresolvedSymbol || symbolGhosts || geometry.GhostNodes is { Count: > 0 } ? MeshCoverage.Partial + : MeshCoverage.Complete; + + private sealed record SymbolMeshes(IReadOnlyList Meshes, int Failed, bool DropsUntakenNodes, bool HasHiddenNode, + bool HasGhostNodes); + + /// Whether the faces of untaken Geometry nodes are left out: when a face of a taken node is there and the geometry has + /// no ghost node, the rule of the IFC export of the current profile. + private static bool DropsUntakenNodes(IReadOnlyList faces, bool hasGhostNodes) => + !hasGhostNodes && faces.Any(face => face.GeometryNode > 0); + + /// The drawable elements (see ) in document order, each with the geometry that is its body: + /// the geometry of its computed symbol for a void helper that has one, its own geometry otherwise (null in a document whose + /// geometry was not decoded). + internal static List<(Element Element, GeometryData? Body)> DrawableElements(RevitFileInfo info) + { + var designOptions = DesignOptions.Of(info.Elements); + var phaseFilter = ProductRules.BuildExportPhaseFilter(info, exportPhaseId: null, lastPhaseByDefault: true); + var typeElementIds = ProductRules.TypeElementIds(info.Elements); + var elementsById = new Dictionary(); + foreach (var element in info.Elements) elementsById.TryAdd(element.Id, element); + var drawable = new List<(Element, GeometryData?)>(); + foreach (var element in info.Elements) + { + if (element.IsBlockOnly || element.IsGroupTemplate + || designOptions.InNonPrimaryOption(element) == true || phaseFilter?.Excludes(element) == true + || element.CategoryId == RevealsCategoryId || ProductRules.IsWallRectangularOpening(element) + || ProductRules.IsValidatedStackedWallContainer(element, elementsById) + || info.ElemTableIsExact && element.IsDiscovered + || element.SerializedFamilyId is > 0 + || ProductRules.IsNonPhysicalCategory(element.Category)) + continue; + bool namesPhase = ProductRules.HasProjectPhaseReference(element) || ProductRules.IsTransformOnlyFamily(element); + if (!namesPhase && (typeElementIds.Contains(element.Id) || element.CategoryId != WallsCategoryId)) continue; + GeometryData? body = element.Geometry; + if (element.SerializedComputedSymbolGeometry is { } computed) body = computed; + else if (ProductRules.HasOnlyUnfilledFaces(element)) continue; + if (body != null || info.WideLayoutNotRewritten && element.BoundingBox != null) drawable.Add((element, body)); + } + // A stair places its runs and landings, a model group its members: those are drawn as elements of their own. + var ids = drawable.Select(item => item.Item1.Id).ToHashSet(); + return drawable.Where(item => item.Item2?.ExactMappedInstances is not { Count: > 0 } mapped + || !mapped.All(instance => ids.Contains(instance.SymbolId))).ToList(); + } + + /// The triangles of , one mesh per material in the order the materials first appear, and the + /// number of faces that could not be triangulated. The faces of untaken Geometry nodes (a negative node id) are left out when + /// a face of a taken node is there, unless the geometry has ghost nodes (). + internal static (List Meshes, int Failed) Triangulate(IReadOnlyList faces, Func materialOf, + bool hasGhostNodes = false) + { + bool dropUntakenNodes = DropsUntakenNodes(faces, hasGhostNodes); + var builders = new List(); + var builderOfMaterial = new Dictionary(); + int failed = 0; + foreach (var face in faces) + { + if (dropUntakenNodes && face.GeometryNode < 0) continue; + int material = materialOf(face); + if (!builderOfMaterial.TryGetValue(material, out var builder)) + { + builder = new MeshBuilder(material); + builderOfMaterial[material] = builder; + builders.Add(builder); + } + if (!AppendFace(face, builder)) failed++; + } + return (builders.Where(builder => builder.Indices.Count > 0).Select(builder => builder.ToMesh()).ToList(), failed); + } + + /// Appends the triangles of one polygon face with its holes; false when the face has no polygon, its triangles do not + /// cover its area, or (for a face with holes) they cover more than its area: a hole that is filled. A face of no area is drawn + /// as nothing and counts as triangulated. + internal static bool AppendFace(GeoFace face, MeshBuilder target) + { + if (face.OuterVertices is not { Count: >= 3 } outer) return false; + var rings = new List> { outer }; + if (face.InnerVertices != null) rings.AddRange(face.InnerVertices.Where(ring => ring is { Count: >= 3 })); + foreach (var ring in rings) + foreach (var point in ring) + if (point.Length < 3 || !double.IsFinite(point[0]) || !double.IsFinite(point[1]) || !double.IsFinite(point[2])) + return false; + + // The plane of the face: the Newell normal of its outer ring (relative to its first corner, so that large coordinates keep + // their precision), turned to the side of the face normal. + var origin = outer[0]; + double nx = 0, ny = 0, nz = 0; + for (int index = 0; index < outer.Count; index++) + { + var a = outer[index]; + var b = outer[(index + 1) % outer.Count]; + double ay = a[1] - origin[1], az = a[2] - origin[2], ax = a[0] - origin[0]; + double by = b[1] - origin[1], bz = b[2] - origin[2], bx = b[0] - origin[0]; + nx += (ay - by) * (az + bz); + ny += (az - bz) * (ax + bx); + nz += (ax - bx) * (ay + by); + } + double length = Math.Sqrt(nx * nx + ny * ny + nz * nz); + if (!(length > 0)) return true; + nx /= length; ny /= length; nz /= length; + if (face.Normal is { Length: >= 3 } given && nx * given[0] + ny * given[1] + nz * given[2] < 0) + { + nx = -nx; ny = -ny; nz = -nz; + } + + // In-plane axes u, v with u x v = n. + double hx = 0, hy = 0, hz = 0; + if (Math.Abs(nx) <= Math.Abs(ny) && Math.Abs(nx) <= Math.Abs(nz)) hx = 1; + else if (Math.Abs(ny) <= Math.Abs(nz)) hy = 1; + else hz = 1; + double ux = hy * nz - hz * ny, uy = hz * nx - hx * nz, uz = hx * ny - hy * nx; + double uLength = Math.Sqrt(ux * ux + uy * uy + uz * uz); + ux /= uLength; uy /= uLength; uz /= uLength; + double vx = ny * uz - nz * uy, vy = nz * ux - nx * uz, vz = nx * uy - ny * ux; + + var points = new List<(double X, double Y)>(); + var loops = new List(rings.Count); + var outerFlags = new bool[rings.Count]; + double expectedArea = 0; + for (int ringIndex = 0; ringIndex < rings.Count; ringIndex++) + { + var ring = rings[ringIndex]; + var loop = new int[ring.Count]; + var projected = new (double X, double Y)[ring.Count]; + for (int index = 0; index < ring.Count; index++) + { + double dx = ring[index][0] - origin[0], dy = ring[index][1] - origin[1], dz = ring[index][2] - origin[2]; + projected[index] = (dx * ux + dy * uy + dz * uz, dx * vx + dy * vy + dz * vz); + loop[index] = points.Count; + points.Add(projected[index]); + } + loops.Add(loop); + outerFlags[ringIndex] = ringIndex == 0; + double ringArea = Math.Abs(GeTess.SignedArea(projected)); + expectedArea += ringIndex == 0 ? ringArea : -ringArea; + } + double outerArea = Math.Abs(GeTess.SignedArea(loops[0].Select(index => points[index]).ToList())); + + var triangles = GeTess.TriangulateProfile(points, loops, outerFlags); + double coveredArea = 0; + if (triangles.Count > 0) + { + int first = target.VertexCount; + foreach (var ring in rings) + foreach (var point in ring) + target.AddVertex(point, nx, ny, nz); + foreach (var (a, b, c) in triangles) + { + var (pa, pb, pc) = (points[a], points[b], points[c]); + double cross = (pb.X - pa.X) * (pc.Y - pa.Y) - (pb.Y - pa.Y) * (pc.X - pa.X); + coveredArea += Math.Abs(cross) / 2; + // Counter-clockwise in (u, v) is counter-clockwise around n. + if (cross < 0) target.AddTriangle(first + a, first + c, first + b); + else target.AddTriangle(first + a, first + b, first + c); + } + } + // Without holes, more area than the outline is a fold of a polygon that is not quite planar (the dense polygons of a curved + // surface), which still covers the face. + double tolerance = 1e-6 * outerArea + 1e-12; + return coveredArea >= expectedArea - tolerance && (rings.Count == 1 || coveredArea <= expectedArea + tolerance); + } + + /// The vertices and triangles of one material. + internal sealed class MeshBuilder(int materialId) + { + private readonly List _positions = []; + private readonly List _normals = []; + + public List Indices { get; } = []; + + public int VertexCount => _positions.Count / 3; + + public void AddVertex(double[] point, double nx, double ny, double nz) + { + _positions.Add(point[0]); _positions.Add(point[1]); _positions.Add(point[2]); + _normals.Add(nx); _normals.Add(ny); _normals.Add(nz); + } + + public void AddTriangle(int a, int b, int c) + { + Indices.Add(a); Indices.Add(b); Indices.Add(c); + } + + public TriangleMesh ToMesh() => new() + { + Positions = _positions.ToArray(), + Indices = Indices.ToArray(), + Normals = _normals.ToArray(), + MaterialId = materialId, + }; + } +} diff --git a/src/RvtIO/Meshes/MeshSet.cs b/src/RvtIO/Meshes/MeshSet.cs new file mode 100644 index 0000000..7cf83c7 --- /dev/null +++ b/src/RvtIO/Meshes/MeshSet.cs @@ -0,0 +1,128 @@ +namespace RvtIO.Meshes; + +/// The triangle meshes of the drawable model elements of a document (see ). All +/// coordinates are in feet. +public sealed class MeshSet +{ + /// One entry per drawable element, in the order of . + public IReadOnlyList Elements { get; init; } = []; + + /// The triangles of every symbol that an element places (), by symbol element id, + /// in the frame of the family. Only placed symbols are listed. + public IReadOnlyDictionary> Symbols { get; init; } = new Dictionary>(); + + /// The document was read without decoding its geometry: a file read by its own class schema (a release other than Revit + /// 2022, such as the Revit 2020 and 2025 samples) opened without . Its elements are listed + /// with . + public bool GeometryUndecoded { get; init; } +} + +/// The geometry of one element: its own triangles in the coordinates of the document, plus symbols placed whole. +public sealed class ElementMesh +{ + /// The id of the element. + public int ElementId { get; init; } + + /// The triangles of the element itself, one mesh per material, in the coordinates of the document. + public IReadOnlyList Meshes { get; init; } = []; + + /// Symbols () that make up the rest of the element, each drawn by its transform. A placement + /// is given only when the element's geometry is exactly its symbols placed rigidly; any other element has its own triangles. + public IReadOnlyList Placements { get; init; } = []; + + /// How completely and represent the element. + public MeshCoverage Coverage { get; init; } + + /// Faces of the element (or of its placed symbols, once per placement) that could not be triangulated. + public int UntriangulatedFaces { get; init; } +} + +/// How completely an represents its element. When several apply, the first in the order +/// , , , is reported. +public enum MeshCoverage +{ + /// Every decoded face is triangulated. + Complete, + + /// Some faces could not be triangulated, a symbol the element places (or a symbol nested in one) has no geometry, or a + /// Geometry node that Revit exports has no decoded polygon (display surfaces, or a duplicate of another node), so it may be + /// missing. + Partial, + + /// A symbol placed at a rescaled placement was replaced by its bounding box. + BoxSubstitute, + + /// The element has a geometry record but nothing in it could be drawn. + NoGeometry, + + /// The geometry of the document was not decoded (). + Undecoded, +} + +/// Triangles that share one material. +public sealed class TriangleMesh +{ + /// Vertex coordinates x, y, z in feet, three values per vertex. + public double[] Positions { get; init; } = []; + + /// Three vertex indices per triangle, counter-clockwise seen from the side the normals point to. + public int[] Indices { get; init; } = []; + + /// Unit normal x, y, z per vertex. Vertices are not shared between faces, so a face keeps its own normal and the + /// edges between faces stay sharp. + public double[] Normals { get; init; } = []; + + /// The material element id of the triangles, 0 for none. + public int MaterialId { get; init; } +} + +/// A symbol drawn by a transform. +/// The key of the symbol in . +/// The transform from the frame of the family to the coordinates of the document. +public readonly record struct SymbolPlacement(int SymbolId, MeshTransform Transform); + +/// A point or a direction in feet. +/// The X coordinate, in feet. +/// The Y coordinate, in feet. +/// The Z coordinate (up), in feet. +public readonly record struct MeshVector(double X, double Y, double Z); + +/// +/// A transform from the frame of a family to the coordinates of the document: a point p maps to +/// Origin + BasisX * p.X + BasisY * p.Y + BasisZ * p.Z. The basis vectors are the images of the family axes. +/// A negative is a mirror image: a renderer that culls back faces reverses the winding of the +/// symbol's triangles for it. +/// +/// A t holds the 3x3 matrix row by row and then the translation, so the +/// basis vectors are its columns: BasisX = (t[0], t[3], t[6]), BasisY = (t[1], t[4], t[7]), BasisZ = (t[2], t[5], t[8]) and +/// Origin = (t[9], t[10], t[11]) (see ). +/// The image of the family origin, in feet: (t[9], t[10], t[11]). +/// The image of the family X axis: the column (t[0], t[3], t[6]). +/// The image of the family Y axis: the column (t[1], t[4], t[7]). +/// The image of the family Z axis: the column (t[2], t[5], t[8]). +public readonly record struct MeshTransform(MeshVector Origin, MeshVector BasisX, MeshVector BasisY, MeshVector BasisZ) +{ + /// The transform of the 12 values of a . + /// Fewer than 12 values. + public static MeshTransform FromRowMajor(IReadOnlyList transform) + { + ArgumentNullException.ThrowIfNull(transform); + if (transform.Count < 12) throw new ArgumentException("A transform has 12 values.", nameof(transform)); + return new(new(transform[9], transform[10], transform[11]), + new(transform[0], transform[3], transform[6]), + new(transform[1], transform[4], transform[7]), + new(transform[2], transform[5], transform[8])); + } + + /// The determinant of the basis: 1 for a rotation, -1 for a mirror image. + public double Determinant => + BasisX.X * (BasisY.Y * BasisZ.Z - BasisZ.Y * BasisY.Z) + - BasisY.X * (BasisX.Y * BasisZ.Z - BasisZ.Y * BasisX.Z) + + BasisZ.X * (BasisX.Y * BasisY.Z - BasisY.Y * BasisX.Z); + + /// The image of a point of the family frame. + public MeshVector Apply(MeshVector point) => new( + Origin.X + BasisX.X * point.X + BasisY.X * point.Y + BasisZ.X * point.Z, + Origin.Y + BasisX.Y * point.X + BasisY.Y * point.Y + BasisZ.Y * point.Z, + Origin.Z + BasisX.Z * point.X + BasisY.Z * point.Y + BasisZ.Z * point.Z); +} diff --git a/src/RvtIO/Model/Element.cs b/src/RvtIO/Model/Element.cs index ca7689e..82a7ac6 100644 --- a/src/RvtIO/Model/Element.cs +++ b/src/RvtIO/Model/Element.cs @@ -983,7 +983,7 @@ internal bool RootClassIs(string className) => NativeRootClassName != null internal int? SerializedCurtainHostId { get; set; } /// The geometry of an instance whose own Faces are all unfilled (a void or cut helper, see - /// IfcExporter.HasOnlyUnfilledFacesCurrent) while its computed symbol has filled ones: the symbol's geometry placed by the + /// ) while its computed symbol has filled ones: the symbol's geometry placed by the /// instance's own transform, with that symbol as its one mapped instance. The current profile exports such an instance with the body of the /// symbol (for example louvers whose own geometry is the box they fill). Null for any other element. [JsonIgnore] diff --git a/src/RvtIO/Ifc/ExportPhaseFilter.cs b/src/RvtIO/Model/ExportPhaseFilter.cs similarity index 98% rename from src/RvtIO/Ifc/ExportPhaseFilter.cs rename to src/RvtIO/Model/ExportPhaseFilter.cs index 5cafe02..68496ea 100644 --- a/src/RvtIO/Ifc/ExportPhaseFilter.cs +++ b/src/RvtIO/Model/ExportPhaseFilter.cs @@ -1,6 +1,4 @@ -using RvtIO.Model; - -namespace RvtIO.Ifc; +namespace RvtIO.Model; /// /// Which elements exist in the export phase: the phases are taken in the order of the project's phase list diff --git a/src/RvtIO/Model/Geometry.cs b/src/RvtIO/Model/Geometry.cs index 71bafdb..6acdf68 100644 --- a/src/RvtIO/Model/Geometry.cs +++ b/src/RvtIO/Model/Geometry.cs @@ -64,6 +64,17 @@ internal bool HasCurvedFace(IReadOnlyCollection? nodes = null) => MeshNodes /// representation-map structure instead of duplicating the expanded mesh. internal List? ExactMappedInstances { get; set; } + /// A symbol placed into this geometry was cut by the tight box of the owning GRep, or left out because it lay + /// outside it (ElementBuilder.ClipGeometryToBounds): the faces are not the placed symbols as they are. + internal bool PlacedSymbolClipped { get; set; } + + /// A component placed at a rescaled placement was replaced by its tight box (ElementBuilder.ScaledComponentFaceBudget). + internal bool PlacedSymbolBox { get; set; } + + /// A GInstance of this geometry, or of a symbol it places at any depth, placed nothing: its symbol has no + /// geometry. + internal bool UnresolvedSymbol { get; set; } + /// Number of stored GFace and Edge objects in the GRep block this geometry was /// built from (null when the block's object graph was not walked). Such node counts of a family instance's /// geometry are compared with its symbol's to decide whether the @@ -89,7 +100,9 @@ public class GeoInstance { /// The id of the symbol that is placed. public int SymbolId { get; set; } - /// The 12 values of the placement transform. + /// The 12 values of the placement transform: the 3x3 matrix row by row, then the translation, so a point p of the + /// symbol maps to (t[9] + p.X t[0] + p.Y t[1] + p.Z t[2], t[10] + p.X t[3] + ..., t[11] + p.X t[6] + ...) and the basis vectors are the + /// columns (see ). public double[] Transform { get; set; } = new double[12]; /// Where the GInstance stands among the nodes of its GRep in the depth-first walk (internal order key). @@ -188,7 +201,7 @@ public class GeoFace internal int GeometryStyle { get; set; } = -1; /// GInfo flags of the serialized Face this polygon came from; null for a face placed from a nested symbol - /// or not read (see IfcExporter.HasOnlyUnfilledFacesCurrent). + /// or not read (see ). internal uint? GInfoFlags { get; set; } /// The material of a face that was placed here from a nested symbol (GInstance); RenderStyleId is diff --git a/src/RvtIO/Model/ProductRules.cs b/src/RvtIO/Model/ProductRules.cs new file mode 100644 index 0000000..09b2cdb --- /dev/null +++ b/src/RvtIO/Model/ProductRules.cs @@ -0,0 +1,120 @@ +namespace RvtIO.Model; + +/// +/// The rules that decide whether a decoded element is a placed product of the model (and not a definition, a void, an annotation or +/// a record of another phase). The IFC export and apply them. +/// +internal static class ProductRules +{ + private const int PhaseCategoryId = -2000112; // OST_Phases + + /// Bit 0x200 of the GInfo flags of a serialized Face. Every element has at least one + /// Face with it, except those whose Faces are all display (0x00200000) or auxiliary (0x04000000) surfaces, which never + /// reach the body. + internal const uint FaceFillFlag = 0x00000200; + + /// Whether every decoded Face of the element lacks . The current IFC profile writes no such instance: + /// an instance whose own geometry is null is not exported (for example the floor-hosted family + /// "Assignment - Rectangular opening (floor)"). Faces whose flags are not known (placed from a nested symbol) count as filled. + internal static bool HasOnlyUnfilledFaces(Element element) => + element.Geometry?.Faces is { Count: > 0 } faces + && faces.All(face => face.GInfoFlags is uint flags && (flags & FaceFillFlag) == 0); + + /// Whether the element names its created phase (PHASE_CREATED) in its own record graph. Preview and type records + /// inherit the parameter during parameter propagation without referencing that phase. + internal static bool HasProjectPhaseReference(Element e) + { + int? phase = e.Parameters.FirstOrDefault(p => p.Id == -1012100)?.Value is int n ? n : null; // PHASE_CREATED + // Id 0 is a phase of Revit 2023 and later (the first project phase, a row of its + // element table); the earlier layouts number phases from 1. + return phase is > 0 && (e.References?.Contains(phase.Value) == true || e.SerializedCreatedPhaseId == phase.Value) + || phase == 0 && e.SerializedCreatedPhaseId == 0; + } + + /// The elements that other elements name as their type (ELEM_TYPE_PARAM, ELEM_FAMILY_AND_TYPE_PARAM). + internal static HashSet TypeElementIds(IEnumerable elements) + { + var typeElementIds = new HashSet(); + foreach (var e in elements) + foreach (var p in e.Parameters) + if (p.Id is -1002050 or -1002052 && p.Value is int typeId && typeId > 0) + typeElementIds.Add(typeId); + return typeElementIds; + } + + /// A family instance placed by its FamilyInstance transform whose category is unknown or generic. + internal static bool IsTransformOnlyFamily(Element elem) => + elem.Transform != null && elem.TypeId > 0 && elem.CategoryId is null or -2000080 or -2001000 or -2001100; + + /// + /// Returns true if the Category string indicates a non-physical element (annotation, link, + /// symbol, etc). + /// + internal static bool IsNonPhysicalCategory(string? category) + { + if (string.IsNullOrEmpty(category)) return false; // null category handled by the IFC class mapping + // Annotations, symbols, links, views, grids, etc. + return category.Contains("Annotation", StringComparison.OrdinalIgnoreCase) + || category.Contains("RvtLink", StringComparison.OrdinalIgnoreCase) + || category.Contains("Symbol", StringComparison.OrdinalIgnoreCase) + || category.Contains("Tag", StringComparison.OrdinalIgnoreCase) + || category.Contains("Schedule", StringComparison.OrdinalIgnoreCase) + || category.Contains("Legend", StringComparison.OrdinalIgnoreCase) + || category.Contains("Titleblock", StringComparison.OrdinalIgnoreCase) + || category.Contains("Detail", StringComparison.OrdinalIgnoreCase) + || category.Contains("Camera", StringComparison.OrdinalIgnoreCase) + || category.Contains("Analytical", StringComparison.OrdinalIgnoreCase) + // Spatial/compound sub-elements are represented by their parent IFC entity, not as + // independent building-element proxies. + || category.Contains("OST_Rooms", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_MEPSpaces", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_RailingTopRail", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_RailingHandRail", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_StairsTrisers", StringComparison.OrdinalIgnoreCase) + || category.Contains("OST_StairsRailingBaluster", StringComparison.OrdinalIgnoreCase) + || category.Equals("OST_Parts", StringComparison.OrdinalIgnoreCase); + } + + /// A rectangular wall opening: a void, not a product of its own. + internal static bool IsWallRectangularOpening(Element element) => + element.Category?.Contains("OST_SWallRectOpening", StringComparison.OrdinalIgnoreCase) == true; + + /// A StackedWall parent, whose member walls are the products; not its source record or children. + internal static bool IsValidatedStackedWallContainer( + Element element, IReadOnlyDictionary elementsById) => + element.CategoryId == -2000011 && element.FamilyTypeId > 0 + && elementsById.TryGetValue(element.FamilyTypeId, out var type) + && type.Id == element.FamilyTypeId && type.CategoryId == -2003500 + && type.IsSerializedStackedWallType; + + /// The last project phase of the authored phase order that is a phase element of the document, null when there is none. + internal static int? LastProjectPhase(RevitFileInfo info) + { + if (info.SerializedProjectPhaseIds is not { Length: > 0 } order) return null; + // a 64-bit-id file can have a phase whose category is not resolved, so the class counts too + var phases = info.Elements.Where(element => (element.CategoryId == PhaseCategoryId || element.RootClassName == "ProjectPhase") + && !(info.ElemTableIsExact && element.IsDiscovered)) + .Select(element => element.Id).ToHashSet(); + for (int index = order.Length - 1; index >= 0; index--) + if (phases.Contains(order[index])) return order[index]; + return null; + } + + /// The filter of the elements that do not exist in the export phase, null when there is none to apply. The export phase is + /// , else, with , the last project phase. With a known phase + /// order it is the general rule (). Without it, the default export phase (the last one, no explicit + /// ) still needs only the set of phases: an element demolished in any phase of the project is + /// demolished at or before the last one. That set is the ProjectPhase elements of the document; a document without any is not + /// filtered. + internal static ExportPhaseFilter? BuildExportPhaseFilter(RevitFileInfo info, int? exportPhaseId, bool lastPhaseByDefault) + { + if (info.SerializedProjectPhaseIds is { } order) + return (exportPhaseId ?? (lastPhaseByDefault ? LastProjectPhase(info) : null)) is int phase + ? ExportPhaseFilter.For(order, phase) : null; + if (exportPhaseId != null || !lastPhaseByDefault) return null; + var phases = info.Elements.Where(element => element.RootClassName == "ProjectPhase" + && !(info.ElemTableIsExact && element.IsDiscovered)) + .Select(element => element.Id).ToArray(); + return phases.Length == 0 ? null : ExportPhaseFilter.OfLastPhase(phases); + } +} diff --git a/src/RvtIO/Model/RevitFileInfo.cs b/src/RvtIO/Model/RevitFileInfo.cs index 6c15ab0..78a21c7 100644 --- a/src/RvtIO/Model/RevitFileInfo.cs +++ b/src/RvtIO/Model/RevitFileInfo.cs @@ -33,6 +33,11 @@ public class RevitFileInfo [System.Text.Json.Serialization.JsonIgnore] internal bool GeometryUndecoded { get; set; } + /// The file is of the 64-bit-id layout and was read without , so no element + /// geometry was decoded. ( stays true for such a file read with the rewrite.) + [System.Text.Json.Serialization.JsonIgnore] + internal bool WideLayoutNotRewritten { get; set; } + /// GStyleElem id -> the ElementId of its GStyle material (-1 or 0 stand for no material) and the /// ElementId of its category, for the graphics styles whose block decoded exactly once. The material /// of a solid whose faces carry none comes from the style of the solid, diff --git a/src/RvtIO/Parsing/ElementBuilder.InstanceQuantities.cs b/src/RvtIO/Parsing/ElementBuilder.InstanceQuantities.cs index 48f2117..3ae14d0 100644 --- a/src/RvtIO/Parsing/ElementBuilder.InstanceQuantities.cs +++ b/src/RvtIO/Parsing/ElementBuilder.InstanceQuantities.cs @@ -42,7 +42,7 @@ internal static void ApplyInstanceQuantities(RevitFileInfo info, IReadOnlyDictio // A family symbol holds solids too but has no Volume, and a void form (all faces // unfilled) is not a body. if (e.NativeMasterSymbolId != null && (!classByElement.TryGetValue((uint)e.Id, out var rootClass) || rootClass != FamilyInstanceRootClassId)) continue; - if (Ifc.IfcExporter.HasOnlyUnfilledFacesCurrent(e)) continue; + if (ProductRules.HasOnlyUnfilledFaces(e)) continue; if (category == -2001320 && e.FramingCopingDistance is not < 0) continue; int level = Array.FindIndex(e.LevelSolidCounts, count => count > 0); if (level < 0 || e.LevelShells[level] is not { } shell) continue; diff --git a/src/RvtIO/Parsing/ElementBuilder.cs b/src/RvtIO/Parsing/ElementBuilder.cs index e45e566..7ee6d0f 100644 --- a/src/RvtIO/Parsing/ElementBuilder.cs +++ b/src/RvtIO/Parsing/ElementBuilder.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using RvtIO.Internal; using RvtIO.Lookup; using RvtIO.Model; @@ -17,12 +18,24 @@ private static void ExpandInstanceGeometry(Element elem, GRepData grep, int type elem.Geometry ??= new GeometryData(); int ownFaceCount = elem.Geometry.Faces.Count; var expandedTopLevel = new List(); + var placedBox = new StrongBox(); + var unresolvedNested = new StrongBox(); foreach (var instance in grep.Instances) { var placed = BuildPlacedSymbolGeometry( - instance.SymbolElemId, instance.Transform, state, new HashSet()); - if (placed == null) { LogUnresolvedSymbol(elem, instance.SymbolElemId, "no-geometry", state); continue; } - if (!state.IncludeNonVisibleQuantities) placed = ClipGeometryToBounds(placed, grep.BoundingBox); + instance.SymbolElemId, instance.Transform, state, new HashSet(), placedBox: placedBox, + unresolvedNested: unresolvedNested); + if (placed == null) + { + elem.Geometry.UnresolvedSymbol = true; + LogUnresolvedSymbol(elem, instance.SymbolElemId, "no-geometry", state); + continue; + } + if (!state.IncludeNonVisibleQuantities) + { + placed = ClipGeometryToBounds(placed, grep.BoundingBox, out bool clipped); + if (clipped) elem.Geometry.PlacedSymbolClipped = true; + } if (placed == null) { LogUnresolvedSymbol(elem, instance.SymbolElemId, "clipped-away", state); continue; } AppendGeometry(elem.Geometry, placed, instance.NodeOrder); expandedTopLevel.Add(new GeoInstance @@ -32,6 +45,8 @@ private static void ExpandInstanceGeometry(Element elem, GRepData grep, int type }); } DeduplicateExactFaces(elem.Geometry); + if (placedBox.Value) elem.Geometry.PlacedSymbolBox = true; + if (unresolvedNested.Value) elem.Geometry.UnresolvedSymbol = true; if (ownFaceCount == 0 && expandedTopLevel.Count > 0) elem.Geometry.ExactMappedInstances = expandedTopLevel; } @@ -46,11 +61,21 @@ private static void ExpandTransformOnlyInstanceGeometry( if ((elem.Geometry?.Faces?.Count ?? 0) >= 3) return; if (!state.FamilyInstanceTransforms.TryGetValue(elemId, out var fi)) return; if (fi.SymbolId <= 0 || fi.SymbolId == elem.Id) return; - var placed = BuildPlacedSymbolGeometry(fi.SymbolId, fi.Trf, state, new HashSet()); - if (placed == null) { LogUnresolvedSymbol(elem, fi.SymbolId, "transform-only", state); return; } + var placedBox = new StrongBox(); + var unresolvedNested = new StrongBox(); + var placed = BuildPlacedSymbolGeometry(fi.SymbolId, fi.Trf, state, new HashSet(), placedBox: placedBox, + unresolvedNested: unresolvedNested); + if (placed == null) + { + if (elem.Geometry != null) elem.Geometry.UnresolvedSymbol = true; + LogUnresolvedSymbol(elem, fi.SymbolId, "transform-only", state); + return; + } elem.Geometry ??= new GeometryData(); bool hadOwnFaces = elem.Geometry.Faces.Count > 0; AppendGeometry(elem.Geometry, placed); + if (placedBox.Value) elem.Geometry.PlacedSymbolBox = true; + if (unresolvedNested.Value) elem.Geometry.UnresolvedSymbol = true; elem.Geometry.Instances ??= new List(); elem.Geometry.Instances.Add(new GeoInstance { @@ -76,17 +101,20 @@ private static void ExpandTransformOnlyInstanceGeometry( internal static void AttachComputedSymbolGeometry(Element elem, uint elemId, ParsingState state) { elem.SerializedComputedSymbolGeometry = null; - if (!Ifc.IfcExporter.HasOnlyUnfilledFacesCurrent(elem) + if (!ProductRules.HasOnlyUnfilledFaces(elem) || !state.ComputedFamilySymbols.TryGetValue(elemId, out var own) || own.BlockCount != 1 || own.Symbol is not { SymbolId: > 0, Transform: { Length: 12 } transform } symbol || symbol.SymbolId == elem.Id || !state.ComputedSymbolTargetClasses.TryGetValue((uint)symbol.SymbolId, out var target) || target is not { BlockCount: 1, ClassId: 1829 } || !state.GrepBlocks.TryGetValue((uint)symbol.SymbolId, out var symbolGrep) - || !symbolGrep.Faces.Any(face => face.GeometryNode > 0 && face.GInfoFlags is uint flags && (flags & Ifc.IfcExporter.FaceFillFlag) != 0)) + || !symbolGrep.Faces.Any(face => face.GeometryNode > 0 && face.GInfoFlags is uint flags && (flags & ProductRules.FaceFillFlag) != 0)) return; - var placed = BuildPlacedSymbolGeometry(symbol.SymbolId, transform, state, new HashSet()); + var unresolvedNested = new StrongBox(); + var placed = BuildPlacedSymbolGeometry(symbol.SymbolId, transform, state, new HashSet(), + unresolvedNested: unresolvedNested); if (placed?.Faces is not { Count: > 0 }) return; + placed.UnresolvedSymbol = unresolvedNested.Value; placed.ExactMappedInstances = [new GeoInstance { SymbolId = symbol.SymbolId, Transform = (double[])transform.Clone() }]; // The placed symbol carries the tight box of the instance's decoded geometry; the header // outline on Element.BoundingBox is the API envelope. @@ -194,9 +222,12 @@ private static double[] TransformGeometryPoint(double[] point, double[] t) => t[11] + point[0] * t[6] + point[1] * t[7] + point[2] * t[8], ]; + /// Set to true when this symbol or a nested one is replaced by its tight box. + /// Set to true when a symbol nested in this one, at any depth, placed nothing. internal static GeometryData? BuildPlacedSymbolGeometry( int symbolId, double[] transform, ParsingState state, HashSet path, - int scaledComponentFaceBudget = ScaledComponentFaceBudget) + int scaledComponentFaceBudget = ScaledComponentFaceBudget, StrongBox? placedBox = null, + StrongBox? unresolvedNested = null) { if (symbolId <= 0 || transform.Length < 12 || !path.Add(symbolId)) return null; try @@ -214,6 +245,7 @@ static int MostFrequent(IEnumerable values) => values.GroupBy(value => valu .Select(group => group.Key).FirstOrDefault(-1); int material = MostFrequent(symbol.Faces.Select(face => face.RenderStyleId ?? 0).Where(id => id > 0)); int style = MostFrequent(symbol.Faces.Select(face => face.GeometryStyle).Where(id => id > 0)); + if (placedBox != null) placedBox.Value = true; return BuildPlacedBox(symbol.BoundingBox, transform, material > 0 ? material : null, style); } var result = new GeometryData(); @@ -230,8 +262,9 @@ static int MostFrequent(IEnumerable values) => values.GroupBy(value => valu { var childTransform = ComposeTransforms(transform, child.Transform); var placedChild = BuildPlacedSymbolGeometry( - child.SymbolElemId, childTransform, state, path, scaledComponentFaceBudget); + child.SymbolElemId, childTransform, state, path, scaledComponentFaceBudget, placedBox, unresolvedNested); if (placedChild != null) AppendGeometry(result, placedChild, child.NodeOrder); + else if (unresolvedNested != null) unresolvedNested.Value = true; } if (result.Faces.Count == 0) return null; @@ -353,7 +386,7 @@ private static double[] ComposeTransforms(double[] o, double[] i) /// A placement that keeps lengths and angles: orthonormal rows (a rotation, or a mirror image /// when the determinant is negative). /// - private static bool IsRigidPlacement(double[] t) + internal static bool IsRigidPlacement(double[] t) { if (t.Length < 12 || t.Any(v => !double.IsFinite(v))) return false; for (int a = 0; a < 3; a++) @@ -466,8 +499,14 @@ double[] Map(double[] p) /// symbol overrides can shorten the type geometry; using the declared box prevents the /// unmodified symbol from leaking past the occurrence. /// - internal static GeometryData? ClipGeometryToBounds(GeometryData source, double[]? bounds) + internal static GeometryData? ClipGeometryToBounds(GeometryData source, double[]? bounds) => + ClipGeometryToBounds(source, bounds, out _); + + /// True when the result is not the faces of as they are: a face was cut + /// or left out, or nothing is left (null). + internal static GeometryData? ClipGeometryToBounds(GeometryData source, double[]? bounds, out bool changed) { + changed = false; if (bounds is not { Length: 6 } || bounds.Any(value => !double.IsFinite(value)) || bounds[0] > bounds[3] || bounds[1] > bounds[4] || bounds[2] > bounds[5]) @@ -545,15 +584,16 @@ double[] Intersect(double[] a, double[] b) for (int oldIndex = 0; oldIndex < source.Faces.Count; oldIndex++) { var face = source.Faces[oldIndex]; - if (face.OuterVertices is not { Count: >= 3 } outer) continue; - bool changed = false; + if (face.OuterVertices is not { Count: >= 3 } outer) { changed = true; continue; } + bool faceChanged = false; var polygon = outer.Select(point => (double[])point.Clone()).ToList(); - polygon = ClipPlane(polygon, 0, bounds[0], keepGreater: true, ref changed); - polygon = ClipPlane(polygon, 0, bounds[3], keepGreater: false, ref changed); - polygon = ClipPlane(polygon, 1, bounds[1], keepGreater: true, ref changed); - polygon = ClipPlane(polygon, 1, bounds[4], keepGreater: false, ref changed); - polygon = ClipPlane(polygon, 2, bounds[2], keepGreater: true, ref changed); - polygon = ClipPlane(polygon, 2, bounds[5], keepGreater: false, ref changed); + polygon = ClipPlane(polygon, 0, bounds[0], keepGreater: true, ref faceChanged); + polygon = ClipPlane(polygon, 0, bounds[3], keepGreater: false, ref faceChanged); + polygon = ClipPlane(polygon, 1, bounds[1], keepGreater: true, ref faceChanged); + polygon = ClipPlane(polygon, 1, bounds[4], keepGreater: false, ref faceChanged); + polygon = ClipPlane(polygon, 2, bounds[2], keepGreater: true, ref faceChanged); + polygon = ClipPlane(polygon, 2, bounds[5], keepGreater: false, ref faceChanged); + if (faceChanged) changed = true; if (polygon.Count < 3) continue; int newIndex = result.Faces.Count; @@ -575,9 +615,9 @@ double[] Intersect(double[] a, double[] b) MeshMarker = face.SourceFace != 0 ? face.SourceFace : face.MeshMarker, }); oldToNew[oldIndex] = newIndex; - if (changed) clippedFaces.Add(oldIndex); + if (faceChanged) clippedFaces.Add(oldIndex); } - if (result.Faces.Count == 0) return null; + if (result.Faces.Count == 0) { changed = true; return null; } // the integrals of a node are those of the whole solid: a node with a clipped or dropped face has none if (source.NodeMeasures is { Count: > 0 } measures) { diff --git a/src/RvtIO/RvtFileReader.cs b/src/RvtIO/RvtFileReader.cs index 098ae47..13dc304 100644 --- a/src/RvtIO/RvtFileReader.cs +++ b/src/RvtIO/RvtFileReader.cs @@ -602,6 +602,7 @@ public RevitFileInfo Read(string rvtPath) { ElemTableIsExact = elemTableIsExact, GeometryUndecoded = state.WideSchema != null, + WideLayoutNotRewritten = state.WideSchema != null && !state.R22View, Metadata = new FileMetadata { StreamVersion = fileMetadata.GetValueOrDefault("StreamVersion"), @@ -885,6 +886,7 @@ internal static void MergeElemScanStringsInChunkOrder( { ElemTableIsExact = elemTableIsExact, GeometryUndecoded = state.WideSchema != null, + WideLayoutNotRewritten = state.WideSchema != null && !state.R22View, Metadata = new FileMetadata { StreamVersion = fileMetadata.GetValueOrDefault("StreamVersion"), diff --git a/src/RvtIO/RvtReaderOptions.cs b/src/RvtIO/RvtReaderOptions.cs index b79b3d9..2a03c4a 100644 --- a/src/RvtIO/RvtReaderOptions.cs +++ b/src/RvtIO/RvtReaderOptions.cs @@ -24,8 +24,11 @@ public class RvtReaderOptions /// are one rectangle with different holes. Off by default, which keeps the geometry the legacy profile export is built on. public bool SeparateFacesWithDistinctOpenings { get; set; } - /// Rewrite the partition streams of a file with a later release's class schema (Revit 2023 and later) into the Revit 2022 - /// layout, so that the geometry and element readers written for 2022 see them (see ). Off by default. + /// Rewrite the partition streams of a file that is read by its own class schema into the Revit 2022 layout, so that the + /// geometry and element readers written for 2022 see them (see ). The reader keeps the layout it + /// ships only for a file of Revit 2022 (or of an unknown release); any other file whose own schema it can read is read by that + /// schema, and its element geometry is decoded only with this option (measured on samples of Revit 2020 and 2025; the Revit 2022 + /// samples decode the same either way). Off by default; the command line tool turns it on for an IFC export. public bool R22Layout { get; set; } /// When several partition streams hold a copy of the same element, keep the current copy named by the file's document diff --git a/tests/RvtIO.Tests/ComputedSymbolGeometryTests.cs b/tests/RvtIO.Tests/ComputedSymbolGeometryTests.cs index 16061f9..2e72ab4 100644 --- a/tests/RvtIO.Tests/ComputedSymbolGeometryTests.cs +++ b/tests/RvtIO.Tests/ComputedSymbolGeometryTests.cs @@ -77,6 +77,21 @@ public void VoidHelperWithAFilledComputedSymbol_GetsTheSymbolPlacedByItsTransfor Assert.False(IfcExporter.HasOnlyUnfilledFacesCurrent(element)); } + [Fact] + public void ComputedSymbolThatPlacesASymbolWithoutGeometry_IsMarkedUnresolved() + { + var element = Instance(7, Unfilled); + ElementBuilder.AttachComputedSymbolGeometry(element, 7, State()); + Assert.False(element.SerializedComputedSymbolGeometry!.UnresolvedSymbol); + + var state = State(); + state.GrepBlocks[70].Instances = [new GRepInstance { SymbolElemId = 999, Transform = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0] }]; + ElementBuilder.AttachComputedSymbolGeometry(element, 7, state); + + Assert.Equal(4, element.SerializedComputedSymbolGeometry!.Faces.Count); + Assert.True(element.SerializedComputedSymbolGeometry.UnresolvedSymbol); + } + [Fact] public void InstanceWithAFilledFace_KeepsItsOwnGeometry() { diff --git a/tests/RvtIO.Tests/ElementMeshesSampleTests.cs b/tests/RvtIO.Tests/ElementMeshesSampleTests.cs new file mode 100644 index 0000000..12fcf4f --- /dev/null +++ b/tests/RvtIO.Tests/ElementMeshesSampleTests.cs @@ -0,0 +1,177 @@ +using RvtIO.Meshes; +using Xunit; + +namespace RvtIO.Tests; + +/// The public triangle meshes () on private project files: a placement is exactly the +/// element's own geometry, triangles follow their normals, and the result does not change between two runs. +public class ElementMeshesSampleTests +{ + private static readonly Lazy Sample2022 = new(() => RvtDocument.Open(TestFiles.Find("Test_2022.rvt"))); + + private static RvtDocument Open2022() + { + TestFiles.Find("Test_2022.rvt"); // skips when the file is missing + return Sample2022.Value; + } + + private static (double[] Min, double[] Max, double Area) Measure(IEnumerable meshes, MeshTransform? transform = null) + { + double[] min = [double.MaxValue, double.MaxValue, double.MaxValue], max = [double.MinValue, double.MinValue, double.MinValue]; + double area = 0; + foreach (var mesh in meshes) + { + var points = new MeshVector[mesh.Positions.Length / 3]; + for (int vertex = 0; vertex < points.Length; vertex++) + { + var point = new MeshVector(mesh.Positions[3 * vertex], mesh.Positions[3 * vertex + 1], mesh.Positions[3 * vertex + 2]); + points[vertex] = transform is { } placed ? placed.Apply(point) : point; + } + for (int index = 0; index < mesh.Indices.Length; index += 3) + { + var (a, b, c) = (points[mesh.Indices[index]], points[mesh.Indices[index + 1]], points[mesh.Indices[index + 2]]); + double ux = b.X - a.X, uy = b.Y - a.Y, uz = b.Z - a.Z, vx = c.X - a.X, vy = c.Y - a.Y, vz = c.Z - a.Z; + double nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; + area += Math.Sqrt(nx * nx + ny * ny + nz * nz) / 2; + foreach (var p in new[] { a, b, c }) + { + min[0] = Math.Min(min[0], p.X); min[1] = Math.Min(min[1], p.Y); min[2] = Math.Min(min[2], p.Z); + max[0] = Math.Max(max[0], p.X); max[1] = Math.Max(max[1], p.Y); max[2] = Math.Max(max[2], p.Z); + } + } + } + return (min, max, area); + } + + private static (double[] Min, double[] Max, double Area) MeasurePlacements(MeshSet set, ElementMesh element) + { + var parts = element.Placements.Select(placement => Measure(set.Symbols[placement.SymbolId], placement.Transform)).ToList(); + return (Enumerable.Range(0, 3).Select(axis => parts.Min(part => part.Min[axis])).ToArray(), + Enumerable.Range(0, 3).Select(axis => parts.Max(part => part.Max[axis])).ToArray(), parts.Sum(part => part.Area)); + } + + /// The placed symbols cover what the element's own faces cover: the same box and the same area. + private static void AssertPlacementsAreTheElement(RvtDocument document, MeshSet set, ElementMesh element) + { + var placed = MeasurePlacements(set, element); + var own = Measure(ElementMeshes.Triangulate(document.GetElement(element.ElementId)!.Geometry!.Faces, _ => 0).Meshes); + for (int axis = 0; axis < 3; axis++) + { + Assert.Equal(own.Min[axis], placed.Min[axis], 6); + Assert.Equal(own.Max[axis], placed.Max[axis], 6); + } + Assert.Equal(own.Area, placed.Area, 6); + } + + private static void AssertWindingFollowsNormals(TriangleMesh mesh) + { + for (int index = 0; index < mesh.Indices.Length; index += 3) + { + int a = 3 * mesh.Indices[index], b = 3 * mesh.Indices[index + 1], c = 3 * mesh.Indices[index + 2]; + double ux = mesh.Positions[b] - mesh.Positions[a], uy = mesh.Positions[b + 1] - mesh.Positions[a + 1], uz = mesh.Positions[b + 2] - mesh.Positions[a + 2]; + double vx = mesh.Positions[c] - mesh.Positions[a], vy = mesh.Positions[c + 1] - mesh.Positions[a + 1], vz = mesh.Positions[c + 2] - mesh.Positions[a + 2]; + double dot = (uy * vz - uz * vy) * mesh.Normals[a] + (uz * vx - ux * vz) * mesh.Normals[a + 1] + (ux * vy - uy * vx) * mesh.Normals[a + 2]; + // A triangle of no area (three corners on a line, up to rounding) has no orientation. + double scale = Math.Sqrt((ux * ux + uy * uy + uz * uz) * (vx * vx + vy * vy + vz * vz)); + Assert.True(dot >= -1e-9 * scale, $"triangle {index / 3} runs against its normal"); + } + } + + [SkippableFact] + public void DoorInAWall_IsAPlacementOfItsSymbol() + { + var document = RvtDocument.Open(TestFiles.Find("test_door_in_wall.rvt")); + + var set = ElementMeshes.Extract(document); + + Assert.Equal(2, set.Elements.Count); + Assert.All(set.Elements, element => Assert.Equal(MeshCoverage.Complete, element.Coverage)); + var door = Assert.Single(set.Elements, element => document.GetElement(element.ElementId)!.CategoryId == -2000023); + var wall = Assert.Single(set.Elements, element => document.GetElement(element.ElementId)!.CategoryId == -2000011); + Assert.Empty(door.Meshes); + var placement = Assert.Single(door.Placements); + Assert.Equal(1, Math.Abs(placement.Transform.Determinant), 6); + Assert.Equal([placement.SymbolId], set.Symbols.Keys); + AssertPlacementsAreTheElement(document, set, door); + Assert.Empty(wall.Placements); + Assert.NotEmpty(wall.Meshes); + } + + [SkippableFact] + public void Sample_PlacementsAreTheirElementsAndTrianglesFollowTheirNormals() + { + var document = Open2022(); + + var set = ElementMeshes.Extract(document); + + Assert.False(set.GeometryUndecoded); + Assert.Equal(set.Elements.Count, set.Elements.Select(element => element.ElementId).Distinct().Count()); + Assert.DoesNotContain(set.Elements, element => element.Coverage == MeshCoverage.Undecoded); + var placed = set.Elements.Where(element => element.Placements.Count > 0).ToList(); + Assert.NotEmpty(placed); + Assert.Equal(placed.SelectMany(element => element.Placements).Select(placement => placement.SymbolId).ToHashSet(), + set.Symbols.Keys.ToHashSet()); + foreach (var element in placed) + { + Assert.Empty(element.Meshes); + AssertPlacementsAreTheElement(document, set, element); + } + foreach (var mesh in set.Elements.SelectMany(element => element.Meshes).Concat(set.Symbols.Values.SelectMany(meshes => meshes))) + { + Assert.Equal(mesh.Positions.Length, mesh.Normals.Length); + Assert.Equal(0, mesh.Indices.Length % 3); + AssertWindingFollowsNormals(mesh); + } + // Every door and window of the sample is its symbol placed. + foreach (int category in new[] { -2000023, -2000014 }) + { + var inserts = set.Elements.Where(element => document.GetElement(element.ElementId)!.CategoryId == category).ToList(); + Assert.NotEmpty(inserts); + Assert.All(inserts, element => Assert.NotEmpty(element.Placements)); + } + } + + [SkippableFact] + public void Sample_GivesTheSameMeshesTwice() + { + var document = Open2022(); + + var first = ElementMeshes.Extract(document); + var second = ElementMeshes.Extract(document); + + Assert.Equal(first.Elements.Select(element => (element.ElementId, element.Coverage, element.Placements.Count, element.UntriangulatedFaces, + element.Meshes.Sum(mesh => mesh.Indices.Length))), + second.Elements.Select(element => (element.ElementId, element.Coverage, element.Placements.Count, element.UntriangulatedFaces, + element.Meshes.Sum(mesh => mesh.Indices.Length)))); + Assert.Equal(first.Symbols.Keys.Order(), second.Symbols.Keys.Order()); + } + + [SkippableFact] + public void FileOfALaterRelease_ReadWithoutTheRewrite_IsUndecoded() + { + var document = RvtDocument.Open(TestFiles.Find("Test_2025.rvt")); + + var set = ElementMeshes.Extract(document); + + Assert.True(set.GeometryUndecoded); + Assert.NotEmpty(set.Elements); + Assert.All(set.Elements, element => + { + Assert.Equal(MeshCoverage.Undecoded, element.Coverage); + Assert.Empty(element.Meshes); + Assert.Empty(element.Placements); + }); + } + + [SkippableFact] + public void FileOfALaterRelease_ReadWithTheRewrite_IsDecoded() + { + var document = RvtDocument.Open(TestFiles.Find("Test_2025.rvt"), new RvtReaderOptions { R22Layout = true }); + + var set = ElementMeshes.Extract(document); + + Assert.False(set.GeometryUndecoded); + Assert.Contains(set.Elements, element => element.Placements.Count > 0); + Assert.Contains(set.Elements, element => element.Meshes.Count > 0); + } +} diff --git a/tests/RvtIO.Tests/ElementMeshesTests.cs b/tests/RvtIO.Tests/ElementMeshesTests.cs new file mode 100644 index 0000000..1e926b2 --- /dev/null +++ b/tests/RvtIO.Tests/ElementMeshesTests.cs @@ -0,0 +1,706 @@ +using System.Runtime.CompilerServices; +using RvtIO.Internal; +using RvtIO.Meshes; +using RvtIO.Model; +using RvtIO.Parsing; +using Xunit; + +namespace RvtIO.Tests; + +/// The public triangle meshes () on synthetic geometry: triangulation, materials, hidden +/// nodes, the decision between a symbol placement and the element's own triangles, and winding. +public class ElementMeshesTests +{ + private const int Phase = 7; + private const int SymbolId = 2; + private const int InstanceId = 1; + private static readonly double[] Identity = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]; + + // ---- synthetic model ---- + + /// An element the drawable rules keep: a generic model that names its project phase. + private static Element Product(int id, GeometryData? geometry) => new() + { + Id = id, + CategoryId = -2000151, + Category = "OST_GenericModel", + Geometry = geometry, + Parameters = [new Parameter { Id = -1012100, Value = Phase }], + SerializedCreatedPhaseId = Phase, + }; + + private static RvtDocument Document(params Element[] elements) => RvtDocument.FromInfo(new RevitFileInfo { Elements = [.. elements] }); + + private static GeoFace Face(double[][] ring, double[] normal, int node = 1) => + new() { OuterVertices = [.. ring], Normal = normal, GeometryNode = node }; + + /// The six faces of the box [min, max], counter-clockwise seen from outside, normals outward. + private static List<(double[][] Ring, double[] Normal)> BoxFaces(double[] min, double[] max) + { + double[] C(int x, int y, int z) => [x == 0 ? min[0] : max[0], y == 0 ? min[1] : max[1], z == 0 ? min[2] : max[2]]; + return + [ + ([C(0, 0, 0), C(0, 1, 0), C(1, 1, 0), C(1, 0, 0)], [0, 0, -1]), + ([C(0, 0, 1), C(1, 0, 1), C(1, 1, 1), C(0, 1, 1)], [0, 0, 1]), + ([C(0, 0, 0), C(1, 0, 0), C(1, 0, 1), C(0, 0, 1)], [0, -1, 0]), + ([C(0, 1, 0), C(0, 1, 1), C(1, 1, 1), C(1, 1, 0)], [0, 1, 0]), + ([C(0, 0, 0), C(0, 0, 1), C(0, 1, 1), C(0, 1, 0)], [-1, 0, 0]), + ([C(1, 0, 0), C(1, 1, 0), C(1, 1, 1), C(1, 0, 1)], [1, 0, 0]), + ]; + } + + private static GRepData BoxBlock(double[] min, double[] max, int? material = null) => new() + { + BoundingBox = [.. min, .. max], + Faces = BoxFaces(min, max).Select(face => new GRepFace + { + Vertices = [.. face.Ring], + Origin = face.Ring[0], + XVector = face.Normal, // the decoder keeps the polygon normal there + GeometryNode = 1, + RenderStyleId = material, + }).ToList(), + }; + + /// A family symbol (a box) and an instance that places it, built by the reader's own element builder; the instance + /// is then made a drawable product. + private static RvtDocument PlacedSymbol(double[] transform, double[]? instanceBox, GRepData? ownFaces = null) + { + var state = new ParsingState(); + state.GrepBlocks[SymbolId] = BoxBlock([0, 0, 0], [1, 2, 3]); + var instance = ownFaces ?? new GRepData(); + instance.BoundingBox = instanceBox; + instance.Instances = [new GRepInstance { SymbolElemId = SymbolId, Transform = transform }]; + state.GrepBlocks[InstanceId] = instance; + return Built(state, SymbolId); + } + + /// The symbols and then the instance, built from the blocks of by the + /// reader's own element builder; the instance is then made a drawable product. + private static RvtDocument Built(ParsingState state, params uint[] symbolIds) + { + var info = new RevitFileInfo(); + foreach (uint symbolId in symbolIds) ElementBuilder.BuildBlockOnlyElement(symbolId, state, info); + ElementBuilder.BuildBlockOnlyElement(InstanceId, state, info); + MakeProduct(info.Elements.Single(candidate => candidate.Id == InstanceId)); + return RvtDocument.FromInfo(info); + } + + /// Makes a built element a drawable product: a generic model that names its project phase. + private static void MakeProduct(Element element) + { + element.IsBlockOnly = false; + element.CategoryId = -2000151; + element.Category = "OST_GenericModel"; + element.Parameters.Add(new Parameter { Id = -1012100, Value = Phase }); + element.SerializedCreatedPhaseId = Phase; + } + + /// A symbol block of one triangle at that places the symbols where it + /// stands. + private static GRepData TriangleBlock(double x, params int[] nested) => new() + { + Faces = [new GRepFace { Vertices = [[x, 0, 0], [x + 1, 0, 0], [x, 1, 0]], Origin = [x, 0, 0], XVector = [0, 0, 1], GeometryNode = 1 }], + Instances = nested.Select(id => new GRepInstance { SymbolElemId = id, Transform = Identity }).ToList(), + }; + + // ---- geometry checks ---- + + private static IEnumerable<(double[] A, double[] B, double[] C, double[] Normal)> Triangles(TriangleMesh mesh) + { + double[] P(double[] values, int vertex) => [values[3 * vertex], values[3 * vertex + 1], values[3 * vertex + 2]]; + for (int index = 0; index < mesh.Indices.Length; index += 3) + yield return (P(mesh.Positions, mesh.Indices[index]), P(mesh.Positions, mesh.Indices[index + 1]), + P(mesh.Positions, mesh.Indices[index + 2]), P(mesh.Normals, mesh.Indices[index])); + } + + private static double[] Cross(double[] a, double[] b, double[] c) + { + double ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2], vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2]; + return [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx]; + } + + private static double Dot(double[] a, double[] b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + + private static double Area(IEnumerable meshes) => + meshes.SelectMany(Triangles).Sum(t => Math.Sqrt(Dot(Cross(t.A, t.B, t.C), Cross(t.A, t.B, t.C))) / 2); + + /// Every triangle is counter-clockwise around its (flat) vertex normal. + private static void AssertWindingFollowsNormals(IEnumerable meshes) + { + foreach (var (a, b, c, normal) in meshes.SelectMany(Triangles)) + Assert.True(Dot(Cross(a, b, c), normal) > 0); + } + + // ---- triangulation ---- + + [Fact] + public void PlanarFaceWithHole_CoversTheRingAndLeavesTheHoleOpen() + { + var face = Face([[0, 0, 5], [4, 0, 5], [4, 4, 5], [0, 4, 5]], [0, 0, 1]); + face.InnerVertices = [[[1, 1, 5], [1, 3, 5], [3, 3, 5], [3, 1, 5]]]; + + var (meshes, failed) = ElementMeshes.Triangulate([face], _ => 0); + + Assert.Equal(0, failed); + var mesh = Assert.Single(meshes); + Assert.Equal(12, Area(meshes), 9); + Assert.All(Triangles(mesh), t => + { + double cx = (t.A[0] + t.B[0] + t.C[0]) / 3, cy = (t.A[1] + t.B[1] + t.C[1]) / 3; + Assert.False(cx > 1 && cx < 3 && cy > 1 && cy < 3, "a triangle lies in the hole"); + Assert.Equal([0, 0, 1], t.Normal); + }); + AssertWindingFollowsNormals(meshes); + } + + [Fact] + public void RingAgainstItsNormal_IsWoundAroundTheNormal() + { + // A mirrored placement keeps the order of the corners and maps the normal: the ring runs clockwise around it. + var face = Face([[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]], [0, 0, 1]); + + var (meshes, failed) = ElementMeshes.Triangulate([face], _ => 0); + + Assert.Equal(0, failed); + Assert.Equal(1, Area(meshes), 12); + AssertWindingFollowsNormals(meshes); + Assert.All(meshes[0].Normals.Chunk(3), normal => Assert.Equal([0, 0, 1], normal)); + } + + [Fact] + public void FaceWithoutAPolygon_IsCountedAsUntriangulated() + { + var (meshes, failed) = ElementMeshes.Triangulate([new GeoFace { Normal = [0, 0, 1] }], _ => 0); + + Assert.Empty(meshes); + Assert.Equal(1, failed); + } + + [Fact] + public void FilledHole_IsCountedAsUntriangulated() + { + // A hole that crosses the outline cannot be cut out of it. + var face = Face([[0, 0, 0], [4, 0, 0], [4, 4, 0], [0, 4, 0]], [0, 0, 1]); + face.InnerVertices = [[[3, 1, 0], [3, 3, 0], [6, 3, 0], [6, 1, 0]]]; + + var (_, failed) = ElementMeshes.Triangulate([face], _ => 0); + + Assert.Equal(1, failed); + } + + [Fact] + public void LargeCoordinates_KeepTheirPrecision() + { + const double far = 1e6; + var face = Face([[far, far, 0], [far + 0.001, far, 0], [far + 0.001, far + 0.001, 0], [far, far + 0.001, 0]], [0, 0, 1]); + + var (meshes, failed) = ElementMeshes.Triangulate([face], _ => 0); + + Assert.Equal(0, failed); + Assert.Equal(1e-6, Area(meshes), 10); + Assert.Equal(far + 0.001, meshes[0].Positions.Where((_, index) => index % 3 == 0).Max()); + } + + [Fact] + public void UntakenNodes_AreDroppedOnlyBesideTakenNodes() + { + var taken = Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1], node: 1); + var untaken = Face([[0, 0, 1], [2, 0, 1], [2, 2, 1]], [0, 0, 1], node: -2); + var unnumbered = Face([[0, 0, 2], [3, 0, 2], [3, 3, 2]], [0, 0, 1], node: 0); + + Assert.Equal(0.5 + 4.5, Area(ElementMeshes.Triangulate([taken, untaken, unnumbered], _ => 0).Meshes), 12); + // Without a taken node the untaken one is all the element has. + Assert.Equal(2, Area(ElementMeshes.Triangulate([untaken], _ => 0).Meshes), 12); + // Beside a ghost node (exported, with no polygon here) the IFC export keeps the untaken nodes too. + Assert.Equal(0.5 + 2 + 4.5, Area(ElementMeshes.Triangulate([taken, untaken, unnumbered], _ => 0, hasGhostNodes: true).Meshes), 12); + } + + [Fact] + public void ElementWithGhostNodes_KeepsItsUntakenNodesAndIsPartial() + { + var geometry = new GeometryData + { + Faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1], node: 1), Face([[0, 0, 5], [2, 0, 5], [2, 2, 5]], [0, 0, 1], node: -2)], + GhostNodes = [(3, 0, 0)], + }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry))).Elements); + + Assert.Equal(0.5 + 2, Area(element.Meshes), 12); + Assert.Equal(MeshCoverage.Partial, element.Coverage); + } + + [Fact] + public void SymbolPlacedInAnElementWithGhostNodes_IsNotShared() + { + // The symbol alone drops its untaken node; the element, which has a ghost node, keeps it. + List faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1], node: 1), Face([[0, 0, 5], [2, 0, 5], [2, 2, 5]], [0, 0, 1], node: -2)]; + var geometry = new GeometryData + { + Faces = faces, + ExactMappedInstances = [new GeoInstance { SymbolId = 5, Transform = Identity }], + GhostNodes = [(3, 0, 0)], + }; + var symbol = new Element { Id = 5, Geometry = new GeometryData { Faces = faces } }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry), symbol)).Elements); + + Assert.Empty(element.Placements); + Assert.Equal(0.5 + 2, Area(element.Meshes), 12); + Assert.Equal(MeshCoverage.Partial, element.Coverage); + } + + // ---- materials ---- + + [Fact] + public void FaceMaterial_FollowsOwnThenNestedThenGraphicsStyle_OneMeshPerMaterial() + { + static GeoFace At(double z) => Face([[0, 0, z], [1, 0, z], [1, 1, z], [0, 1, z]], [0, 0, 1]); + var own = At(0); own.RenderStyleId = 901; own.NestedRenderStyleId = 902; + var nested = At(1); nested.NestedRenderStyleId = 902; + var notAMaterial = At(2); notAMaterial.RenderStyleId = 800; notAMaterial.NestedRenderStyleId = 902; + var styled = At(3); styled.GeometryStyle = 50; + var styledWithoutMaterial = At(4); styledWithoutMaterial.GeometryStyle = 51; + var none = At(5); + var styledInvalid = At(6); styledInvalid.RenderStyleId = 800; + var info = new RevitFileInfo + { + Elements = + [ + Product(10, new GeometryData { Faces = [own, nested, notAMaterial, styled, styledWithoutMaterial, none, styledInvalid] }), + new Element { Id = 901, CategoryId = -2000700 }, + new Element { Id = 902, CategoryId = -2000700 }, + new Element { Id = 903, CategoryId = -2000700 }, + new Element { Id = 800, CategoryId = -2000151 }, + ], + GraphicsStyles = new Dictionary { [50] = (903, -2000151), [51] = (-1, -2000151) }, + }; + + var element = Assert.Single(ElementMeshes.Extract(RvtDocument.FromInfo(info)).Elements); + + Assert.Equal([901, 902, 903, 0], element.Meshes.Select(mesh => mesh.MaterialId)); + Assert.Equal([1.0, 2.0, 1.0, 3.0], element.Meshes.Select(mesh => Area([mesh])).Select(area => Math.Round(area, 9))); + } + + // ---- placements ---- + + [Fact] + public void InstanceThatIsItsSymbolPlaced_IsAPlacement() + { + double[] rotated = [0, -1, 0, 1, 0, 0, 0, 0, 1, 10, 20, 30]; + var document = PlacedSymbol(rotated, [8, 20, 30, 10, 21, 33]); + + var set = ElementMeshes.Extract(document); + + var element = Assert.Single(set.Elements); + Assert.Equal(InstanceId, element.ElementId); + Assert.Empty(element.Meshes); + Assert.Equal(MeshCoverage.Complete, element.Coverage); + var placement = Assert.Single(element.Placements); + Assert.Equal(SymbolId, placement.SymbolId); + Assert.Equal(new MeshTransform(new(10, 20, 30), new(0, 1, 0), new(-1, 0, 0), new(0, 0, 1)), placement.Transform); + // The symbol mesh is in the frame of the family, wound around its own normals. + var symbol = set.Symbols[SymbolId]; + Assert.Equal(2 * (1 * 2 + 2 * 3 + 1 * 3), Area(symbol), 9); + Assert.All(symbol.SelectMany(mesh => mesh.Positions), value => Assert.InRange(value, 0, 3)); + AssertWindingFollowsNormals(symbol); + // Placed, it is the instance's own geometry. + var own = ElementMeshes.Triangulate(document.GetElement(InstanceId)!.Geometry!.Faces, _ => 0).Meshes; + Assert.Equal(Area(own), Area(symbol), 9); + } + + [Fact] + public void ClippedInstance_KeepsItsOwnShape() + { + // The tight box of the occurrence is lower than the symbol: the reader cuts the placed symbol to it. + var document = PlacedSymbol(Identity, [0, 0, 0, 1, 2, 1.5]); + Assert.True(document.GetElement(InstanceId)!.Geometry!.PlacedSymbolClipped); + Assert.NotNull(document.GetElement(InstanceId)!.Geometry!.ExactMappedInstances); // the IFC mapping flag alone would allow it + + var set = ElementMeshes.Extract(document); + + var element = Assert.Single(set.Elements); + Assert.Empty(element.Placements); + Assert.Empty(set.Symbols); + Assert.Equal(1.5, element.Meshes.SelectMany(mesh => mesh.Positions).Where((_, index) => index % 3 == 2).Max()); + // The bottom and the four sides cut at the box; the cut leaves no top. + Assert.Equal(1 * 2 + 2 * (1 * 1.5) + 2 * (2 * 1.5), Area(element.Meshes), 9); + } + + [Fact] + public void InstanceWithFacesOfItsOwn_KeepsItsOwnShape() + { + var own = new GRepData + { + Faces = [new GRepFace { Vertices = [[5, 5, 0], [6, 5, 0], [6, 6, 0]], Origin = [5, 5, 0], XVector = [0, 0, 1], GeometryNode = 1 }], + }; + var document = PlacedSymbol(Identity, null, own); + + var element = Assert.Single(ElementMeshes.Extract(document).Elements); + + Assert.Empty(element.Placements); + Assert.Equal(22 + 0.5, Area(element.Meshes), 9); + } + + [Fact] + public void ScaledInstance_KeepsItsOwnShape() + { + double[] scaled = [2, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0]; + var document = PlacedSymbol(scaled, null); + + var set = ElementMeshes.Extract(document); + + var element = Assert.Single(set.Elements); + Assert.Empty(element.Placements); + Assert.Empty(set.Symbols); + Assert.Equal(4 * 22, Area(element.Meshes), 9); + } + + [Fact] + public void BoxSubstitute_IsReportedAndNeverPlaced() + { + // The reader replaces a large component at a rescaled placement by its box and says so. + var state = new ParsingState(); + state.GrepBlocks[1] = new GRepData { Instances = [new GRepInstance { SymbolElemId = 2, Transform = [0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, 0, 0] }] }; + state.GrepBlocks[2] = BoxBlock([0, 0, 0], [4, 2, 2]); + var placedBox = new StrongBox(); + Assert.NotNull(ElementBuilder.BuildPlacedSymbolGeometry(1, Identity, state, [], scaledComponentFaceBudget: 5, placedBox: placedBox)); + Assert.True(placedBox.Value); + + var geometry = new GeometryData + { + Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList(), + ExactMappedInstances = [new GeoInstance { SymbolId = 5, Transform = Identity }], + PlacedSymbolBox = true, + }; + var symbol = new Element { Id = 5, Geometry = new GeometryData { Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList() } }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry), symbol)).Elements); + + Assert.Equal(MeshCoverage.BoxSubstitute, element.Coverage); + Assert.Empty(element.Placements); + Assert.Equal(6, Area(element.Meshes), 9); + } + + [Fact] + public void MirroredPlacement_CarriesTheMirrorAndKeepsTheSymbolWinding() + { + double[] mirrored = [-1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 0, 0]; + var document = PlacedSymbol(mirrored, [4, 0, 0, 5, 2, 3]); + + var set = ElementMeshes.Extract(document); + + var placement = Assert.Single(Assert.Single(set.Elements).Placements); + Assert.Equal(-1, placement.Transform.Determinant, 12); + // The shared triangles are those of the family, wound around the family's outward normals; the mirror is left to the + // consumer, who reverses the winding for a negative determinant. + var symbol = set.Symbols[SymbolId]; + AssertWindingFollowsNormals(symbol); + Assert.All(symbol.SelectMany(Triangles), t => + { + double[] centre = [0.5, 1, 1.5]; + double[] centroid = [(t.A[0] + t.B[0] + t.C[0]) / 3, (t.A[1] + t.B[1] + t.C[1]) / 3, (t.A[2] + t.B[2] + t.C[2]) / 3]; + Assert.True(Dot(t.Normal, [centroid[0] - centre[0], centroid[1] - centre[1], centroid[2] - centre[2]]) > 0); + }); + } + + [Fact] + public void MirroredInstanceWithItsOwnTriangles_IsWoundAroundTheMirroredNormals() + { + // The reader places the faces of a mirrored instance without changing the order of the corners, so its rings run + // against the mapped normals; the own triangles follow the normals, which point out of the mirrored box. + double[] mirrored = [-1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 0, 0]; + var document = PlacedSymbol(mirrored, [4, 0, 0, 5, 2, 2]); // clipped: the instance keeps its own shape + + var element = Assert.Single(ElementMeshes.Extract(document).Elements); + + Assert.Empty(element.Placements); + AssertWindingFollowsNormals(element.Meshes); + double[] centre = [4.5, 1, 1]; + Assert.All(element.Meshes.SelectMany(Triangles), t => + { + double[] centroid = [(t.A[0] + t.B[0] + t.C[0]) / 3, (t.A[1] + t.B[1] + t.C[1]) / 3, (t.A[2] + t.B[2] + t.C[2]) / 3]; + Assert.True(Dot(t.Normal, [centroid[0] - centre[0], centroid[1] - centre[1], centroid[2] - centre[2]]) > 0); + }); + } + + [Fact] + public void SymbolPlacementsOff_GivesEveryElementItsOwnTriangles() + { + var document = PlacedSymbol(Identity, null); + + var set = ElementMeshes.Extract(document, new ElementMeshOptions { UseSymbolPlacements = false }); + + var element = Assert.Single(set.Elements); + Assert.Empty(element.Placements); + Assert.Empty(set.Symbols); + Assert.Equal(22, Area(element.Meshes), 9); + } + + [Fact] + public void SymbolWhoseOwnNestedSymbolWasClipped_IsNotShared() + { + var geometry = new GeometryData + { + Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList(), + ExactMappedInstances = [new GeoInstance { SymbolId = 5, Transform = Identity }], + }; + var symbol = new Element + { + Id = 5, + Geometry = new GeometryData + { + Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList(), + PlacedSymbolClipped = true, + }, + }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry), symbol)).Elements); + + Assert.Empty(element.Placements); + Assert.Equal(6, Area(element.Meshes), 9); + } + + [Fact] + public void SymbolOfUntakenNodesBesideTakenOnes_IsNotShared() + { + // Alone, the symbol keeps its untaken nodes; in the element beside taken ones they are dropped. + var hidden = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal, node: -1)).ToList(); + var geometry = new GeometryData + { + Faces = [.. hidden, Face([[5, 5, 5], [6, 5, 5], [6, 6, 5]], [0, 0, 1], node: 2)], + ExactMappedInstances = [new GeoInstance { SymbolId = 5, Transform = Identity }, new GeoInstance { SymbolId = 6, Transform = Identity }], + }; + var symbolA = new Element { Id = 5, Geometry = new GeometryData { Faces = hidden } }; + var symbolB = new Element { Id = 6, Geometry = new GeometryData { Faces = [Face([[5, 5, 5], [6, 5, 5], [6, 6, 5]], [0, 0, 1], node: 1)] } }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry), symbolA, symbolB)).Elements); + + Assert.Empty(element.Placements); + Assert.Equal(0.5, Area(element.Meshes), 12); + } + + // ---- coverage and selection ---- + + [Fact] + public void UnresolvedSymbol_IsPartial() + { + var geometry = new GeometryData { Faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1])], UnresolvedSymbol = true }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry))).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + Assert.Equal(0, element.UntriangulatedFaces); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SymbolNestedInThePlacedSymbolWithoutGeometry_IsPartial(bool useSymbolPlacements) + { + // The placed symbol draws its triangle; the symbol it places in turn has no geometry block. + var state = new ParsingState(); + state.GrepBlocks[InstanceId] = new GRepData { Instances = [new GRepInstance { SymbolElemId = SymbolId, Transform = Identity }] }; + state.GrepBlocks[SymbolId] = TriangleBlock(0, 999); + + var element = Assert.Single(ElementMeshes.Extract(Built(state, SymbolId), + new ElementMeshOptions { UseSymbolPlacements = useSymbolPlacements }).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + Assert.Equal(useSymbolPlacements ? 1 : 0, element.Placements.Count); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SymbolTwoLevelsDownWithoutGeometry_IsPartial(bool useSymbolPlacements) + { + var state = new ParsingState(); + state.GrepBlocks[InstanceId] = new GRepData { Instances = [new GRepInstance { SymbolElemId = SymbolId, Transform = Identity }] }; + state.GrepBlocks[SymbolId] = TriangleBlock(0, 3); + state.GrepBlocks[3] = TriangleBlock(2, 999); + + var element = Assert.Single(ElementMeshes.Extract(Built(state, 3, SymbolId), + new ElementMeshOptions { UseSymbolPlacements = useSymbolPlacements }).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NestedSymbolsThatAllHaveGeometry_AreComplete(bool useSymbolPlacements) + { + var state = new ParsingState(); + state.GrepBlocks[InstanceId] = new GRepData { Instances = [new GRepInstance { SymbolElemId = SymbolId, Transform = Identity }] }; + state.GrepBlocks[SymbolId] = TriangleBlock(0, 3); + state.GrepBlocks[3] = TriangleBlock(2); + + var set = ElementMeshes.Extract(Built(state, 3, SymbolId), new ElementMeshOptions { UseSymbolPlacements = useSymbolPlacements }); + + var element = Assert.Single(set.Elements); + Assert.Equal(MeshCoverage.Complete, element.Coverage); + Assert.Equal(1, Area(useSymbolPlacements ? set.Symbols[SymbolId] : element.Meshes), 12); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TransformOnlyInstanceOfASymbolWithANestedSymbolWithoutGeometry_IsPartial(bool useSymbolPlacements) + { + // A family instance record with a placement and no geometry block of its own: its geometry is its symbol placed. + var state = new ParsingState(); + state.GrepBlocks[SymbolId] = TriangleBlock(0, 999); + state.Records[InstanceId] = new ElementRecord { ElemId = InstanceId }; + state.FamilyInstanceTransforms[InstanceId] = new FamilyInstanceData { SymbolId = SymbolId, Trf = Identity }; + var info = new RevitFileInfo(); + ElementBuilder.BuildBlockOnlyElement(SymbolId, state, info); + ElementBuilder.BuildDiscoveredElement(InstanceId, state, info); + MakeProduct(info.Elements.Single(candidate => candidate.Id == InstanceId)); + + var element = Assert.Single(ElementMeshes.Extract(RvtDocument.FromInfo(info), + new ElementMeshOptions { UseSymbolPlacements = useSymbolPlacements }).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + Assert.Equal(useSymbolPlacements ? 1 : 0, element.Placements.Count); + } + + [Fact] + public void UntriangulatedFace_IsPartial() + { + var geometry = new GeometryData { Faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1]), new GeoFace()] }; + + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, geometry))).Elements); + + Assert.Equal(MeshCoverage.Partial, element.Coverage); + Assert.Equal(1, element.UntriangulatedFaces); + } + + [Fact] + public void GeometryRecordWithoutFaces_IsNoGeometry() + { + var element = Assert.Single(ElementMeshes.Extract(Document(Product(1, new GeometryData { BoundingBox = [0, 0, 0, 1, 1, 1] }))).Elements); + + Assert.Equal(MeshCoverage.NoGeometry, element.Coverage); + Assert.Empty(element.Meshes); + } + + [Fact] + public void UndecodedDocument_ListsItsElementsAsUndecoded() + { + var info = new RevitFileInfo + { + WideLayoutNotRewritten = true, + Elements = [Product(1, null), Product(2, null)], + }; + info.Elements[0].BoundingBox = [0, 0, 0, 1, 1, 1]; + + var set = ElementMeshes.Extract(RvtDocument.FromInfo(info)); + + Assert.True(set.GeometryUndecoded); + var element = Assert.Single(set.Elements); // the other one has no box either + Assert.Equal(1, element.ElementId); + Assert.Equal(MeshCoverage.Undecoded, element.Coverage); + Assert.Empty(element.Meshes); + } + + [Fact] + public void DefinitionsAndContainers_AreNotListed() + { + static GeometryData Triangle() => new() { Faces = [Face([[0, 0, 0], [1, 0, 0], [1, 1, 0]], [0, 0, 1])] }; + var blockOnly = Product(2, Triangle()); blockOnly.IsBlockOnly = true; + var template = Product(3, Triangle()); template.IsGroupTemplate = true; + var withoutPhase = Product(4, Triangle()); withoutPhase.SerializedCreatedPhaseId = null; + var room = Product(5, Triangle()); room.Category = "OST_Rooms"; room.CategoryId = -2000160; + var run = Product(6, Triangle()); + var stair = Product(7, new GeometryData + { + Faces = Triangle().Faces, + ExactMappedInstances = [new GeoInstance { SymbolId = 6, Transform = Identity }], + }); + + var set = ElementMeshes.Extract(Document(Product(1, Triangle()), blockOnly, template, withoutPhase, room, run, stair)); + + Assert.Equal([1, 6], set.Elements.Select(element => element.ElementId)); + } + + [Fact] + public void PlacementOfADrawableElement_IsLeftToThatElement() + { + static List Box() => BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList(); + var member = Product(6, new GeometryData { Faces = Box() }); + var symbol = new Element { Id = 5, Geometry = new GeometryData { Faces = Box() } }; + var container = Product(7, new GeometryData + { + Faces = [.. Box(), .. Box()], + ExactMappedInstances = [new GeoInstance { SymbolId = 6, Transform = Identity }, new GeoInstance { SymbolId = 5, Transform = Identity }], + }); + + var set = ElementMeshes.Extract(Document(member, symbol, container)); + + Assert.Equal([6, 7], set.Elements.Select(element => element.ElementId)); + Assert.Equal([5], set.Elements[1].Placements.Select(placement => placement.SymbolId)); + Assert.Equal([5], set.Symbols.Keys); + } + + [Fact] + public void Extract_RejectsANullDocument() + { + Assert.Throws(() => ElementMeshes.Extract(null!)); + } + + // ---- reader flags ---- + + [Fact] + public void ClipGeometryToBounds_ReportsWhetherItChangedTheFaces() + { + var source = new GeometryData { Faces = BoxFaces([0, 0, 0], [1, 1, 1]).Select(face => Face(face.Ring, face.Normal)).ToList() }; + + Assert.NotNull(ElementBuilder.ClipGeometryToBounds(source, [0, 0, 0, 1, 1, 1], out bool inside)); + Assert.False(inside); + Assert.NotNull(ElementBuilder.ClipGeometryToBounds(source, [0, 0, 0, 1, 1, 0.5], out bool cut)); + Assert.True(cut); + Assert.Null(ElementBuilder.ClipGeometryToBounds(source, [5, 5, 5, 6, 6, 6], out bool away)); + Assert.True(away); + Assert.Same(source, ElementBuilder.ClipGeometryToBounds(source, null, out bool noBox)); + Assert.False(noBox); + } + + // ---- public types ---- + + [Fact] + public void MeshTransform_ReadsTheRowMajorInstanceTransform() + { + double[] t = [0, -1, 0, 1, 0, 0, 0, 0, 1, 10, 20, 30]; + var transform = MeshTransform.FromRowMajor(t); + double[] p = [1, 2, 3]; + + Assert.Equal(new MeshVector( + t[9] + p[0] * t[0] + p[1] * t[1] + p[2] * t[2], + t[10] + p[0] * t[3] + p[1] * t[4] + p[2] * t[5], + t[11] + p[0] * t[6] + p[1] * t[7] + p[2] * t[8]), transform.Apply(new MeshVector(1, 2, 3))); + Assert.Equal(1, transform.Determinant, 12); + Assert.Equal(-1, MeshTransform.FromRowMajor([-1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]).Determinant, 12); + Assert.Throws(() => MeshTransform.FromRowMajor([1, 0, 0])); + } + + [Fact] + public void MeshSet_CanBeBuiltByAConsumer() + { + var mesh = new TriangleMesh { Positions = [0, 0, 0, 1, 0, 0, 0, 1, 0], Indices = [0, 1, 2], Normals = [0, 0, 1, 0, 0, 1, 0, 0, 1], MaterialId = 3 }; + var set = new MeshSet + { + Elements = + [ + new ElementMesh { ElementId = 1, Meshes = [mesh], Coverage = MeshCoverage.Complete }, + new ElementMesh { ElementId = 2, Placements = [new SymbolPlacement(9, MeshTransform.FromRowMajor(Identity))] }, + ], + Symbols = new Dictionary> { [9] = [mesh] }, + }; + + Assert.Equal(2, set.Elements.Count); + Assert.False(set.GeometryUndecoded); + Assert.Same(mesh, set.Symbols[9][0]); + } +}