diff --git a/README.md b/README.md index 87064df..a5a151c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ RvtIO is a .NET library and command line tool that reads Autodesk Revit projects - Reads elements, parameters, geometry, file metadata and project information. - Queries elements through `FilteredElementCollector`. - Exports IFC2X3 coordination view, with optional Revit parameters as property sets. +- Exports browser-ready 3D scenes as binary glTF 2.0 (`.glb`) or versioned `rvtio-scene/1` JSON with reusable meshes, instancing, materials and element metadata. +- Exports a single-file offline 3D HTML viewer (`.html`) with an embedded WebGL inspection app and zero external network dependencies. - Writes compact JSON or a full JSON dump with every parameter and geometry. ## Install @@ -57,7 +59,15 @@ Export IFC with Revit parameters as property sets: rvtio project.rvt project.parameters.ifc --ifc-revit-parameters ``` -A `.ifc` output path selects IFC. Any other output path selects JSON, which is compact by default. If the output path is omitted, the tool writes `output.json`. Diagnostics go to standard error. +Export a binary glTF 2.0 scene or a self-contained offline 3D HTML viewer: + +```sh +rvtio project.rvt project.glb +rvtio project.rvt project.html --viewer-title "Project Review" +rvtio project.rvt project.scene.json +``` + +A `.ifc` output path selects IFC, `.glb` selects glTF 2.0, `.html`/`.htm` selects the standalone 3D HTML viewer, `.scene.json` (or `--scene-json`) selects the `rvtio-scene/1` JSON contract, and any other output path selects data JSON, which is compact by default. If the output path is omitted, the tool writes `output.json`. Diagnostics go to standard error. See [3D scene export and standalone HTML viewer](docs/scene-export.md) for the scene contract and viewer options. Export the state of one project phase: diff --git a/docs/export-report.md b/docs/export-report.md index 4a924ca..67f9bff 100644 --- a/docs/export-report.md +++ b/docs/export-report.md @@ -72,7 +72,7 @@ The report is one JSON object, UTF-8, property names in camelCase. `schema` name | `elements.inTable`, `elements.decoded` | Rows of the element table, and those with a parsed record. `decodedFraction` is their quotient (1 for an empty table). | | `issues[]` | Other findings that make a read `partial`, as `stream`, `code`, `detail`. The one code is `no-rows`: the element table decoded no row (a project always has elements, so the table did not decode). | | `elements.undecoded[]` | Rows of the element table without a parsed record, as `class`, `reason`, `count`, `examples`. The class of an element that never decoded is `unknown`. | -| `export.format` | `ifc`, `json` (the compact export) or `json-full`. | +| `export.format` | `ifc`, `glb`, `html`, `scene-json`, `json` (the compact export) or `json-full`. | | `export.considered`, `exported`, `skipped` | Parsed elements the export looked at, wrote, and left out, counted as the exporter counts them (`exported` counts elements, not product entities: for example, a roof element that writes two product entities, an `IfcRoof` and an `IfcSlab` with the same tag, counts once in `exported`, so the exported total can be below the number of product entities). `exportedFraction` is `exported / considered`. | | `export.skippedByClass[]` | Every skipped element class with its reason, count and up to five element ids (`examples`), largest first. The counts add up to `export.skipped`. | @@ -88,6 +88,10 @@ A level, a room and the topography that is written as the site are listed as ski The compact export writes the rows of the element table: `block-only-record` and `record-outside-exact-element-table` are the two reasons an element is left out. The full dump (`--json-full`) writes every parsed element. +### Reasons of a scene or HTML viewer export + +`block-only-record`, `group-template`, `record-outside-exact-element-table`, `owned-by-family-nonspatial`, `type-element`, `non-physical-category`, `no-project-phase-reference`, `outside-export-phase`, `filtered-out`, `filtered-by-element-id`, `filtered-by-category`, `filtered-by-predicate`, `no-geometry`. + ## Scripting ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index ecaa74d..e886485 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,15 +11,17 @@ dotnet tool install --global RvtIO.Cli rvtio project.rvt project.ifc ``` -A `.ifc` output path selects IFC. Any other output path selects JSON, which is compact by default. +A `.ifc` output path selects IFC, `.glb` selects a binary glTF 2.0 scene, `.html` / `.htm` selects a standalone 3D HTML viewer, and `.scene.json` (or `--scene-json`) selects the versioned `rvtio-scene/1` JSON contract. Any other output path selects data JSON, which is compact by default. ```sh +rvtio project.rvt project.glb +rvtio project.rvt viewer.html rvtio project.rvt project.compact.json --compact-json rvtio project.rvt project.full.json --json-full rvtio project.rvt project.parameters.ifc --ifc-revit-parameters ``` -Run `rvtio --help` for all options. The exit codes and the `--report` file are described in [Export report and exit codes](export-report.md). +Run `rvtio --help` for all options. Scene and viewer options are described in [Scene and HTML 3D viewer export](scene-export.md); the exit codes and the `--report` file are described in [Export report and exit codes](export-report.md). ## Library diff --git a/docs/index.md b/docs/index.md index 4c17510..0319f30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,6 +9,7 @@ RvtIO is a .NET library and command line tool that reads Autodesk Revit projects - Reads elements, parameters, geometry, file metadata and project information. - Queries elements through `FilteredElementCollector`. - Exports IFC2X3 coordination view, with optional Revit parameters as property sets. +- Exports browser-ready 3D scenes as binary glTF 2.0 (`.glb`), versioned `rvtio-scene/1` JSON, or a self-contained offline 3D HTML viewer (`.html`). - Writes compact JSON or a full JSON dump with every parameter and geometry. The library targets .NET 8 and .NET 10, uses managed code only, and supports Windows, Linux and macOS. @@ -17,6 +18,7 @@ The library targets .NET 8 and .NET 10, uses managed code only, and supports Win - [Getting started](getting-started.md): install the tool or the package and run a first export. - [Public API](public-api.md): the supported surface of the library. +- [Scene and HTML 3D viewer export](scene-export.md): `.glb`, `rvtio-scene/1` JSON and standalone `.html` 3D viewer options. - [Export report and exit codes](export-report.md): how a complete export is told from a partial one. - [API reference](api/RvtIO.yml): every public type, generated from the XML documentation. - [Source on GitHub](https://github.com/IOExtend/RvtIO) and [DeepWiki](https://deepwiki.com/IOExtend/RvtIO) for an automatically generated overview of the repository. diff --git a/docs/public-api.md b/docs/public-api.md index d2e1319..921e0b2 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -41,12 +41,16 @@ extension and label helpers carry the ids and names stored in the file format. | `IfcUnitFamily` | The length unit family an export of the current profile declares, by the length format of the project: `Metre` for metres and for metres and centimetres, `Foot` for feet, feet and fractional inches and US survey feet, `Millimetre` for millimetres, for every other format and for an unreadable one. The legacy profile always declares millimetres. | | `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. | +| `SceneExporter` | Builds a `ModelScene` (`Build`, `OpenAndBuild`), writes and reads the versioned `rvtio-scene/1` JSON (`ExportJson`, `LoadJson`), and writes binary glTF 2.0 `.glb` (`ExportGlb`); see [3D scene export](scene-export.md). | +| `HtmlViewerExporter` | Writes a single-file offline 3D HTML viewer (`Export`, `ExportToString`) embedding a `ModelScene` and WebGL viewer. | +| `SceneExportOptions`, `HtmlViewerOptions` | Filtering (`ElementIds`, `CategoryIds`, `ElementFilter`), metadata (`IncludeParameters`, `IncludeTypeParameters`, `IncludedParameterNames`, `MaxParametersPerElement`), coordinate (`ApplySharedCoordinates`, `IncludeGrids`, `IncludeRooms`), viewer (`Title`, `InitialTheme`, `ShowGridsByDefault`) and diagnostic (`SkipLog`, `DiagnosticOutput`) options. | +| `ModelScene` and scene records | `ModelScene`, `SceneMaterial`, `SceneMeshPrototype`, `SceneMeshInstance`, `SceneElement`, `ScenePropertyGroup`, `SceneProperty`, `SceneCategoryNode`, `SceneGridLine`, `SceneCompletenessSummary`, `SceneOmittedGroup` (`RvtIO.Scene`). | ## Diagnostics The library never writes to the console. A diagnostic message is written only when the caller gives a `TextWriter`: `RvtReaderOptions.DiagnosticOutput` for a read (and for `CompactJsonExporter.Export`), `IfcExporter.DiagnosticOutput` (or the -constructor argument) for an IFC export. The default is `null`, which is silent. The writer is scoped to the call that +constructor argument) for an IFC export, `SceneExportOptions.DiagnosticOutput` for a scene or HTML viewer export. The default is `null`, which is silent. The writer is scoped to the call that received it, so concurrent calls with different writers do not mix. The `rvtio` command line tool sets the writer to standard error. diff --git a/docs/scene-export.md b/docs/scene-export.md new file mode 100644 index 0000000..2dcdb79 --- /dev/null +++ b/docs/scene-export.md @@ -0,0 +1,126 @@ +--- +title: Scene and HTML 3D viewer export +--- + +# 3D scene export and standalone HTML viewer + +`RvtIO` exports browser-ready 3D geometry and element metadata directly from `.rvt` projects and `.rfa` families in three forms: + +1. **Binary glTF 2.0 (`.glb`)** — standard 3D asset with reusable meshes, per-instance transforms, PBR materials and element metadata in `node.extras`. +2. **Versioned scene JSON (`rvtio-scene/1`)** — compact JSON contract with Base64 typed-array mesh buffers, category hierarchy, grouped element properties, datum grids and completeness notes. +3. **Self-contained 3D HTML viewer (`.html`)** — single offline HTML file embedding the `rvtio-scene/1` payload and a zero-dependency WebGL viewer with no external network requests or CDN links. + +## Command line + +Export a binary glTF 2.0 scene: + +```sh +rvtio project.rvt project.glb +``` + +Export a self-contained HTML viewer: + +```sh +rvtio project.rvt project.html --viewer-title "Tower A - Architectural" +``` + +Export the `rvtio-scene/1` JSON contract: + +```sh +rvtio project.rvt project.scene.json +rvtio project.rvt scene.json --scene-json --scene-pretty +``` + +Filter by category or element IDs, control exported metadata, or apply shared coordinates: + +```sh +rvtio project.rvt envelope.html --scene-categories Walls,Windows,Doors,Roofs +rvtio project.rvt subset.glb --scene-ids 184201,184205 --scene-no-parameters +rvtio project.rvt site.glb --scene-shared-coordinates --scene-include-rooms +``` + +### Options + +| Option | Effect | +| --- | --- | +| `--scene-json` | Write `rvtio-scene/1` JSON instead of the compact data JSON. Automatic when the output path ends with `.scene.json`. | +| `--scene-pretty` | Indent the `rvtio-scene/1` JSON output. | +| `--scene-categories ` | Include only the listed categories. Accepts numeric category IDs (`-2000011`), `BuiltInCategory` names (`OST_Walls`) or English category names (`Walls`). | +| `--scene-ids ` | Include only the listed Revit element IDs. | +| `--scene-no-parameters` | Omit element and type parameters from the scene and HTML viewer. | +| `--scene-parameter-names ` | Include only parameters matching the listed names or captions. | +| `--scene-shared-coordinates` | Transform coordinates by `RevitFileInfo.SharedTransform` before computing the relative-to-center (`rtcOffset`) origin. | +| `--scene-include-rooms` | Export room volumes (`OST_Rooms`) as translucent 3D enclosures. | +| `--scene-no-grids` | Omit datum grid lines (`OST_Grids`). | +| `--glb-z-up` | Keep Revit right-handed Z-up coordinates in `.glb` instead of rotating the root node to glTF Y-up. | +| `--viewer-title ` | Set a custom document and header title in the HTML viewer. | +| `--viewer-theme ` | Set the initial color theme of the HTML viewer (default: `dark`). | + +All scene exports participate in `--report ` (`export.format` is `glb`, `html` or `scene-json`) and respect `--raise-limits` and `--allow-partial`. + +## Library API + +```csharp +using RvtIO; +using RvtIO.BuiltIns; +using RvtIO.Scene; + +var doc = RvtDocument.Open("project.rvt", new RvtReaderOptions +{ + R22Layout = true, + SeparateFacesWithDistinctOpenings = true, + SystemFamilyNameLocale = "ENU", +}); + +var options = new HtmlViewerOptions +{ + Title = "Project Review", + InitialTheme = "dark", + CategoryIds = new HashSet + { + (int)BuiltInCategory.OST_Walls, + (int)BuiltInCategory.OST_Doors, + (int)BuiltInCategory.OST_Windows, + }, +}; + +ModelScene scene = SceneExporter.Build(doc, options); +SceneExporter.ExportGlb(scene, "project.glb"); +SceneExporter.ExportJson(scene, "project.scene.json", indented: true); +HtmlViewerExporter.Export(scene, "project.html", options); +``` + +`SceneExporter.OpenAndBuild(rvtPath, options)` and `HtmlViewerExporter.Export(rvtPath, outputPath, options)` open the file with 3D geometry decoding (`R22Layout = true`) enabled by default. + +## `rvtio-scene/1` JSON contract + +The scene JSON is a single UTF-8 object with `camelCase` property names and `"schema": "rvtio-scene/1"`. Additive properties may be introduced within `rvtio-scene/1`; breaking changes increment the schema version. + +| Property | Meaning | +| --- | --- | +| `schema` | Always `"rvtio-scene/1"`. | +| `title` | Human-readable model title. | +| `units` | `"m"` (metres). | +| `coordinateSystem` | `"right-handed-z-up"`. | +| `rtcOffset` | `[x, y, z]` double-precision origin offset in metres. Add `rtcOffset` to any scene coordinate to recover world metres: $P_{\text{world}} = P_{\text{scene}} + \text{rtcOffset}$. | +| `sharedTransform` | Optional 12-double `[basisX(3), basisY(3), basisZ(3), originMetres(3)]` internal-to-shared transform when present in the file. | +| `boundsMin`, `boundsMax` | `[x, y, z]` bounding box of the scene in RTC metres. | +| `materials[]` | `{ index, revitMaterialId?, name, color: [r,g,b], opacity, metallic, roughness, doubleSided }`. | +| `meshes[]` | Reusable indexed triangle prototypes: `{ index, sourceSymbolId?, isMirroredVariant, vertexCount, triangleCount, indexType ("uint16"\|"uint32"), localBoundsMin, localBoundsMax, positionsB64, normalsB64, indicesB64 }`. `positionsB64` and `normalsB64` are little-endian IEEE 754 `Float32Array` bytes (`vertexCount * 3` floats); `indicesB64` is little-endian `Uint16Array` or `Uint32Array` (`triangleCount * 3` indices). | +| `instances[]` | Placed mesh occurrences: `{ meshIndex, elementIndex, elementId, materialIndex, matrix, boundsMin, boundsMax }`, where `matrix` is a 16-float column-major 4x4 affine matrix in RTC metres. | +| `elements[]` | Exported physical elements: `{ id, uniqueId?, name, category, categoryId?, familyName?, typeName?, level?, hostId?, isInstanced, instanceIndices, propertyGroups: [{ group, properties: [{ name, value }] }] }`. | +| `categories[]` | Category tree nodes: `{ category, categoryId?, elementIndices }`. | +| `grids[]` | Datum grid segments in RTC metres: `{ id, name, start: [x,y,z], end: [x,y,z] }`. | +| `completeness` | `{ totalElements, consideredElements, exportedElements, instancedElements, reusableMeshCount, instanceCount, vertexCount, triangleCount, isPartialGeometry, notes[], omittedGroups: [{ code, reason, count }] }`. | + +### Precision and instancing guarantees + +- **Relative-to-center (RTC) coordinates**: Models located more than 100 m from the origin (such as georeferenced shared coordinates) automatically shift their scene origin to the double-precision bounding-box center stored in `rtcOffset`, preventing 32-bit `Float32` vertex jitter in WebGL and glTF viewers. +- **Reusable family symbol meshes**: Repeated `FamilyInstance` placements share a single `SceneMeshPrototype` in `meshes[]` (and a single glTF `mesh` in `.glb`) placed by per-instance 4x4 transforms. +- **Mirrored instances**: Instances whose placement transform has negative determinant ($\det(M) < 0$) reference a dedicated mirrored prototype variant (`isMirroredVariant: true`) with reversed triangle index winding `(i0, i2, i1)` so counter-clockwise front faces and outward `(M^{-1})^T` normals are preserved in standard glTF 2.0 and WebGL pipelines. + +## Standalone HTML viewer capabilities and security + +- **Navigation**: Orbit (left-drag), pan (right-drag or Shift + drag), cursor-targeted zoom (wheel), fit-to-model / fit-to-selection (`F`), orthographic/perspective toggle (`O`), and an interactive 3D ViewCube (`Top`, `Bottom`, `Front`, `Back`, `Left`, `Right`, `Home`). +- **Inspection & filtering**: 3D raycast picking with selection highlight, category hierarchy browser with visibility toggles, live text search across element names/IDs/categories/types/levels, `Hide` (`H`), `Isolate` (`I`), `Show All` (`Esc` / button), grouped property inspector, 6-axis interactive section box, datum grid toggle (`G`), 3D point-to-point distance measurement (`M`), theme toggle, and a model completeness summary dialog. +- **Offline & XSS safety**: Zero external scripts, stylesheets, fonts or images are referenced. All embedded JSON escapes `<`, `>`, `&` (`\u003C`, `\u003E`, `\u0026`) so `` payloads in untrusted model strings cannot break out of ` + + + +"""; +} diff --git a/src/RvtIO/Scene/HtmlViewerOptions.cs b/src/RvtIO/Scene/HtmlViewerOptions.cs new file mode 100644 index 0000000..56f1c69 --- /dev/null +++ b/src/RvtIO/Scene/HtmlViewerOptions.cs @@ -0,0 +1,17 @@ +namespace RvtIO.Scene; + +/// +/// Options controlling standalone 3D HTML viewer generation via . +/// +public sealed class HtmlViewerOptions : SceneExportOptions +{ + /// + /// Initial visual theme of the generated HTML viewer ("dark" or "light"). Default is "dark". + /// + public string InitialTheme { get; set; } = "dark"; + + /// + /// Whether datum grid lines are visible initially when the viewer opens. Default is true. + /// + public bool ShowGridsByDefault { get; set; } = true; +} diff --git a/src/RvtIO/Scene/ModelScene.cs b/src/RvtIO/Scene/ModelScene.cs new file mode 100644 index 0000000..cadaefb --- /dev/null +++ b/src/RvtIO/Scene/ModelScene.cs @@ -0,0 +1,351 @@ +namespace RvtIO.Scene; + +/// +/// Versioned, browser-ready 3D scene representation of a Revit model, combining reusable indexed triangle meshes, +/// per-instance transforms, materials, element hierarchy, caller-selected metadata, and completeness diagnostics. +/// +public sealed class ModelScene +{ + /// + /// Current schema identifier of the serialized scene contract. + /// + public const string CurrentSchema = "rvtio-scene/1"; + + /// + /// Versioned contract identifier (). + /// + public string Schema { get; init; } = CurrentSchema; + + /// + /// Human-readable title of the model or document. + /// + public string Title { get; init; } = "Revit Model"; + + /// + /// Linear unit of all scene coordinates and matrices ("m" for metres). + /// + public string Units { get; init; } = "m"; + + /// + /// Coordinate convention of the scene ("right-handed-z-up"). + /// + public string CoordinateSystem { get; init; } = "right-handed-z-up"; + + /// + /// Double-precision world origin offset [x, y, z] in metres subtracted from scene geometry so + /// 32-bit floating-point vertex buffers retain sub-millimetre precision even at large survey coordinates. + /// Add to any scene-space point to recover world coordinates in metres. + /// + public double[] RtcOffset { get; init; } = [0, 0, 0]; + + /// + /// Optional 12-value internal-to-shared coordinate transform [basisX(3), basisY(3), basisZ(3), originMetres(3)], + /// or null when the document defines no shared-coordinate offset or rotation. + /// + public double[]? SharedTransform { get; init; } + + /// + /// Minimum corner [minX, minY, minZ] of the scene bounding box in RTC metres. + /// + public double[] BoundsMin { get; init; } = [0, 0, 0]; + + /// + /// Maximum corner [maxX, maxY, maxZ] of the scene bounding box in RTC metres. + /// + public double[] BoundsMax { get; init; } = [0, 0, 0]; + + /// + /// Materials referenced by . + /// + public IReadOnlyList Materials { get; init; } = []; + + /// + /// Reusable indexed triangle mesh prototypes referenced by . + /// Repeated family instances share a prototype rather than duplicating geometry buffers. + /// + public IReadOnlyList Meshes { get; init; } = []; + + /// + /// Placed mesh instances in the scene, linking a , a , + /// a 4x4 transform matrix in RTC metres, and the owning . + /// + public IReadOnlyList Instances { get; init; } = []; + + /// + /// Exported physical elements with stable Revit identity and selected metadata. + /// + public IReadOnlyList Elements { get; init; } = []; + + /// + /// Category hierarchy grouping indices into for model browsing. + /// + public IReadOnlyList Categories { get; init; } = []; + + /// + /// Exported 3D datum grid lines in RTC metres. + /// + public IReadOnlyList Grids { get; init; } = []; + + /// + /// Summary of exported and omitted content for user-facing completeness reporting. + /// + public SceneCompletenessSummary Completeness { get; init; } = new(); +} + +/// +/// Surface appearance of scene geometry, suitable for PBR glTF 2.0 and WebGL shading. +/// +public sealed class SceneMaterial +{ + /// Zero-based index in . + public int Index { get; init; } + + /// Source Revit material element ID when backed by a document material, otherwise null. + public int? RevitMaterialId { get; init; } + + /// Display name of the material. + public string Name { get; init; } = "Default"; + + /// Linear/sRGB base color channels [r, g, b] in [0..1]. + public float[] Color { get; init; } = [0.78f, 0.80f, 0.82f]; + + /// Opacity in [0..1] (1.0 = opaque, < 1.0 = translucent such as glazing). + public float Opacity { get; init; } = 1.0f; + + /// PBR metallic factor in [0..1]. + public float Metallic { get; init; } = 0.05f; + + /// PBR roughness factor in [0..1]. + public float Roughness { get; init; } = 0.65f; + + /// Whether both sides of triangles should be rendered. + public bool DoubleSided { get; init; } = true; +} + +/// +/// Reusable indexed triangle mesh prototype in metres. +/// +public sealed class SceneMeshPrototype +{ + /// Zero-based index in . + public int Index { get; init; } + + /// Source family symbol element ID when shared across family instances; null for element-owned geometry. + public int? SourceSymbolId { get; init; } + + /// True when triangle winding was reversed for negative-determinant (mirrored) instance transforms. + public bool IsMirroredVariant { get; init; } + + /// Packed vertex positions [x0, y0, z0, x1, y1, z1, ...] in metres. + public float[] Positions { get; init; } = []; + + /// Packed unit vertex normals [nx0, ny0, nz0, ...] matching . + public float[] Normals { get; init; } = []; + + /// Zero-based triangle vertex indices (3 * TriangleCount entries, counter-clockwise outward). + public int[] Indices { get; init; } = []; + + /// Minimum local corner [minX, minY, minZ] in metres. + public float[] LocalBoundsMin { get; init; } = [0, 0, 0]; + + /// Maximum local corner [maxX, maxY, maxZ] in metres. + public float[] LocalBoundsMax { get; init; } = [0, 0, 0]; + + /// Number of vertices in this mesh prototype. + public int VertexCount => Positions.Length / 3; + + /// Number of triangles in this mesh prototype. + public int TriangleCount => Indices.Length / 3; +} + +/// +/// Single placed occurrence of a belonging to a . +/// +public sealed class SceneMeshInstance +{ + /// Index into . + public int MeshIndex { get; init; } + + /// Index into . + public int ElementIndex { get; init; } + + /// Owning Revit element ID (). + public int ElementId { get; init; } + + /// Index into . + public int MaterialIndex { get; init; } + + /// + /// Column-major 4x4 affine transform matrix (16 floats, glTF / WebGL layout) mapping prototype-local metres + /// into scene RTC metres. + /// + public float[] Matrix { get; init; } = + [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]; + + /// Minimum world-aligned corner [minX, minY, minZ] of this placed instance in RTC metres. + public float[] BoundsMin { get; init; } = [0, 0, 0]; + + /// Maximum world-aligned corner [maxX, maxY, maxZ] of this placed instance in RTC metres. + public float[] BoundsMax { get; init; } = [0, 0, 0]; +} + +/// +/// Physical Revit element in the exported scene with stable identity, hierarchy placement, and selected properties. +/// +public sealed class SceneElement +{ + /// Revit element ID. + public int Id { get; init; } + + /// Stable Revit UniqueId when present on the element. + public string? UniqueId { get; init; } + + /// Display name of the element. + public string Name { get; init; } = ""; + + /// English category display name (for example "Walls", "Doors"). + public string Category { get; init; } = "Uncategorized"; + + /// Raw Revit built-in or user category ID when known. + public int? CategoryId { get; init; } + + /// Family name when resolved. + public string? FamilyName { get; init; } + + /// Element type / symbol name when resolved. + public string? TypeName { get; init; } + + /// Associated building level name when resolved. + public string? Level { get; init; } + + /// Host element ID (for example the wall hosting a door or window) when resolved. + public int? HostId { get; init; } + + /// True when this element's geometry reuses shared symbol prototypes via instancing. + public bool IsInstanced { get; init; } + + /// Indices into rendered for this element. + public IReadOnlyList InstanceIndices { get; init; } = []; + + /// Grouped key/value metadata properties exported for this element. + public IReadOnlyList PropertyGroups { get; init; } = []; +} + +/// +/// Named group of element properties (for example "Constraints", "Dimensions", "Identity Data"). +/// +public sealed class ScenePropertyGroup +{ + /// Display title of the property group. + public string Group { get; init; } = "Properties"; + + /// Properties belonging to this group. + public IReadOnlyList Properties { get; init; } = []; +} + +/// +/// Single formatted property entry on a . +/// +public sealed class SceneProperty +{ + /// Human-readable property name. + public string Name { get; init; } = ""; + + /// Formatted display value. + public string Value { get; init; } = ""; +} + +/// +/// Category group in the scene hierarchy. +/// +public sealed class SceneCategoryNode +{ + /// Category display name. + public string Category { get; init; } = ""; + + /// Revit category ID when known. + public int? CategoryId { get; init; } + + /// Indices into belonging to this category. + public IReadOnlyList ElementIndices { get; init; } = []; +} + +/// +/// Datum grid line in scene RTC metres. +/// +public sealed class SceneGridLine +{ + /// Revit element ID of the grid. + public int Id { get; init; } + + /// Grid bubble label (for example "A" or "1"). + public string Name { get; init; } = ""; + + /// Start point [x, y, z] in RTC metres. + public float[] Start { get; init; } = [0, 0, 0]; + + /// End point [x, y, z] in RTC metres. + public float[] End { get; init; } = [0, 0, 0]; +} + +/// +/// User-facing completeness summary describing what was exported into the 3D scene and what was omitted. +/// +public sealed class SceneCompletenessSummary +{ + /// Total element records present in the source document. + public int TotalElements { get; init; } + + /// Physical candidate elements evaluated after skipping internal/orphan records. + public int ConsideredElements { get; init; } + + /// Physical elements exported with 3D geometry. + public int ExportedElements { get; init; } + + /// Exported elements rendered via shared symbol mesh instancing. + public int InstancedElements { get; init; } + + /// Number of unique buffers in the scene. + public int ReusableMeshCount { get; init; } + + /// Total placed occurrences in the scene. + public int InstanceCount { get; init; } + + /// Total unique vertices across all mesh prototypes. + public int VertexCount { get; init; } + + /// Total rendered triangles across all placed instances. + public int TriangleCount { get; init; } + + /// + /// True when the document had undecoded geometry (for example read without + /// on a newer file) or physical model elements lacked representable 3D meshes. + /// + public bool IsPartialGeometry { get; init; } + + /// Plain user-facing notes describing completeness or limitations without internal implementation details. + public IReadOnlyList Notes { get; init; } = []; + + /// Breakdown of omitted elements by user-friendly category/reason. + public IReadOnlyList OmittedGroups { get; init; } = []; +} + +/// +/// Count of source elements omitted from the 3D scene for a single user-readable reason. +/// +public sealed class SceneOmittedGroup +{ + /// Stable machine-readable reason code. + public string Code { get; init; } = ""; + + /// User-friendly description suitable for display in a viewer UI. + public string Reason { get; init; } = ""; + + /// Number of elements omitted for this reason. + public int Count { get; init; } +} diff --git a/src/RvtIO/Scene/SceneBuilder.cs b/src/RvtIO/Scene/SceneBuilder.cs new file mode 100644 index 0000000..7623e8d --- /dev/null +++ b/src/RvtIO/Scene/SceneBuilder.cs @@ -0,0 +1,1548 @@ +using System.Globalization; +using RvtIO.BuiltIns; +using RvtIO.Compact; +using RvtIO.Ifc; +using RvtIO.Lookup; +using RvtIO.Model; + +namespace RvtIO.Scene; + +/// +/// Builds a from a decoded . +/// +internal static class SceneBuilder +{ + private const double FeetToMetres = SceneGeometryMesher.FeetToMetres; + private const double RtcActivationThresholdMetres = 100.0; + + private static readonly int[] LevelParamIds = + [ + (int)BuiltInParameter.WALL_BASE_CONSTRAINT, + (int)BuiltInParameter.FAMILY_LEVEL_PARAM, + (int)BuiltInParameter.INSTANCE_REFERENCE_LEVEL_PARAM, + (int)BuiltInParameter.SCHEDULE_LEVEL_PARAM, + (int)BuiltInParameter.LEVEL_PARAM, + (int)BuiltInParameter.ROOF_BASE_LEVEL_PARAM, + (int)BuiltInParameter.ROOF_CONSTRAINT_LEVEL_PARAM, + (int)BuiltInParameter.FACEROOF_LEVEL_PARAM, + (int)BuiltInParameter.STAIRS_BASE_LEVEL_PARAM, + (int)BuiltInParameter.ROOM_LEVEL_ID, + (int)BuiltInParameter.RBS_START_LEVEL_PARAM, + ]; + + private static readonly HashSet LevelParamNames = new(StringComparer.OrdinalIgnoreCase) + { + nameof(BuiltInParameter.WALL_BASE_CONSTRAINT), + nameof(BuiltInParameter.FAMILY_LEVEL_PARAM), + nameof(BuiltInParameter.INSTANCE_REFERENCE_LEVEL_PARAM), + nameof(BuiltInParameter.SCHEDULE_LEVEL_PARAM), + nameof(BuiltInParameter.LEVEL_PARAM), + nameof(BuiltInParameter.ROOF_BASE_LEVEL_PARAM), + nameof(BuiltInParameter.ROOF_CONSTRAINT_LEVEL_PARAM), + nameof(BuiltInParameter.FACEROOF_LEVEL_PARAM), + nameof(BuiltInParameter.STAIRS_BASE_LEVEL_PARAM), + nameof(BuiltInParameter.ROOM_LEVEL_ID), + nameof(BuiltInParameter.RBS_START_LEVEL_PARAM), + }; + + private static readonly HashSet IgnoredIdentityParamIds = + [ + (int)BuiltInParameter.ELEM_CATEGORY_PARAM, + (int)BuiltInParameter.ELEM_CATEGORY_PARAM_MT, + (int)BuiltInParameter.ELEM_TYPE_PARAM, + (int)BuiltInParameter.ELEM_FAMILY_AND_TYPE_PARAM, + (int)BuiltInParameter.ELEM_FAMILY_PARAM, + (int)BuiltInParameter.SYMBOL_ID_PARAM, + (int)BuiltInParameter.ID_PARAM, + ]; + + private static readonly HashSet IgnoredIdentityParamNames = new(StringComparer.OrdinalIgnoreCase) + { + nameof(BuiltInParameter.ELEM_CATEGORY_PARAM), + nameof(BuiltInParameter.ELEM_CATEGORY_PARAM_MT), + nameof(BuiltInParameter.ELEM_TYPE_PARAM), + nameof(BuiltInParameter.ELEM_FAMILY_AND_TYPE_PARAM), + nameof(BuiltInParameter.ELEM_FAMILY_PARAM), + nameof(BuiltInParameter.SYMBOL_ID_PARAM), + nameof(BuiltInParameter.ID_PARAM), + }; + + internal static ModelScene Build(RevitFileInfo info, SceneExportOptions? options = null) + { + ArgumentNullException.ThrowIfNull(info); + options ??= new SceneExportOptions(); + + var elemById = new Dictionary(info.Elements.Count); + foreach (var e in info.Elements) + elemById.TryAdd(e.Id, e); + + // Identify all element IDs referenced as types or geometry symbols by other elements + var typeAndSymbolIds = CollectTypeAndSymbolIds(info.Elements); + var curtainContainerWallIds = CollectCurtainContainerWallIds(info.Elements, elemById); + + bool hasProjectPhases = info.SerializedProjectPhaseIds is { Length: > 0 } + || info.Elements.Any(HasProjectPhaseReference); + + int[]? phaseOrder = info.SerializedProjectPhaseIds; + int activePhaseIdx = -1; + if (phaseOrder is { Length: > 0 } && info.SerializedDefaultExportPhaseId is int defPhase) + activePhaseIdx = Array.IndexOf(phaseOrder, defPhase); + + var omittedCounts = new Dictionary(StringComparer.Ordinal); + void CountOmitted(Element e, string skipCode, string summaryCode) + { + options.SkipLog?.Add(e.Id, SkipLog.ClassOf(e), skipCode); + omittedCounts[summaryCode] = omittedCounts.GetValueOrDefault(summaryCode) + 1; + } + + var physicalCandidates = new List(); + var gridElements = new List(); + + foreach (var elem in info.Elements.OrderBy(e => e.Id)) + { + if (elem.IsBlockOnly) + { + CountOmitted(elem, "block-only-record", "internal-definition"); + continue; + } + if (elem.IsGroupTemplate) + { + CountOmitted(elem, "group-template", "internal-definition"); + continue; + } + if (info.ElemTableIsExact && elem.IsDiscovered) + { + CountOmitted(elem, "record-outside-exact-element-table", "internal-definition"); + continue; + } + if (elem.SerializedFamilyId is > 0) + { + CountOmitted(elem, "owned-by-family-nonspatial", "internal-definition"); + continue; + } + + // Datum grids are exported as 3D grid lines rather than triangle meshes + if (elem.CategoryId == (int)BuiltInCategory.OST_Grids) + { + if (options.IncludeGrids) + gridElements.Add(elem); + else + CountOmitted(elem, "filtered-out", "filtered-out"); + continue; + } + + bool transformOnlyFamily = elem.Transform != null && elem.TypeId > 0 + && elem.CategoryId is null or -2000080 or -2001000 or -2001100; + + if (typeAndSymbolIds.Contains(elem.Id) && !transformOnlyFamily + && (!hasProjectPhases || !HasProjectPhaseReference(elem))) + { + CountOmitted(elem, "type-element", "type-or-symbol"); + continue; + } + + if (IsNonPhysicalElement(elem, options.IncludeRooms, curtainContainerWallIds)) + { + CountOmitted(elem, "non-physical-category", "non-physical"); + continue; + } + + if (hasProjectPhases) + { + if (elem.CategoryId != (int)BuiltInCategory.OST_Walls + && !HasProjectPhaseReference(elem) + && !transformOnlyFamily) + { + CountOmitted(elem, "no-project-phase-reference", "type-or-symbol"); + continue; + } + + if (activePhaseIdx >= 0 && phaseOrder != null && IsOutsideActivePhase(elem, phaseOrder, activePhaseIdx)) + { + CountOmitted(elem, "outside-export-phase", "phase-filtered"); + continue; + } + } + + // Caller subset filters + if (options.ElementIds != null && !options.ElementIds.Contains(elem.Id)) + { + CountOmitted(elem, "filtered-by-element-id", "filtered-out"); + continue; + } + if (options.CategoryIds != null && (elem.CategoryId == null || !options.CategoryIds.Contains(elem.CategoryId.Value))) + { + CountOmitted(elem, "filtered-by-category", "filtered-out"); + continue; + } + if (options.ElementFilter != null && !options.ElementFilter(elem)) + { + CountOmitted(elem, "filtered-by-predicate", "filtered-out"); + continue; + } + + physicalCandidates.Add(elem); + } + + // Shared coordinate transform setup + double[]? sharedTrfFeet = info.SharedTransform is { Length: >= 12 } st && st.Take(12).All(double.IsFinite) ? st : null; + double[]? exportedSharedTrfMetres = sharedTrfFeet == null ? null : + [ + sharedTrfFeet[0], sharedTrfFeet[1], sharedTrfFeet[2], + sharedTrfFeet[3], sharedTrfFeet[4], sharedTrfFeet[5], + sharedTrfFeet[6], sharedTrfFeet[7], sharedTrfFeet[8], + sharedTrfFeet[9] * FeetToMetres, sharedTrfFeet[10] * FeetToMetres, sharedTrfFeet[11] * FeetToMetres, + ]; + + bool applyShared = options.ApplySharedCoordinates && sharedTrfFeet != null; + (double X, double Y, double Z) InternalFeetToWorldMetres(double fx, double fy, double fz) + { + if (applyShared && sharedTrfFeet != null) + { + // SharedTransform stores columns [basisX(3), basisY(3), basisZ(3), origin(3)] in feet + double sx = sharedTrfFeet[9] + fx * sharedTrfFeet[0] + fy * sharedTrfFeet[3] + fz * sharedTrfFeet[6]; + double sy = sharedTrfFeet[10] + fx * sharedTrfFeet[1] + fy * sharedTrfFeet[4] + fz * sharedTrfFeet[7]; + double sz = sharedTrfFeet[11] + fx * sharedTrfFeet[2] + fy * sharedTrfFeet[5] + fz * sharedTrfFeet[8]; + return (sx * FeetToMetres, sy * FeetToMetres, sz * FeetToMetres); + } + return (fx * FeetToMetres, fy * FeetToMetres, fz * FeetToMetres); + } + + (double X, double Y, double Z) InternalDirToWorldDir((double X, double Y, double Z) d) + { + if (applyShared && sharedTrfFeet != null) + { + return ( + d.X * sharedTrfFeet[0] + d.Y * sharedTrfFeet[3] + d.Z * sharedTrfFeet[6], + d.X * sharedTrfFeet[1] + d.Y * sharedTrfFeet[4] + d.Z * sharedTrfFeet[7], + d.X * sharedTrfFeet[2] + d.Y * sharedTrfFeet[5] + d.Z * sharedTrfFeet[8]); + } + return d; + } + + // Compute world bounding box in metres to determine Relative-To-Center (RTC) offset + double[] rtcOffset = ComputeRtcOffsetMetres(physicalCandidates, gridElements, elemById, InternalFeetToWorldMetres); + + (double X, double Y, double Z) WorldFeetToRtcMetres((double X, double Y, double Z) ptFeet) + { + var wm = InternalFeetToWorldMetres(ptFeet.X, ptFeet.Y, ptFeet.Z); + return (wm.X - rtcOffset[0], wm.Y - rtcOffset[1], wm.Z - rtcOffset[2]); + } + + var materials = new List(); + var materialIndexByKey = new Dictionary(StringComparer.Ordinal); + int GetOrCreateMaterialIndex(int materialElemId, Element elem, Element? typeElem) + { + var spec = ResolveMaterialSpec(materialElemId, elem, typeElem, elemById); + if (materialIndexByKey.TryGetValue(spec.Key, out int existing)) + return existing; + int idx = materials.Count; + materials.Add(new SceneMaterial + { + Index = idx, + RevitMaterialId = spec.RevitMaterialId, + Name = spec.Name, + Color = spec.Color, + Opacity = spec.Opacity, + Metallic = spec.Metallic, + Roughness = spec.Roughness, + DoubleSided = true, + }); + materialIndexByKey[spec.Key] = idx; + return idx; + } + + var prototypes = new List(); + var instances = new List(); + var sceneElements = new List(); + + // Symbol prototype cache: symbolId -> list of (MaterialSlotId, NormalProtoIndex, MirroredProtoIndex?) + var symbolPrototypeCache = new Dictionary?>(); + + List? GetOrBuildSymbolPrototypes(int symbolId, GeometryData? fallbackInstanceGeo, double[]? fallbackInstanceTrf) + { + if (symbolPrototypeCache.TryGetValue(symbolId, out var cached)) + return cached; + + GeometryData? symbolGeo = null; + if (elemById.TryGetValue(symbolId, out var symElem) && HasRenderableGeometry(symElem.Geometry)) + { + symbolGeo = symElem.Geometry; + } + else if (fallbackInstanceGeo != null && fallbackInstanceTrf != null + && SceneGeometryMesher.TryInvertRowMajor3x4(fallbackInstanceTrf, out var invTrf)) + { + // Recover canonical symbol-local geometry from the first placed instance when symbol element has no own GRep + symbolGeo = UnplaceGeometryToSymbolSpace(fallbackInstanceGeo, invTrf); + } + + if (symbolGeo == null) + { + symbolPrototypeCache[symbolId] = null; + return null; + } + + var subparts = SceneGeometryMesher.MeshGeometry(symbolGeo, info.GraphicsStyles); + if (subparts.Count == 0) + { + symbolPrototypeCache[symbolId] = null; + return null; + } + + var entries = new List(subparts.Count); + foreach (var sp in subparts) + { + int protoIdx = prototypes.Count; + prototypes.Add(new SceneMeshPrototype + { + Index = protoIdx, + SourceSymbolId = symbolId, + IsMirroredVariant = false, + Positions = sp.Positions, + Normals = sp.Normals, + Indices = sp.Indices, + LocalBoundsMin = sp.BoundsMin, + LocalBoundsMax = sp.BoundsMax, + }); + entries.Add(new SymbolSubmeshEntry(sp.MaterialSlotId, protoIdx)); + } + + symbolPrototypeCache[symbolId] = entries; + return entries; + } + + int instancedElementCount = 0; + int missingGeometryCount = 0; + + double sceneMinX = double.MaxValue, sceneMinY = double.MaxValue, sceneMinZ = double.MaxValue; + double sceneMaxX = double.MinValue, sceneMaxY = double.MinValue, sceneMaxZ = double.MinValue; + void ExpandSceneBounds(float[] bMin, float[] bMax) + { + if (bMin[0] < sceneMinX) sceneMinX = bMin[0]; + if (bMin[1] < sceneMinY) sceneMinY = bMin[1]; + if (bMin[2] < sceneMinZ) sceneMinZ = bMin[2]; + if (bMax[0] > sceneMaxX) sceneMaxX = bMax[0]; + if (bMax[1] > sceneMaxY) sceneMaxY = bMax[1]; + if (bMax[2] > sceneMaxZ) sceneMaxZ = bMax[2]; + } + + foreach (var elem in physicalCandidates) + { + var effectiveGeo = ResolveEffectiveElementGeometry(elem, elemById); + + Element? typeElem = ResolveTypeElement(elem, elemById); + int elementIndex = sceneElements.Count; + var elemInstanceIndices = new List(); + bool usedInstancing = false; + + // 1. Try symbol instancing when the element maps cleanly to reusable symbol(s) + var mappedList = ResolveMappedInstances(elem, effectiveGeo); + if (mappedList is { Count: > 0 } && CanUseSymbolInstancing(mappedList, effectiveGeo, elemById)) + { + var pendingInstances = new List(); + bool allResolved = true; + + foreach (var gi in mappedList) + { + var symEntries = GetOrBuildSymbolPrototypes( + gi.SymbolId, + mappedList.Count == 1 ? effectiveGeo : null, + mappedList.Count == 1 ? gi.Transform : null); + + if (symEntries is not { Count: > 0 }) + { + allResolved = false; + break; + } + + float[] instanceMatrix = BuildInstanceRtcMatrix(gi.Transform, applyShared ? sharedTrfFeet : null, rtcOffset, out bool isMirrored); + foreach (var entry in symEntries) + { + int protoIdx = entry.NormalProtoIndex; + if (isMirrored) + { + if (entry.MirroredProtoIndex is not { } mirIdx) + { + mirIdx = prototypes.Count; + prototypes.Add(SceneGeometryMesher.CreateMirroredVariant(prototypes[entry.NormalProtoIndex], mirIdx)); + entry.MirroredProtoIndex = mirIdx; + } + protoIdx = mirIdx; + } + + var proto = prototypes[protoIdx]; + ComputeTransformedBounds(proto.LocalBoundsMin, proto.LocalBoundsMax, instanceMatrix, out var wMin, out var wMax); + int matIdx = GetOrCreateMaterialIndex(entry.MaterialSlotId, elem, typeElem); + + pendingInstances.Add(new SceneMeshInstance + { + MeshIndex = protoIdx, + ElementIndex = elementIndex, + ElementId = elem.Id, + MaterialIndex = matIdx, + Matrix = instanceMatrix, + BoundsMin = wMin, + BoundsMax = wMax, + }); + } + } + + if (allResolved && pendingInstances.Count > 0) + { + usedInstancing = true; + instancedElementCount++; + foreach (var inst in pendingInstances) + { + elemInstanceIndices.Add(instances.Count); + instances.Add(inst); + ExpandSceneBounds(inst.BoundsMin, inst.BoundsMax); + } + } + } + + // 2. Direct element mesh when not instanced + if (!usedInstancing) + { + if (effectiveGeo == null) + { + CountOmitted(elem, "no-geometry", "missing-geometry"); + missingGeometryCount++; + continue; + } + + var subparts = SceneGeometryMesher.MeshGeometry( + effectiveGeo, + info.GraphicsStyles, + WorldFeetToRtcMetres, + applyShared ? d => InternalDirToWorldDir(d) : null); + + if (subparts.Count == 0) + { + CountOmitted(elem, "no-triangulated-mesh", "missing-geometry"); + missingGeometryCount++; + continue; + } + + foreach (var sp in subparts) + { + int protoIdx = prototypes.Count; + prototypes.Add(new SceneMeshPrototype + { + Index = protoIdx, + SourceSymbolId = null, + IsMirroredVariant = false, + Positions = sp.Positions, + Normals = sp.Normals, + Indices = sp.Indices, + LocalBoundsMin = sp.BoundsMin, + LocalBoundsMax = sp.BoundsMax, + }); + + int matIdx = GetOrCreateMaterialIndex(sp.MaterialSlotId, elem, typeElem); + int instIdx = instances.Count; + var inst = new SceneMeshInstance + { + MeshIndex = protoIdx, + ElementIndex = elementIndex, + ElementId = elem.Id, + MaterialIndex = matIdx, + Matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + BoundsMin = (float[])sp.BoundsMin.Clone(), + BoundsMax = (float[])sp.BoundsMax.Clone(), + }; + instances.Add(inst); + elemInstanceIndices.Add(instIdx); + ExpandSceneBounds(inst.BoundsMin, inst.BoundsMax); + } + } + + string categoryDisplay = ResolveCategoryDisplayName(elem, info); + string? familyName = ResolveFamilyName(elem, typeElem); + string? typeName = ResolveTypeName(elem, typeElem); + string elemName = ResolveElementDisplayName(elem, typeName, familyName, categoryDisplay); + string? levelName = ResolveLevelName(elem, elemById); + int? hostId = elem.HostId is > 0 ? elem.HostId : elem.SerializedHostId is > 0 ? elem.SerializedHostId : null; + + var propGroups = options.IncludeParameters + ? BuildPropertyGroups(elem, typeElem, elemById, options) + : []; + + sceneElements.Add(new SceneElement + { + Id = elem.Id, + UniqueId = string.IsNullOrWhiteSpace(elem.UniqueId) ? null : elem.UniqueId, + Name = elemName, + Category = categoryDisplay, + CategoryId = elem.CategoryId, + FamilyName = familyName, + TypeName = typeName, + Level = levelName, + HostId = hostId, + IsInstanced = usedInstancing, + InstanceIndices = elemInstanceIndices, + PropertyGroups = propGroups, + }); + } + + // Build category hierarchy sorted alphabetically + var categoryNodes = sceneElements + .Select((el, idx) => (Element: el, Index: idx)) + .GroupBy(x => x.Element.Category, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .Select(g => new SceneCategoryNode + { + Category = g.First().Element.Category, + CategoryId = g.First().Element.CategoryId, + ElementIndices = g.Select(x => x.Index).ToList(), + }) + .ToList(); + + // Build 3D datum grids at the floor base elevation of the scene + double gridZRtc = sceneMinZ <= sceneMaxZ ? sceneMinZ : 0.0; + var sceneGrids = BuildSceneGrids(gridElements, WorldFeetToRtcMetres, gridZRtc, ref sceneMinX, ref sceneMinY, ref sceneMinZ, ref sceneMaxX, ref sceneMaxY, ref sceneMaxZ); + + if (sceneMinX > sceneMaxX) + { + sceneMinX = sceneMinY = sceneMinZ = 0; + sceneMaxX = sceneMaxY = sceneMaxZ = 0; + } + + // Compute completeness summary + int totalVertices = prototypes.Sum(p => p.VertexCount); + int totalTriangles = instances.Sum(inst => prototypes[inst.MeshIndex].TriangleCount); + + var omittedGroups = BuildOmittedGroups(omittedCounts); + var notes = BuildCompletenessNotes(info, sceneElements.Count, instancedElementCount, prototypes.Count, instances.Count, missingGeometryCount); + bool isPartial = info.GeometryUndecoded || missingGeometryCount > 0 || (physicalCandidates.Count > 0 && sceneElements.Count == 0); + + string resolvedTitle = ResolveSceneTitle(info, options.Title); + + options.DiagnosticOutput?.WriteLine( + $"Scene built: {sceneElements.Count} elements ({instancedElementCount} instanced), " + + $"{prototypes.Count} reusable meshes, {instances.Count} instances, {totalTriangles} triangles."); + + return new ModelScene + { + Schema = ModelScene.CurrentSchema, + Title = resolvedTitle, + Units = "m", + CoordinateSystem = "right-handed-z-up", + RtcOffset = rtcOffset, + SharedTransform = exportedSharedTrfMetres, + BoundsMin = [Math.Round(sceneMinX, 6), Math.Round(sceneMinY, 6), Math.Round(sceneMinZ, 6)], + BoundsMax = [Math.Round(sceneMaxX, 6), Math.Round(sceneMaxY, 6), Math.Round(sceneMaxZ, 6)], + Materials = materials, + Meshes = prototypes, + Instances = instances, + Elements = sceneElements, + Categories = categoryNodes, + Grids = sceneGrids, + Completeness = new SceneCompletenessSummary + { + TotalElements = info.Elements.Count, + ConsideredElements = physicalCandidates.Count, + ExportedElements = sceneElements.Count, + InstancedElements = instancedElementCount, + ReusableMeshCount = prototypes.Count, + InstanceCount = instances.Count, + VertexCount = totalVertices, + TriangleCount = totalTriangles, + IsPartialGeometry = isPartial, + Notes = notes, + OmittedGroups = omittedGroups, + }, + }; + } + + private sealed class SymbolSubmeshEntry(int materialSlotId, int normalProtoIndex) + { + public int MaterialSlotId { get; } = materialSlotId; + public int NormalProtoIndex { get; } = normalProtoIndex; + public int? MirroredProtoIndex { get; set; } + } + + private static GeometryData? ResolveEffectiveElementGeometry(Element elem, Dictionary elemById) + { + if (IfcExporter.HasOnlyUnfilledFacesCurrent(elem) && elem.SerializedComputedSymbolGeometry != null) + return elem.SerializedComputedSymbolGeometry; + + if (elem.Geometry != null && (HasRenderableGeometry(elem.Geometry) || elem.Geometry.Instances is { Count: > 0 } || elem.Geometry.ExactMappedInstances is { Count: > 0 })) + return elem.Geometry; + + if (elem.CategoryId is (int)BuiltInCategory.OST_Rooms or (int)BuiltInCategory.OST_MEPSpaces) + { + var boundary = elem.SerializedSpaceBoundaryCurrent ?? elem.SerializedLiteralSpaceBoundary; + if (boundary != null && HasRenderableGeometry(boundary)) + return boundary; + + if (elem.SerializedPlanBoundary is { Count: >= 3 } planLoop) + return ExtrudePlanLoop(planLoop, elem, elemById); + } + + return elem.Geometry; + } + + private static GeometryData ExtrudePlanLoop(IReadOnlyList<(double X, double Y)> loop, Element elem, Dictionary elemById) + { + double zBase = 0.0; + if (elem.Parameters.FirstOrDefault(p => p.Id == -1002003)?.Value is int lvlId + && elemById.TryGetValue(lvlId, out var lvl) + && lvl.Parameters.FirstOrDefault(p => p.Definition?.Name == "LEVEL_ELEV")?.Value is double elev) + { + zBase = elev; + } + + double height = elem.Parameters.FirstOrDefault(p => p.Definition?.Name is "ROOM_UPPER_OFFSET" or "ROOM_COMPUTATION_HEIGHT")?.Value is double h && h > 0.1 + ? h + : 9.0; + double zTop = zBase + height; + + // Ensure counter-clockwise XY plan loop + double signedArea2 = 0; + for (int i = 0; i < loop.Count; i++) + { + var a = loop[i]; + var b = loop[(i + 1) % loop.Count]; + signedArea2 += a.X * b.Y - b.X * a.Y; + } + + var pts = signedArea2 >= 0 ? loop.ToList() : loop.Reverse().ToList(); + var faces = new List(pts.Count + 2) + { + new() + { + Normal = [0, 0, -1], + PlaneSurface = true, + OuterVertices = Enumerable.Reverse(pts).Select(p => new[] { p.X, p.Y, zBase }).ToList(), + }, + new() + { + Normal = [0, 0, 1], + PlaneSurface = true, + OuterVertices = pts.Select(p => new[] { p.X, p.Y, zTop }).ToList(), + }, + }; + + for (int i = 0; i < pts.Count; i++) + { + var a = pts[i]; + var b = pts[(i + 1) % pts.Count]; + double dx = b.X - a.X, dy = b.Y - a.Y; + double len = Math.Sqrt(dx * dx + dy * dy); + if (len < 1e-6) continue; + faces.Add(new GeoFace + { + Normal = [dy / len, -dx / len, 0], + PlaneSurface = true, + OuterVertices = + [ + [a.X, a.Y, zBase], + [b.X, b.Y, zBase], + [b.X, b.Y, zTop], + [a.X, a.Y, zTop], + ], + }); + } + + return new GeometryData { Faces = faces }; + } + + private static List? ResolveMappedInstances(Element elem, GeometryData? effectiveGeo) + { + if (effectiveGeo?.ExactMappedInstances is { Count: > 0 } exact) + return exact; + + if (effectiveGeo?.Instances is { Count: > 0 } instances) + { + bool hasOwnGeometry = effectiveGeo.Faces is { Count: > 0 } + || effectiveGeo.MeshNodes is { Count: > 0 } + || effectiveGeo.Solids is { Count: > 0 }; + if (!hasOwnGeometry) + return instances; + // Single-symbol expanded instance (e.g. deserialized from public JSON where ExactMappedInstances is omitted) + if (instances.Count == 1 && effectiveGeo.MeshNodes is not { Count: > 0 } && effectiveGeo.Solids is not { Count: > 0 }) + return instances; + } + + if (elem.Transform is { SymbolId: > 0 } tr + && tr.Origin is { Length: >= 3 } && tr.BasisX is { Length: >= 3 } + && tr.BasisY is { Length: >= 3 } && tr.BasisZ is { Length: >= 3 }) + { + return + [ + new GeoInstance + { + SymbolId = tr.SymbolId, + Transform = + [ + tr.BasisX[0], tr.BasisY[0], tr.BasisZ[0], + tr.BasisX[1], tr.BasisY[1], tr.BasisZ[1], + tr.BasisX[2], tr.BasisY[2], tr.BasisZ[2], + tr.Origin[0], tr.Origin[1], tr.Origin[2], + ], + }, + ]; + } + + return null; + } + + private static bool CanUseSymbolInstancing( + List mappedList, + GeometryData? effectiveGeo, + Dictionary elemById) + { + foreach (var gi in mappedList) + { + if (gi.SymbolId <= 0 || gi.Transform is not { Length: >= 12 }) + return false; + if (gi.Transform.Take(12).Any(v => !double.IsFinite(v))) + return false; + if (Math.Abs(SceneGeometryMesher.DeterminantRowMajor3x4(gi.Transform)) < 1e-9) + return false; + } + + // When the instance has expanded faces AND the symbol is in elemById, ensure the instance wasn't host-clipped + if (mappedList.Count == 1 && effectiveGeo?.Faces is { Count: > 0 } instFaces + && elemById.TryGetValue(mappedList[0].SymbolId, out var sym) + && sym.Geometry?.Faces is { Count: > 0 } symFaces) + { + if (instFaces.Count != symFaces.Count) + return false; + } + + return true; + } + + private static GeometryData? UnplaceGeometryToSymbolSpace(GeometryData instanceGeo, double[] invTrf) + { + if (instanceGeo.Faces.Count == 0) + return null; + + bool mirrored = SceneGeometryMesher.DeterminantRowMajor3x4(invTrf) < 0; + double[] MapPt(double[] p) => + [ + invTrf[9] + p[0] * invTrf[0] + p[1] * invTrf[1] + p[2] * invTrf[2], + invTrf[10] + p[0] * invTrf[3] + p[1] * invTrf[4] + p[2] * invTrf[5], + invTrf[11] + p[0] * invTrf[6] + p[1] * invTrf[7] + p[2] * invTrf[8], + ]; + double[] MapVec(double[] v) => + [ + v[0] * invTrf[0] + v[1] * invTrf[1] + v[2] * invTrf[2], + v[0] * invTrf[3] + v[1] * invTrf[4] + v[2] * invTrf[5], + v[0] * invTrf[6] + v[1] * invTrf[7] + v[2] * invTrf[8], + ]; + + var local = new GeometryData { GStyleElemId = instanceGeo.GStyleElemId }; + foreach (var f in instanceGeo.Faces) + { + if (f.OuterVertices is not { Count: >= 3 }) continue; + var outer = f.OuterVertices.Select(MapPt).ToList(); + if (mirrored) outer.Reverse(); + var inner = f.InnerVertices?.Select(ring => + { + var r = ring.Select(MapPt).ToList(); + if (mirrored) r.Reverse(); + return r; + }).ToList(); + + var n = f.Normal is { Length: >= 3 } ? MapVec(f.Normal) : [0.0, 0.0, 1.0]; + if (mirrored) n = [-n[0], -n[1], -n[2]]; + + local.Faces.Add(new GeoFace + { + Origin = outer[0], + XVector = f.XVector is { Length: >= 3 } ? MapVec(f.XVector) : [1, 0, 0], + YVector = f.YVector is { Length: >= 3 } ? MapVec(f.YVector) : [0, 1, 0], + Normal = n, + OuterVertices = outer, + InnerVertices = inner, + RenderStyleId = f.RenderStyleId, + NestedRenderStyleId = f.NestedRenderStyleId, + GeometryStyle = f.GeometryStyle, + }); + } + return local.Faces.Count > 0 ? local : null; + } + + private static float[] BuildInstanceRtcMatrix( + double[] t, + double[]? sharedTrfFeet, + double[] rtcOffsetMetres, + out bool isMirrored) + { + // t is row-major 3x4 in feet: + // worldFeet = [t[9] + x*t[0] + y*t[1] + z*t[2], t[10] + x*t[3] + y*t[4] + z*t[5], t[11] + x*t[6] + y*t[7] + z*t[8]] + // Note: prototype vertices are already scaled to metres (x_m = x_ft * FeetToMetres), so the 3x3 linear part is dimensionless! + double r00 = t[0], r01 = t[1], r02 = t[2]; + double r10 = t[3], r11 = t[4], r12 = t[5]; + double r20 = t[6], r21 = t[7], r22 = t[8]; + double txFt = t[9], tyFt = t[10], tzFt = t[11]; + + if (sharedTrfFeet != null) + { + // SharedTransform S has columns: sCol0 = st[0..2], sCol1 = st[3..5], sCol2 = st[6..8], sOrigin = st[9..11] + // S * R where sRow0 = (st[0], st[3], st[6]), sRow1 = (st[1], st[4], st[7]), sRow2 = (st[2], st[5], st[8]) + double s00 = sharedTrfFeet[0], s01 = sharedTrfFeet[3], s02 = sharedTrfFeet[6]; + double s10 = sharedTrfFeet[1], s11 = sharedTrfFeet[4], s12 = sharedTrfFeet[7]; + double s20 = sharedTrfFeet[2], s21 = sharedTrfFeet[5], s22 = sharedTrfFeet[8]; + + double nr00 = s00 * r00 + s01 * r10 + s02 * r20; + double nr01 = s00 * r01 + s01 * r11 + s02 * r21; + double nr02 = s00 * r02 + s01 * r12 + s02 * r22; + + double nr10 = s10 * r00 + s11 * r10 + s12 * r20; + double nr11 = s10 * r01 + s11 * r11 + s12 * r21; + double nr12 = s10 * r02 + s11 * r12 + s12 * r22; + + double nr20 = s20 * r00 + s21 * r10 + s22 * r20; + double nr21 = s20 * r01 + s21 * r11 + s22 * r21; + double nr22 = s20 * r02 + s21 * r12 + s22 * r22; + + double ntx = sharedTrfFeet[9] + s00 * txFt + s01 * tyFt + s02 * tzFt; + double nty = sharedTrfFeet[10] + s10 * txFt + s11 * tyFt + s12 * tzFt; + double ntz = sharedTrfFeet[11] + s20 * txFt + s21 * tyFt + s22 * tzFt; + + r00 = nr00; r01 = nr01; r02 = nr02; + r10 = nr10; r11 = nr11; r12 = nr12; + r20 = nr20; r21 = nr21; r22 = nr22; + txFt = ntx; tyFt = nty; tzFt = ntz; + } + + double det = r00 * (r11 * r22 - r12 * r21) - r01 * (r10 * r22 - r12 * r20) + r02 * (r10 * r21 - r11 * r20); + isMirrored = det < 0; + + double txRtc = txFt * FeetToMetres - rtcOffsetMetres[0]; + double tyRtc = tyFt * FeetToMetres - rtcOffsetMetres[1]; + double tzRtc = tzFt * FeetToMetres - rtcOffsetMetres[2]; + + // Column-major 4x4 (glTF 2.0 & WebGL standard): + return + [ + (float)r00, (float)r10, (float)r20, 0f, + (float)r01, (float)r11, (float)r21, 0f, + (float)r02, (float)r12, (float)r22, 0f, + (float)txRtc, (float)tyRtc, (float)tzRtc, 1f, + ]; + } + + private static void ComputeTransformedBounds( + float[] localMin, + float[] localMax, + float[] colMajor, + out float[] worldMin, + out float[] worldMax) + { + float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue; + float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue; + + for (int mask = 0; mask < 8; mask++) + { + float lx = (mask & 1) == 0 ? localMin[0] : localMax[0]; + float ly = (mask & 2) == 0 ? localMin[1] : localMax[1]; + float lz = (mask & 4) == 0 ? localMin[2] : localMax[2]; + + float wx = colMajor[12] + lx * colMajor[0] + ly * colMajor[4] + lz * colMajor[8]; + float wy = colMajor[13] + lx * colMajor[1] + ly * colMajor[5] + lz * colMajor[9]; + float wz = colMajor[14] + lx * colMajor[2] + ly * colMajor[6] + lz * colMajor[10]; + + if (wx < minX) minX = wx; if (wx > maxX) maxX = wx; + if (wy < minY) minY = wy; if (wy > maxY) maxY = wy; + if (wz < minZ) minZ = wz; if (wz > maxZ) maxZ = wz; + } + + worldMin = [minX, minY, minZ]; + worldMax = [maxX, maxY, maxZ]; + } + + private static double[] ComputeRtcOffsetMetres( + List candidates, + List grids, + Dictionary elemById, + Func toWorldMetres) + { + double minX = double.MaxValue, minY = double.MaxValue, minZ = double.MaxValue; + double maxX = double.MinValue, maxY = double.MinValue, maxZ = double.MinValue; + bool any = false; + + void IncludeFeetPoint(double fx, double fy, double fz) + { + if (!double.IsFinite(fx) || !double.IsFinite(fy) || !double.IsFinite(fz)) return; + var wm = toWorldMetres(fx, fy, fz); + if (wm.X < minX) minX = wm.X; if (wm.X > maxX) maxX = wm.X; + if (wm.Y < minY) minY = wm.Y; if (wm.Y > maxY) maxY = wm.Y; + if (wm.Z < minZ) minZ = wm.Z; if (wm.Z > maxZ) maxZ = wm.Z; + any = true; + } + + foreach (var e in candidates) + { + var geo = ResolveEffectiveElementGeometry(e, elemById); + var box = e.DecodedBox ?? geo?.BoundingBox ?? e.BoundingBox; + if (box is { Length: >= 6 } && box.Take(6).All(double.IsFinite) && box[3] >= box[0]) + { + IncludeFeetPoint(box[0], box[1], box[2]); + IncludeFeetPoint(box[3], box[4], box[5]); + continue; + } + + if (geo?.Faces is { Count: > 0 } faces) + { + foreach (var f in faces) + { + if (f.OuterVertices == null) continue; + foreach (var v in f.OuterVertices) + if (v is { Length: >= 3 }) IncludeFeetPoint(v[0], v[1], v[2]); + } + } + else if (geo?.Solids is { Count: > 0 } solids) + { + foreach (var s in solids) + if (s.Position is { Length: >= 3 }) IncludeFeetPoint(s.Position[0], s.Position[1], s.Position[2]); + } + else if (ResolveMappedInstances(e, geo) is { Count: > 0 } mapped) + { + foreach (var gi in mapped) + { + if (gi.Transform is { Length: >= 12 }) + IncludeFeetPoint(gi.Transform[9], gi.Transform[10], gi.Transform[11]); + } + } + } + + if (!any) + { + foreach (var g in grids) + { + if (g.Location is { StartPoint.Length: >= 3, EndPoint.Length: >= 3 } loc) + { + IncludeFeetPoint(loc.StartPoint[0], loc.StartPoint[1], loc.StartPoint[2]); + IncludeFeetPoint(loc.EndPoint[0], loc.EndPoint[1], loc.EndPoint[2]); + } + } + } + + if (!any) return [0, 0, 0]; + + double cx = (minX + maxX) * 0.5; + double cy = (minY + maxY) * 0.5; + double cz = (minZ + maxZ) * 0.5; + + if (Math.Abs(cx) <= RtcActivationThresholdMetres + && Math.Abs(cy) <= RtcActivationThresholdMetres + && Math.Abs(cz) <= RtcActivationThresholdMetres) + { + return [0, 0, 0]; + } + + return [Math.Round(cx, 3), Math.Round(cy, 3), Math.Round(cz, 3)]; + } + + private static List BuildSceneGrids( + List gridElements, + Func<(double X, double Y, double Z), (double X, double Y, double Z)> worldFeetToRtcMetres, + double gridZRtc, + ref double minX, ref double minY, ref double minZ, + ref double maxX, ref double maxY, ref double maxZ) + { + var list = new List(); + foreach (var g in gridElements) + { + (double X, double Y, double Z) sRtc, eRtc; + if (g.Location is { StartPoint.Length: >= 2, EndPoint.Length: >= 2 } loc + && double.IsFinite(loc.StartPoint[0]) && double.IsFinite(loc.StartPoint[1]) + && double.IsFinite(loc.EndPoint[0]) && double.IsFinite(loc.EndPoint[1])) + { + double sz = loc.StartPoint.Length >= 3 && double.IsFinite(loc.StartPoint[2]) ? loc.StartPoint[2] : 0; + double ez = loc.EndPoint.Length >= 3 && double.IsFinite(loc.EndPoint[2]) ? loc.EndPoint[2] : 0; + sRtc = worldFeetToRtcMetres((loc.StartPoint[0], loc.StartPoint[1], sz)); + eRtc = worldFeetToRtcMetres((loc.EndPoint[0], loc.EndPoint[1], ez)); + } + else if (g.GridPlane is { Envelope.Length: >= 4, Origin.Length: >= 3, XVector.Length: >= 3 } gp) + { + double u0 = gp.Envelope[0], u1 = gp.Envelope[2]; + sRtc = worldFeetToRtcMetres((gp.Origin[0] + u0 * gp.XVector[0], gp.Origin[1] + u0 * gp.XVector[1], gp.Origin[2])); + eRtc = worldFeetToRtcMetres((gp.Origin[0] + u1 * gp.XVector[0], gp.Origin[1] + u1 * gp.XVector[1], gp.Origin[2])); + } + else + { + continue; + } + + double dx = eRtc.X - sRtc.X, dy = eRtc.Y - sRtc.Y; + if (dx * dx + dy * dy < 1e-6) continue; + + float sx = (float)sRtc.X, sy = (float)sRtc.Y, z = (float)gridZRtc; + float ex = (float)eRtc.X, ey = (float)eRtc.Y; + + if (minX > maxX) + { + minX = Math.Min(sx, ex); maxX = Math.Max(sx, ex); + minY = Math.Min(sy, ey); maxY = Math.Max(sy, ey); + minZ = maxZ = z; + } + + list.Add(new SceneGridLine + { + Id = g.Id, + Name = !string.IsNullOrWhiteSpace(g.Name) ? g.Name : $"{list.Count + 1}", + Start = [sx, sy, z], + End = [ex, ey, z], + }); + } + return list; + } + + private sealed record MaterialSpec( + string Key, + int? RevitMaterialId, + string Name, + float[] Color, + float Opacity, + float Metallic, + float Roughness); + + private static MaterialSpec ResolveMaterialSpec( + int materialSlotId, + Element elem, + Element? typeElem, + Dictionary elemById) + { + // Spatial rooms / MEP spaces get clean translucent shading + if (elem.CategoryId == (int)BuiltInCategory.OST_Rooms) + return new MaterialSpec("spatial:room", null, "Room Volume", [0.35f, 0.68f, 0.95f], 0.25f, 0.0f, 0.85f); + if (elem.CategoryId == (int)BuiltInCategory.OST_MEPSpaces) + return new MaterialSpec("spatial:space", null, "MEP Space Volume", [0.25f, 0.82f, 0.68f], 0.25f, 0.0f, 0.85f); + + int resolvedMatId = materialSlotId; + if (resolvedMatId <= 0) + resolvedMatId = FindElementMaterialId(elem) ?? FindElementMaterialId(typeElem) ?? 0; + + if (resolvedMatId > 0 && elemById.TryGetValue(resolvedMatId, out var matElem)) + { + string matName = !string.IsNullOrWhiteSpace(matElem.Name) && !IsArtifactName(matElem.Name) + ? matElem.Name + : $"Material {resolvedMatId}"; + + int? packedColor = matElem.Parameters.FirstOrDefault(p => p.Definition?.Name == "MATERIAL_PARAM_COLOR")?.Value is int pc ? pc : null; + int transparencyPct = matElem.Parameters.FirstOrDefault(p => p.Definition?.Name == "MATERIAL_PARAM_TRANSPARENCY")?.Value is int tr + ? Math.Clamp(tr, 0, 100) : 0; + int shininess = matElem.Parameters.FirstOrDefault(p => p.Definition?.Name == "MATERIAL_PARAM_SHININESS")?.Value is int sh + ? Math.Clamp(sh, 0, 128) : 32; + + float r, g, b; + if (packedColor.HasValue) + { + var (dr, dg, db) = IfcExporter.DecodePackedMaterialColor(packedColor.Value); + r = (float)dr; g = (float)dg; b = (float)db; + } + else + { + var fallback = CategoryPaletteColor(elem.CategoryId); + r = fallback[0]; g = fallback[1]; b = fallback[2]; + } + + float opacity = matElem.SerializedMaterialTransparency is { } storedFrac && storedFrac is >= 0 and <= 1 + ? Math.Clamp(1f - (float)storedFrac, 0.08f, 1f) + : Math.Clamp(1f - transparencyPct / 100f, 0.08f, 1f); + + // Glazing heuristic when material name clearly names glass and lacks explicit transparency param + if (opacity >= 0.99f && IsGlassMaterialName(matName)) + { + opacity = 0.35f; + if (!packedColor.HasValue) { r = 0.62f; g = 0.82f; b = 0.92f; } + } + + bool isMetal = matName.Contains("Metal", StringComparison.OrdinalIgnoreCase) + || matName.Contains("Steel", StringComparison.OrdinalIgnoreCase) + || matName.Contains("Aluminum", StringComparison.OrdinalIgnoreCase) + || matName.Contains("Aluminium", StringComparison.OrdinalIgnoreCase) + || matName.Contains("Chrome", StringComparison.OrdinalIgnoreCase) + || matName.Contains("Copper", StringComparison.OrdinalIgnoreCase); + + float metallic = isMetal ? 0.55f : 0.04f; + float roughness = Math.Clamp(0.85f - (shininess / 128f) * 0.65f, 0.15f, 0.9f); + if (opacity < 0.95f) roughness = Math.Min(roughness, 0.2f); + + return new MaterialSpec($"mat:{resolvedMatId}", resolvedMatId, matName, [r, g, b], opacity, metallic, roughness); + } + + // Category fallback palette + int catKey = elem.CategoryId ?? 0; + string catLabel = elem.CategoryId is int cid && EnglishCategoryNames.Get(cid) is { Length: > 0 } en + ? en + : elem.Category ?? "Default"; + var col = CategoryPaletteColor(elem.CategoryId); + float catOpacity = elem.CategoryId is (int)BuiltInCategory.OST_WindowsGlassProjection + or (int)BuiltInCategory.OST_DoorsGlassProjection ? 0.35f : 1.0f; + + return new MaterialSpec($"cat:{catKey}", null, $"{catLabel} Default", col, catOpacity, 0.05f, 0.68f); + } + + private static int? FindElementMaterialId(Element? elem) + { + if (elem == null) return null; + if (elem.Layers is { Count: > 0 }) + { + foreach (var layer in elem.Layers) + if (layer.MaterialId > 0) return layer.MaterialId; + } + foreach (var p in elem.Parameters) + { + if (p.StorageType == StorageType.ElementId && p.Value is int mid && mid > 0 + && p.Definition?.Name is "STRUCTURAL_MATERIAL_PARAM" or "MATERIAL_ID_PARAM" or "FINISH_MAT_ID") + { + return mid; + } + } + return null; + } + + private static bool IsGlassMaterialName(string name) => + name.Contains("Glass", StringComparison.OrdinalIgnoreCase) + || name.Contains("Glazing", StringComparison.OrdinalIgnoreCase) + || name.Contains("Стекло", StringComparison.OrdinalIgnoreCase) + || name.Contains("Vitrage", StringComparison.OrdinalIgnoreCase) + || name.Contains("Glas", StringComparison.OrdinalIgnoreCase); + + private static float[] CategoryPaletteColor(int? categoryId) => categoryId switch + { + (int)BuiltInCategory.OST_Walls => [0.84f, 0.83f, 0.80f], + (int)BuiltInCategory.OST_Floors => [0.74f, 0.75f, 0.77f], + (int)BuiltInCategory.OST_Roofs => [0.68f, 0.45f, 0.38f], + (int)BuiltInCategory.OST_Ceilings => [0.91f, 0.91f, 0.89f], + (int)BuiltInCategory.OST_Doors => [0.66f, 0.49f, 0.34f], + (int)BuiltInCategory.OST_Windows or (int)BuiltInCategory.OST_WindowsGlassProjection => [0.62f, 0.80f, 0.90f], + (int)BuiltInCategory.OST_CurtainWallPanels => [0.58f, 0.78f, 0.88f], + (int)BuiltInCategory.OST_CurtainWallMullions => [0.38f, 0.41f, 0.45f], + (int)BuiltInCategory.OST_StructuralColumns or (int)BuiltInCategory.OST_Columns => [0.56f, 0.60f, 0.65f], + (int)BuiltInCategory.OST_StructuralFraming => [0.50f, 0.57f, 0.66f], + (int)BuiltInCategory.OST_StructuralFoundation => [0.62f, 0.63f, 0.62f], + (int)BuiltInCategory.OST_Stairs or (int)BuiltInCategory.OST_Ramps => [0.77f, 0.74f, 0.69f], + (int)BuiltInCategory.OST_StairsRailing or (int)BuiltInCategory.OST_Railings => [0.45f, 0.48f, 0.52f], + (int)BuiltInCategory.OST_Furniture or (int)BuiltInCategory.OST_FurnitureSystems => [0.76f, 0.64f, 0.50f], + (int)BuiltInCategory.OST_Casework => [0.80f, 0.73f, 0.63f], + (int)BuiltInCategory.OST_PlumbingFixtures => [0.92f, 0.94f, 0.96f], + (int)BuiltInCategory.OST_MechanicalEquipment => [0.46f, 0.64f, 0.56f], + (int)BuiltInCategory.OST_DuctCurves or (int)BuiltInCategory.OST_DuctFitting or (int)BuiltInCategory.OST_DuctTerminal => [0.44f, 0.68f, 0.84f], + (int)BuiltInCategory.OST_PipeCurves or (int)BuiltInCategory.OST_PipeFitting or (int)BuiltInCategory.OST_PipeAccessory => [0.34f, 0.72f, 0.58f], + (int)BuiltInCategory.OST_Sprinklers => [0.86f, 0.36f, 0.34f], + (int)BuiltInCategory.OST_CableTray or (int)BuiltInCategory.OST_CableTrayFitting + or (int)BuiltInCategory.OST_Conduit or (int)BuiltInCategory.OST_ConduitFitting => [0.85f, 0.68f, 0.32f], + (int)BuiltInCategory.OST_ElectricalEquipment or (int)BuiltInCategory.OST_ElectricalFixtures => [0.82f, 0.62f, 0.34f], + (int)BuiltInCategory.OST_LightingFixtures => [0.94f, 0.88f, 0.58f], + (int)BuiltInCategory.OST_Topography or (int)BuiltInCategory.OST_Site => [0.54f, 0.67f, 0.48f], + (int)BuiltInCategory.OST_Planting => [0.38f, 0.62f, 0.36f], + _ => [0.78f, 0.80f, 0.82f], + }; + + private static List BuildPropertyGroups( + Element elem, + Element? typeElem, + Dictionary elemById, + SceneExportOptions options) + { + var grouped = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + int limit = options.MaxParametersPerElement > 0 ? options.MaxParametersPerElement : int.MaxValue; + int addedCount = 0; + + void AddParametersFrom(IEnumerable parameters, bool fromType) + { + foreach (var p in parameters) + { + if (addedCount >= limit) return; + if (!p.HasValue) continue; + + string? rawName = p.Definition?.Name; + if (IgnoredIdentityParamIds.Contains(p.Id) + || (rawName != null && IgnoredIdentityParamNames.Contains(rawName))) + { + continue; + } + + string groupName; + string displayName; + if (p.Id < 0 && CompactLabels.TryGetLabels(p.Id, out string cGroup, out string cLabel) + && !string.IsNullOrWhiteSpace(cLabel)) + { + bool isInvalidGroup = string.IsNullOrWhiteSpace(cGroup) + || cGroup.Equals("Invalid group", StringComparison.OrdinalIgnoreCase) + || cGroup.Equals("INVALID", StringComparison.OrdinalIgnoreCase); + string cleanGroup = isInvalidGroup ? "Parameters" : cGroup; + groupName = fromType && !cleanGroup.EndsWith(" (Type)", StringComparison.Ordinal) + ? (isInvalidGroup ? "Type Parameters" : $"{cleanGroup} (Type)") + : cleanGroup; + displayName = cLabel; + } + else + { + groupName = fromType ? "Type Parameters" : "Parameters"; + displayName = HumanizeParameterName(rawName ?? $"Parameter {p.Id}"); + } + + if (options.IncludedParameterNames != null + && !options.IncludedParameterNames.Contains(displayName) + && (rawName == null || !options.IncludedParameterNames.Contains(rawName))) + { + continue; + } + + string dedupKey = $"{groupName}::{displayName}"; + if (!seenNames.Add(dedupKey)) + continue; + + string? formattedValue = FormatParameterValue(p, elemById); + if (string.IsNullOrWhiteSpace(formattedValue)) + continue; + + if (!grouped.TryGetValue(groupName, out var list)) + grouped[groupName] = list = new List(); + + list.Add(new SceneProperty { Name = displayName, Value = formattedValue }); + addedCount++; + } + } + + AddParametersFrom(elem.Parameters, fromType: false); + if (options.IncludeTypeParameters && typeElem != null && addedCount < limit) + AddParametersFrom(typeElem.Parameters, fromType: true); + + return grouped + .OrderBy(g => g.Key.EndsWith("(Type)", StringComparison.Ordinal) ? 1 : 0) + .ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .Select(g => new ScenePropertyGroup + { + Group = g.Key, + Properties = g.Value, + }) + .ToList(); + } + + private static string? FormatParameterValue(Parameter p, Dictionary elemById) + { + if (p.StorageType == StorageType.ElementId && p.Value is int refId) + { + if (refId <= 0) return null; + if (elemById.TryGetValue(refId, out var target) && !string.IsNullOrWhiteSpace(target.Name) && !IsArtifactName(target.Name)) + return target.Name; + return $"#{refId}"; + } + + if (p.Value is string s) + return string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + + return p.AsValueString(); + } + + private static string HumanizeParameterName(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return "Parameter"; + if (!raw.Contains('_') || raw.Any(char.IsLower)) return raw; + + string trimmed = raw; + if (trimmed.EndsWith("_PARAM", StringComparison.Ordinal)) + trimmed = trimmed[..^6]; + + var parts = trimmed.Split('_', StringSplitOptions.RemoveEmptyEntries); + return string.Join(' ', parts.Select(w => + w.Length <= 2 ? w : char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant())); + } + + private static HashSet CollectTypeAndSymbolIds(List elements) + { + var ids = new HashSet(); + foreach (var e in elements) + { + if (e.TypeId > 0 && e.TypeId != e.Id) ids.Add(e.TypeId); + if (e.FamilyTypeId > 0 && e.FamilyTypeId != e.Id) ids.Add(e.FamilyTypeId); + if (e.Transform is { SymbolId: > 0 } tr && tr.SymbolId != e.Id) ids.Add(tr.SymbolId); + + if (e.Geometry?.ExactMappedInstances != null) + foreach (var gi in e.Geometry.ExactMappedInstances) + if (gi.SymbolId > 0 && gi.SymbolId != e.Id) ids.Add(gi.SymbolId); + + if (e.Geometry?.Instances != null) + foreach (var gi in e.Geometry.Instances) + if (gi.SymbolId > 0 && gi.SymbolId != e.Id) ids.Add(gi.SymbolId); + + foreach (var p in e.Parameters) + { + if (p.Id is -1002050 or -1002052 && p.Value is int tid && tid > 0 && tid != e.Id) + ids.Add(tid); + } + } + return ids; + } + + private static HashSet CollectCurtainContainerWallIds(List elements, Dictionary elemById) + { + var ids = new HashSet(); + foreach (var child in elements) + { + if (child.CategoryId is not (-2000170 or -2000171)) continue; + if (child.References == null) continue; + foreach (int refId in child.References) + { + if (elemById.TryGetValue(refId, out var host) + && host.CategoryId == (int)BuiltInCategory.OST_Walls + && host.Layers is not { Count: > 0 }) + { + ids.Add(refId); + } + } + } + return ids; + } + + private static bool HasProjectPhaseReference(Element e) + { + object? val = e.Parameters.FirstOrDefault(p => p.Id == -1012100)?.Value; + if (val is not int phase) return false; + return (phase > 0 && (e.References?.Contains(phase) == true || e.SerializedCreatedPhaseId == phase)) + || (phase == 0 && e.SerializedCreatedPhaseId == 0); + } + + private static bool IsOutsideActivePhase(Element elem, int[] phaseOrder, int activePhaseIdx) + { + if (elem.SerializedCreatedPhaseId is int createdId) + { + int cIdx = Array.IndexOf(phaseOrder, createdId); + if (cIdx > activePhaseIdx) return true; + } + if (elem.SerializedDemolishedPhaseId is int demoId && demoId >= 0) + { + int dIdx = Array.IndexOf(phaseOrder, demoId); + if (dIdx >= 0 && dIdx <= activePhaseIdx) return true; + } + return false; + } + + private static bool IsNonPhysicalElement(Element elem, bool includeRooms, HashSet curtainContainerWallIds) + { + if (curtainContainerWallIds.Contains(elem.Id)) + return true; + + int? cid = elem.CategoryId; + if (cid == (int)BuiltInCategory.OST_Rooms || cid == (int)BuiltInCategory.OST_MEPSpaces) + return !includeRooms; + + // Known non-3D / annotation / system / helper categories + if (cid is (int)BuiltInCategory.OST_Levels + or (int)BuiltInCategory.OST_Views + or (int)BuiltInCategory.OST_Sheets + or (int)BuiltInCategory.OST_Materials + or (int)BuiltInCategory.OST_Cameras + or (int)BuiltInCategory.OST_Dimensions + or (int)BuiltInCategory.OST_ProjectInformation + or -2000182 // OST_Reveals (void sweep subtracted from wall) + or (int)BuiltInCategory.OST_SWallRectOpening + or (int)BuiltInCategory.OST_ArcWallRectOpening) + { + return true; + } + + string? cat = elem.Category; + if (!string.IsNullOrEmpty(cat)) + { + if (!includeRooms && (cat.Contains("OST_Rooms", StringComparison.OrdinalIgnoreCase) + || cat.Contains("OST_MEPSpaces", StringComparison.OrdinalIgnoreCase))) + return true; + + if (cat.Contains("Annotation", StringComparison.OrdinalIgnoreCase) + || cat.Contains("RvtLink", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Symbol", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Tag", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Schedule", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Legend", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Titleblock", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Detail", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Camera", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Analytical", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Sketch", StringComparison.OrdinalIgnoreCase) + || cat.Contains("Separation", StringComparison.OrdinalIgnoreCase) + || cat.Contains("HiddenLines", StringComparison.OrdinalIgnoreCase) + || cat.Contains("CenterLine", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool HasRenderableGeometry(GeometryData? geo) => + geo != null && (geo.Faces.Count > 0 || geo.MeshNodes is { Count: > 0 } || geo.Solids is { Count: > 0 }); + + private static Element? ResolveTypeElement(Element elem, Dictionary elemById) + { + int tid = elem.FamilyTypeId > 0 ? elem.FamilyTypeId : elem.TypeId; + if (tid > 0 && tid != elem.Id && elemById.TryGetValue(tid, out var t)) + return t; + return null; + } + + private static string ResolveCategoryDisplayName(Element elem, RevitFileInfo info) + { + if (elem.CategoryId is int cid) + { + if (((BuiltInCategory)cid).TryGetCategoryName(out string? bicName) && !string.IsNullOrWhiteSpace(bicName)) + return bicName; + if (EnglishCategoryNames.Get(cid) is { Length: > 0 } lookupName) + return lookupName; + if (info.CategoryNames != null && info.CategoryNames.TryGetValue(cid, out string? customCat) && !string.IsNullOrWhiteSpace(customCat)) + return customCat; + } + + if (!string.IsNullOrWhiteSpace(elem.Category)) + { + string raw = elem.Category.Trim(); + return raw.StartsWith("OST_", StringComparison.Ordinal) ? raw[4..] : raw; + } + + return "Uncategorized"; + } + + private static string? ResolveFamilyName(Element elem, Element? typeElem) + { + if (!string.IsNullOrWhiteSpace(elem.FamilyName) && !IsArtifactName(elem.FamilyName)) + return elem.FamilyName.Trim(); + if (typeElem != null && !string.IsNullOrWhiteSpace(typeElem.FamilyName) && !IsArtifactName(typeElem.FamilyName)) + return typeElem.FamilyName.Trim(); + return null; + } + + private static string? ResolveTypeName(Element elem, Element? typeElem) + { + if (typeElem != null && !string.IsNullOrWhiteSpace(typeElem.Name) && !IsArtifactName(typeElem.Name)) + return typeElem.Name.Trim(); + return null; + } + + private static string ResolveElementDisplayName(Element elem, string? typeName, string? familyName, string categoryDisplay) + { + if (!string.IsNullOrWhiteSpace(elem.Name) && !IsArtifactName(elem.Name)) + return elem.Name.Trim(); + if (familyName != null && typeName != null && !string.Equals(familyName, typeName, StringComparison.OrdinalIgnoreCase)) + return $"{familyName}: {typeName}"; + if (typeName != null) + return typeName; + if (familyName != null) + return familyName; + return $"{categoryDisplay} #{elem.Id.ToString(CultureInfo.InvariantCulture)}"; + } + + private static string? ResolveLevelName(Element elem, Dictionary elemById) + { + foreach (int pid in LevelParamIds) + { + var p = elem.Parameters.FirstOrDefault(x => x.Id == pid); + if (p?.Value is int lid && lid > 0 && elemById.TryGetValue(lid, out var lvl) && !string.IsNullOrWhiteSpace(lvl.Name)) + return lvl.Name.Trim(); + } + foreach (var p in elem.Parameters) + { + if (p.Definition?.Name != null && LevelParamNames.Contains(p.Definition.Name) + && p.Value is int lid && lid > 0 && elemById.TryGetValue(lid, out var lvl) && !string.IsNullOrWhiteSpace(lvl.Name)) + { + return lvl.Name.Trim(); + } + } + return null; + } + + private static string ResolveSceneTitle(RevitFileInfo info, string? customTitle) + { + if (!string.IsNullOrWhiteSpace(customTitle)) + return customTitle.Trim(); + if (!string.IsNullOrWhiteSpace(info.ProjectInfo?.Name)) + return info.ProjectInfo.Name.Trim(); + if (!string.IsNullOrWhiteSpace(info.Metadata?.LastSavePath)) + { + string saved = info.Metadata.LastSavePath.Trim(); + int slash = Math.Max(saved.LastIndexOf('/'), saved.LastIndexOf('\\')); + string fileName = slash >= 0 ? saved[(slash + 1)..] : saved; + string stem = Path.GetFileNameWithoutExtension(fileName); + if (!string.IsNullOrWhiteSpace(stem)) + return stem; + } + return "Revit Model"; + } + + private static bool IsArtifactName(string name) => + name is "Arial" or "Times New Roman" or "Courier New" or "Verdana" or "Tahoma" or "Calibri" or "Segoe UI" or "Consolas" + || name.StartsWith("SunAndSky", StringComparison.Ordinal) + || name.StartsWith("assetlibrary_base", StringComparison.Ordinal) + || name.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); + + private static List BuildOmittedGroups(Dictionary counts) + { + static string LabelFor(string code) => code switch + { + "internal-definition" => "Internal family and geometry definitions", + "type-or-symbol" => "Element type and symbol definitions", + "non-physical" => "Views, levels, materials, and 2D annotations", + "phase-filtered" => "Demolished or inactive-phase elements", + "filtered-out" => "Excluded by caller export filter", + "missing-geometry" => "Physical elements without decoded 3D geometry", + _ => "Other non-visual records", + }; + + return counts + .Where(kv => kv.Value > 0) + .OrderByDescending(kv => kv.Value) + .ThenBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => new SceneOmittedGroup + { + Code = kv.Key, + Reason = LabelFor(kv.Key), + Count = kv.Value, + }) + .ToList(); + } + + private static List BuildCompletenessNotes( + RevitFileInfo info, + int exportedElements, + int instancedElements, + int meshCount, + int instanceCount, + int missingGeometryCount) + { + var notes = new List(); + if (exportedElements == 0) + { + if (info.GeometryUndecoded) + { + notes.Add("3D geometry was not decoded for this file configuration. Open or export with Revit 2022 layout conversion enabled to extract 3D solids."); + } + else + { + notes.Add("No representable 3D model geometry was found in the selected scope."); + } + } + else + { + notes.Add($"Exported {exportedElements.ToString(CultureInfo.InvariantCulture)} 3D elements across {instanceCount.ToString(CultureInfo.InvariantCulture)} placed mesh instances ({meshCount.ToString(CultureInfo.InvariantCulture)} unique mesh definitions)."); + if (instancedElements > 0) + { + notes.Add($"{instancedElements.ToString(CultureInfo.InvariantCulture)} repeated family instances share symbol geometry buffers."); + } + if (info.GeometryUndecoded) + { + notes.Add("Some newer-format records did not convert to 3D geometry and were omitted."); + } + } + + if (missingGeometryCount > 0 && !(exportedElements == 0 && info.GeometryUndecoded)) + { + notes.Add($"{missingGeometryCount.ToString(CultureInfo.InvariantCulture)} physical model elements had no representable 3D mesh and were omitted."); + } + + return notes; + } +} diff --git a/src/RvtIO/Scene/SceneExportOptions.cs b/src/RvtIO/Scene/SceneExportOptions.cs new file mode 100644 index 0000000..e43447c --- /dev/null +++ b/src/RvtIO/Scene/SceneExportOptions.cs @@ -0,0 +1,78 @@ +using RvtIO.Model; + +namespace RvtIO.Scene; + +/// +/// Options controlling how a is built and exported from a Revit document. +/// +public class SceneExportOptions +{ + /// + /// Optional scene title. When null or empty, the project name or file name is used. + /// + public string? Title { get; set; } + + /// + /// Optional subset of Revit element IDs to include in the scene. Null includes all eligible physical elements. + /// + public IReadOnlySet? ElementIds { get; set; } + + /// + /// Optional subset of category IDs (such as (int)BuiltInCategory.OST_Walls) to include in the scene. + /// Null includes all eligible physical categories. + /// + public IReadOnlySet? CategoryIds { get; set; } + + /// + /// Optional caller predicate to select which elements are included in the scene. + /// + public Func? ElementFilter { get; set; } + + /// + /// Include element parameter metadata in exported elements. Default is true. + /// + public bool IncludeParameters { get; set; } = true; + + /// + /// Include inherited type parameters when is true. Default is true. + /// + public bool IncludeTypeParameters { get; set; } = true; + + /// + /// Optional case-insensitive allow-list of parameter names or display captions to export. + /// When null, all resolved readable parameters up to are included. + /// + public IReadOnlySet? IncludedParameterNames { get; set; } + + /// + /// Maximum number of parameters exported per element (0 or negative for unlimited). Default is 64. + /// + public int MaxParametersPerElement { get; set; } = 64; + + /// + /// Include datum grid lines (OST_Grids) in the exported scene. Default is true. + /// + public bool IncludeGrids { get; set; } = true; + + /// + /// Include translucent spatial volumes for rooms and MEP spaces (OST_Rooms, OST_MEPSpaces). + /// Default is false. + /// + public bool IncludeRooms { get; set; } + + /// + /// Transform model coordinates by into shared/survey coordinates + /// before computing the relative-to-center () origin. Default is false. + /// + public bool ApplySharedCoordinates { get; set; } + + /// + /// Optional collecting elements omitted from the scene export. + /// + public SkipLog? SkipLog { get; set; } + + /// + /// Optional writer for export diagnostic messages. Null (the default) is silent. + /// + public TextWriter? DiagnosticOutput { get; set; } +} diff --git a/src/RvtIO/Scene/SceneExporter.cs b/src/RvtIO/Scene/SceneExporter.cs new file mode 100644 index 0000000..32c44a2 --- /dev/null +++ b/src/RvtIO/Scene/SceneExporter.cs @@ -0,0 +1,116 @@ +using RvtIO.Model; + +namespace RvtIO.Scene; + +/// +/// Builds and exports browser-ready 3D scenes () from Revit documents +/// in either the versioned rvtio-scene/1 JSON contract or binary glTF 2.0 (.glb). +/// +public static class SceneExporter +{ + /// + /// Builds an in-memory from an opened . + /// + /// The source Revit document. + /// Optional filtering, metadata, coordinate, and diagnostic settings. + /// The constructed . + public static ModelScene Build(RvtDocument document, SceneExportOptions? options = null) + { + ArgumentNullException.ThrowIfNull(document); + return SceneBuilder.Build(document.RawData, options); + } + + /// + /// Builds an in-memory from decoded data. + /// + /// The decoded Revit file data. + /// Optional filtering, metadata, coordinate, and diagnostic settings. + /// The constructed . + public static ModelScene Build(RevitFileInfo fileInfo, SceneExportOptions? options = null) + { + ArgumentNullException.ThrowIfNull(fileInfo); + return SceneBuilder.Build(fileInfo, options); + } + + /// + /// Reads a Revit .rvt or .rfa file from disk with 3D geometry decoding enabled and builds a . + /// + /// Path to the .rvt or .rfa file. + /// Optional scene export options. + /// Optional file reader options. When null, and are enabled automatically. + /// The constructed . + public static ModelScene OpenAndBuild(string rvtPath, SceneExportOptions? options = null, RvtReaderOptions? readerOptions = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rvtPath); + var effectiveReaderOptions = readerOptions ?? new RvtReaderOptions + { + R22Layout = true, + SeparateFacesWithDistinctOpenings = true, + SystemFamilyNameLocale = "ENU", + DiagnosticOutput = options?.DiagnosticOutput, + }; + var doc = RvtDocument.Open(rvtPath, effectiveReaderOptions); + return Build(doc, options); + } + + /// + /// Writes a as a versioned rvtio-scene/1 UTF-8 JSON stream. + /// + /// The scene to serialize. + /// Target writable stream. + /// True to format JSON with indentation; false (default) for compact output. + public static void ExportJson(ModelScene scene, Stream output, bool indented = false) => + SceneJsonSerializer.Write(scene, output, indented); + + /// + /// Writes a as a versioned rvtio-scene/1 UTF-8 JSON file. + /// + /// The scene to serialize. + /// Target file path. + /// True to format JSON with indentation; false (default) for compact output. + public static void ExportJson(ModelScene scene, string outputPath, bool indented = false) + { + ArgumentNullException.ThrowIfNull(scene); + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + string? dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + using var fs = File.Create(outputPath); + SceneJsonSerializer.Write(scene, fs, indented); + } + + /// + /// Deserializes a from a rvtio-scene/1 UTF-8 JSON stream. + /// + /// Readable stream containing rvtio-scene/1 JSON. + /// The deserialized . + public static ModelScene LoadJson(Stream input) + { + ArgumentNullException.ThrowIfNull(input); + return SceneJsonSerializer.Read(input); + } + + /// + /// Writes a as a binary glTF 2.0 (.glb) stream. + /// + /// The scene to export. + /// Target writable stream. + /// When true (default), rotates the root glTF node from Revit Z-up to standard glTF Y-up. + public static void ExportGlb(ModelScene scene, Stream output, bool convertZUpToYUp = true) => + GlbSceneWriter.Write(scene, output, convertZUpToYUp); + + /// + /// Writes a as a binary glTF 2.0 (.glb) file. + /// + /// The scene to export. + /// Target .glb file path. + /// When true (default), rotates the root glTF node from Revit Z-up to standard glTF Y-up. + public static void ExportGlb(ModelScene scene, string outputPath, bool convertZUpToYUp = true) + { + ArgumentNullException.ThrowIfNull(scene); + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + string? dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + using var fs = File.Create(outputPath); + GlbSceneWriter.Write(scene, fs, convertZUpToYUp); + } +} diff --git a/src/RvtIO/Scene/SceneGeometryMesher.cs b/src/RvtIO/Scene/SceneGeometryMesher.cs new file mode 100644 index 0000000..8cf0ffb --- /dev/null +++ b/src/RvtIO/Scene/SceneGeometryMesher.cs @@ -0,0 +1,795 @@ +using RvtIO.Geometry.Meshing; +using RvtIO.Model; +using RvtIO.Parsing; + +namespace RvtIO.Scene; + +/// +/// Converts decoded (B-rep mesh nodes, planar faces with openings, or extruded solids) +/// into compact indexed triangle sub-meshes grouped by resolved Revit material ID. +/// +internal static class SceneGeometryMesher +{ + internal const double FeetToMetres = 0.3048; + private const double MeshLod = 0.2; + private const float WeldCellSizeMetres = 0.0001f; // 0.1 mm + + internal sealed record MeshedSubpart( + int MaterialSlotId, + float[] Positions, + float[] Normals, + int[] Indices, + float[] BoundsMin, + float[] BoundsMax); + + internal static List MeshGeometry( + GeometryData geo, + IReadOnlyDictionary? graphicsStyles, + Func<(double X, double Y, double Z), (double X, double Y, double Z)>? worldFeetToRtcMetres = null, + Func<(double X, double Y, double Z), (double X, double Y, double Z)>? worldDirectionMap = null) + { + var builders = new Dictionary(); + SubpartBuilder GetBuilder(int matId) + { + if (!builders.TryGetValue(matId, out var b)) + builders[matId] = b = new SubpartBuilder(matId); + return b; + } + + (double X, double Y, double Z) MapPointFeet(double fx, double fy, double fz) + { + if (worldFeetToRtcMetres != null) + return worldFeetToRtcMetres((fx, fy, fz)); + return (fx * FeetToMetres, fy * FeetToMetres, fz * FeetToMetres); + } + + (double X, double Y, double Z) MapDir(double dx, double dy, double dz) + { + if (worldDirectionMap != null) + return worldDirectionMap((dx, dy, dz)); + return (dx, dy, dz); + } + + int ResolveFaceMaterial(GeoFace? face, int styleOverride = -1) + { + if (face != null) + { + int direct = face.RenderStyleId ?? face.NestedRenderStyleId ?? 0; + if (direct > 0) return direct; + if (face.GeometryStyle > 0 && graphicsStyles != null + && graphicsStyles.TryGetValue(face.GeometryStyle, out var gs) && gs.Material > 0) + return gs.Material; + } + if (styleOverride > 0 && graphicsStyles != null + && graphicsStyles.TryGetValue(styleOverride, out var styleGs) && styleGs.Material > 0) + return styleGs.Material; + if (geo.GStyleElemId > 0 && graphicsStyles != null + && graphicsStyles.TryGetValue(geo.GStyleElemId, out var rootGs) && rootGs.Material > 0) + return rootGs.Material; + return 0; + } + + // 1. Curved / exact B-rep triangle nodes (FaceMesher / FreeformSolid) + var writtenNodes = new HashSet(); + if (GRepParserV2.MeshEnabled && geo.MeshNodes is { Count: > 0 } nodes) + { + foreach (var node in nodes) + { + if (TryAppendMeshNode(geo, node, ResolveFaceMaterial, GetBuilder, MapPointFeet, MapDir)) + writtenNodes.Add(node.Node); + } + } + + // 2. Planar / polygon faces (including inner hole loops via GeTess) + bool hasTransform = geo.Transform is { Length: >= 12 } && geo.Faces.Count > 0 && IsLikelyLocalFaces(geo); + double[]? geoTrf = hasTransform ? geo.Transform : null; + + foreach (var face in geo.Faces) + { + if (face.GeometryNode != 0 && writtenNodes.Contains(face.GeometryNode)) + continue; + if (face.OuterVertices is not { Count: >= 3 }) + continue; + + int matSlot = ResolveFaceMaterial(face); + AppendPlanarFace(face, geoTrf, GetBuilder(matSlot), MapPointFeet, MapDir); + } + + // 3. Fallback extruded solids (e.g. walls with rectangular openings when Faces is empty) + if (builders.Values.All(b => b.Indices.Count == 0) && geo.Solids is { Count: > 0 }) + { + int matSlot = ResolveFaceMaterial(null); + var builder = GetBuilder(matSlot); + foreach (var solid in geo.Solids) + AppendExtrudedSolid(solid, builder, MapPointFeet, MapDir); + } + + var output = new List(builders.Count); + foreach (var kv in builders.OrderBy(k => k.Key)) + { + if (kv.Value.TryBuild(out var subpart)) + output.Add(subpart); + } + return output; + } + + private static bool IsLikelyLocalFaces(GeometryData geo) + { + // ElementBuilder already transforms Faces into world coordinates when expanding symbols; + // geo.Transform is only needed when BoundingBox is absent or matches local unplaced coordinates. + return false; + } + + private static bool TryAppendMeshNode( + GeometryData geo, + GeometryNode node, + Func resolveFaceMaterial, + Func getBuilder, + Func mapPointFeet, + Func mapDir) + { + var nodeFaces = geo.Faces.Where(f => f.GeometryNode == node.Node).ToList(); + if (!node.Ghost && (nodeFaces.Count == 0 || nodeFaces.Any(face => + (face.SourceFace == 0 && face.MeshMarker == 0) + || !node.Faces.Any(mf => (uint)mf.Marker == (face.SourceFace != 0 ? face.SourceFace : face.MeshMarker))))) + { + return false; + } + + var placement = node.Placement; + bool mirroredPlacement = placement is { Length: >= 12 } && DeterminantRowMajor3x4(placement) < 0; + + (double X, double Y, double Z) PlaceFeet(double[] p) + { + if (placement is not { Length: >= 12 }) return (p[0], p[1], p[2]); + return ( + placement[9] + p[0] * placement[0] + p[1] * placement[1] + p[2] * placement[2], + placement[10] + p[0] * placement[3] + p[1] * placement[4] + p[2] * placement[5], + placement[11] + p[0] * placement[6] + p[1] * placement[7] + p[2] * placement[8]); + } + + FreeformSolid? freeformSolid = null; + if (node.Faces.Any(FreeformSolid.IsFreeform)) + { + freeformSolid = FreeformSolid.Create(node.Faces, MeshLod, node.Diagonal); + if (freeformSolid == null) return false; + } + + var faceMeshes = new List<(BrFace Face, Mesh Mesh)>(node.Faces.Count); + foreach (var brFace in node.Faces) + { + Mesh? mesh; + bool ok = FreeformSolid.IsFreeform(brFace) + ? freeformSolid!.TryMesh(brFace, out mesh) + : FaceMesher.TryMesh(GRepParserV2.WithSamples(brFace, MeshLod, node.Diagonal, freeformSolid), MeshLod, node.Diagonal, out mesh); + if (!ok || mesh == null) return false; + faceMeshes.Add((brFace, mesh)); + } + + if (faceMeshes.Sum(fm => fm.Mesh.Triangles.Length) == 0) + return false; + + // Verify bounding box consistency against polygon faces (same guard as IfcExporter.Mesh.cs) + if (!node.Ghost && nodeFaces.Count > 0) + { + double[] meshMin = [double.MaxValue, double.MaxValue, double.MaxValue]; + double[] meshMax = [double.MinValue, double.MinValue, double.MinValue]; + foreach (var (_, m) in faceMeshes) + { + foreach (var v in m.Vertices) + { + var pf = PlaceFeet(v); + meshMin[0] = Math.Min(meshMin[0], pf.X); meshMax[0] = Math.Max(meshMax[0], pf.X); + meshMin[1] = Math.Min(meshMin[1], pf.Y); meshMax[1] = Math.Max(meshMax[1], pf.Y); + meshMin[2] = Math.Min(meshMin[2], pf.Z); meshMax[2] = Math.Max(meshMax[2], pf.Z); + } + } + double[] polyMin = [double.MaxValue, double.MaxValue, double.MaxValue]; + double[] polyMax = [double.MinValue, double.MinValue, double.MinValue]; + foreach (var v in nodeFaces.Where(f => f.OuterVertices != null).SelectMany(f => f.OuterVertices!)) + { + polyMin[0] = Math.Min(polyMin[0], v[0]); polyMax[0] = Math.Max(polyMax[0], v[0]); + polyMin[1] = Math.Min(polyMin[1], v[1]); polyMax[1] = Math.Max(polyMax[1], v[1]); + polyMin[2] = Math.Min(polyMin[2], v[2]); polyMax[2] = Math.Max(polyMax[2], v[2]); + } + for (int k = 0; k < 3; k++) + { + if (Math.Abs(meshMin[k] - polyMin[k]) > 0.05 || Math.Abs(meshMax[k] - polyMax[k]) > 0.05) + return false; + } + } + + // Map marker -> GeoFace for per-face material lookup + var faceByMarker = new Dictionary(); + foreach (var gf in nodeFaces) + { + uint marker = gf.SourceFace != 0 ? gf.SourceFace : gf.MeshMarker; + if (marker != 0) faceByMarker.TryAdd(marker, gf); + } + + foreach (var (brFace, mesh) in faceMeshes) + { + if (mesh.Triangles.Length == 0 || mesh.Vertices.Length == 0) continue; + faceByMarker.TryGetValue((uint)brFace.Marker, out var matchingGeoFace); + int matSlot = resolveFaceMaterial(matchingGeoFace, node.Style); + var builder = getBuilder(matSlot); + + int vCount = mesh.Vertices.Length; + var mappedVerts = new (double X, double Y, double Z)[vCount]; + for (int i = 0; i < vCount; i++) + { + var pf = PlaceFeet(mesh.Vertices[i]); + mappedVerts[i] = mapPointFeet(pf.X, pf.Y, pf.Z); + } + + bool smoothNormals = brFace.SurfaceKind != SurfaceKind.Plane; + if (smoothNormals) + { + var accum = new (double Nx, double Ny, double Nz)[vCount]; + foreach (var tri in mesh.Triangles) + { + if (tri.Length < 3) continue; + int i0 = tri[0], i1 = mirroredPlacement ? tri[2] : tri[1], i2 = mirroredPlacement ? tri[1] : tri[2]; + if ((uint)i0 >= (uint)vCount || (uint)i1 >= (uint)vCount || (uint)i2 >= (uint)vCount) continue; + var p0 = mappedVerts[i0]; var p1 = mappedVerts[i1]; var p2 = mappedVerts[i2]; + var (nx, ny, nz) = Cross(p1.X - p0.X, p1.Y - p0.Y, p1.Z - p0.Z, p2.X - p0.X, p2.Y - p0.Y, p2.Z - p0.Z); + accum[i0] = (accum[i0].Nx + nx, accum[i0].Ny + ny, accum[i0].Nz + nz); + accum[i1] = (accum[i1].Nx + nx, accum[i1].Ny + ny, accum[i1].Nz + nz); + accum[i2] = (accum[i2].Nx + nx, accum[i2].Ny + ny, accum[i2].Nz + nz); + } + + var outIndices = new int[vCount]; + for (int i = 0; i < vCount; i++) + { + var n = NormalizeOrDefault(accum[i].Nx, accum[i].Ny, accum[i].Nz); + outIndices[i] = builder.AddVertex(mappedVerts[i], n); + } + + foreach (var tri in mesh.Triangles) + { + if (tri.Length < 3) continue; + int i0 = tri[0], i1 = mirroredPlacement ? tri[2] : tri[1], i2 = mirroredPlacement ? tri[1] : tri[2]; + if ((uint)i0 >= (uint)vCount || (uint)i1 >= (uint)vCount || (uint)i2 >= (uint)vCount) continue; + builder.AddTriangleIndices(outIndices[i0], outIndices[i1], outIndices[i2]); + } + } + else + { + foreach (var tri in mesh.Triangles) + { + if (tri.Length < 3) continue; + int i0 = tri[0], i1 = mirroredPlacement ? tri[2] : tri[1], i2 = mirroredPlacement ? tri[1] : tri[2]; + if ((uint)i0 >= (uint)vCount || (uint)i1 >= (uint)vCount || (uint)i2 >= (uint)vCount) continue; + builder.AddFlatTriangle(mappedVerts[i0], mappedVerts[i1], mappedVerts[i2], null); + } + } + } + + return true; + } + + private static void AppendPlanarFace( + GeoFace face, + double[]? optionalLocalTrf, + SubpartBuilder builder, + Func mapPointFeet, + Func mapDir) + { + var outerRaw = CleanRingFeet(face.OuterVertices!, optionalLocalTrf); + if (outerRaw.Count < 3) return; + + // Compute outward face normal in feet space + (double Nx, double Ny, double Nz) normalFeet; + if (face.Normal is { Length: >= 3 } fn + && double.IsFinite(fn[0]) && double.IsFinite(fn[1]) && double.IsFinite(fn[2]) + && (fn[0] * fn[0] + fn[1] * fn[1] + fn[2] * fn[2]) > 1e-12) + { + normalFeet = NormalizeOrDefault(fn[0], fn[1], fn[2]); + } + else + { + normalFeet = NewellNormal(outerRaw); + } + + var mappedNormalRaw = mapDir(normalFeet.Nx, normalFeet.Ny, normalFeet.Nz); + var mappedNormal = NormalizeOrDefault(mappedNormalRaw.X, mappedNormalRaw.Y, mappedNormalRaw.Z); + + bool hasHoles = face.InnerVertices is { Count: > 0 }; + + // Fast path for simple triangles and convex quads with no inner openings + if (!hasHoles && outerRaw.Count == 3) + { + var p0 = mapPointFeet(outerRaw[0].X, outerRaw[0].Y, outerRaw[0].Z); + var p1 = mapPointFeet(outerRaw[1].X, outerRaw[1].Y, outerRaw[1].Z); + var p2 = mapPointFeet(outerRaw[2].X, outerRaw[2].Y, outerRaw[2].Z); + builder.AddFlatTriangle(p0, p1, p2, mappedNormal); + return; + } + + if (!hasHoles && outerRaw.Count == 4 && IsConvexQuad(outerRaw, normalFeet)) + { + var p0 = mapPointFeet(outerRaw[0].X, outerRaw[0].Y, outerRaw[0].Z); + var p1 = mapPointFeet(outerRaw[1].X, outerRaw[1].Y, outerRaw[1].Z); + var p2 = mapPointFeet(outerRaw[2].X, outerRaw[2].Y, outerRaw[2].Z); + var p3 = mapPointFeet(outerRaw[3].X, outerRaw[3].Y, outerRaw[3].Z); + // Ensure CCW wrt normalFeet + var c = Cross(outerRaw[1].X - outerRaw[0].X, outerRaw[1].Y - outerRaw[0].Y, outerRaw[1].Z - outerRaw[0].Z, + outerRaw[2].X - outerRaw[0].X, outerRaw[2].Y - outerRaw[0].Y, outerRaw[2].Z - outerRaw[0].Z); + if (Dot(c.X, c.Y, c.Z, normalFeet.Nx, normalFeet.Ny, normalFeet.Nz) >= 0) + { + builder.AddFlatTriangle(p0, p1, p2, mappedNormal); + builder.AddFlatTriangle(p0, p2, p3, mappedNormal); + } + else + { + builder.AddFlatTriangle(p0, p3, p2, mappedNormal); + builder.AddFlatTriangle(p0, p2, p1, mappedNormal); + } + return; + } + + // General polygon with or without holes: project onto orthonormal 2D plane (U, V) where U x V = normalFeet + BuildPlaneBasis(normalFeet, face.XVector, out var uAxis, out var vAxis); + var origin = outerRaw[0]; + + (double U, double V) Project2D((double X, double Y, double Z) pt) + { + double dx = pt.X - origin.X, dy = pt.Y - origin.Y, dz = pt.Z - origin.Z; + return (Dot(dx, dy, dz, uAxis.X, uAxis.Y, uAxis.Z), Dot(dx, dy, dz, vAxis.X, vAxis.Y, vAxis.Z)); + } + + var outer2D = outerRaw.Select(Project2D).ToList(); + if (SignedArea2D(outer2D) < 0) + { + outerRaw.Reverse(); + outer2D.Reverse(); + } + + var all3D = new List<(double X, double Y, double Z)>(outerRaw); + var all2D = new List<(double X, double Y)>(outer2D); + var loops = new List { Enumerable.Range(0, outerRaw.Count).ToArray() }; + var outerFlags = new List { true }; + + if (hasHoles) + { + foreach (var holeRing in face.InnerVertices!) + { + var holeClean = CleanRingFeet(holeRing, optionalLocalTrf); + if (holeClean.Count < 3) continue; + var hole2D = holeClean.Select(Project2D).ToList(); + // GeTess expects holes oriented clockwise (negative signed area) + if (SignedArea2D(hole2D) > 0) + { + holeClean.Reverse(); + hole2D.Reverse(); + } + int startIdx = all3D.Count; + all3D.AddRange(holeClean); + all2D.AddRange(hole2D); + loops.Add(Enumerable.Range(startIdx, holeClean.Count).ToArray()); + outerFlags.Add(false); + } + } + + List<(int A, int B, int C)> tris; + try + { + tris = GeTess.TriangulateProfile(all2D, loops, outerFlags.ToArray()); + } + catch + { + tris = []; + } + + // Fallback fan if GeTess returned nothing on a simple polygon without holes + if (tris.Count == 0 && loops.Count == 1 && outerRaw.Count >= 3) + { + for (int i = 1; i < outerRaw.Count - 1; i++) + tris.Add((0, i, i + 1)); + } + + if (tris.Count == 0) return; + + var mappedPts = new (double X, double Y, double Z)[all3D.Count]; + for (int i = 0; i < all3D.Count; i++) + mappedPts[i] = mapPointFeet(all3D[i].X, all3D[i].Y, all3D[i].Z); + + foreach (var (a, b, c) in tris) + { + if ((uint)a >= (uint)mappedPts.Length || (uint)b >= (uint)mappedPts.Length || (uint)c >= (uint)mappedPts.Length) + continue; + builder.AddFlatTriangle(mappedPts[a], mappedPts[b], mappedPts[c], mappedNormal); + } + } + + private static void AppendExtrudedSolid( + GeoSolid solid, + SubpartBuilder builder, + Func mapPointFeet, + Func mapDir) + { + if (solid.ProfileWidth <= 1e-6 || solid.ProfileDepth <= 1e-6 || solid.ExtrusionHeight <= 1e-6) + return; + if (solid.Position is not { Length: >= 3 } pos) + return; + + var zDir = solid.ExtrusionDirection is { Length: >= 3 } ed + ? NormalizeOrDefault(ed[0], ed[1], ed[2]) + : (Nx: 0.0, Ny: 0.0, Nz: 1.0); + var xRaw = solid.RefDirection is { Length: >= 3 } rd && (rd[0] * rd[0] + rd[1] * rd[1] + rd[2] * rd[2]) > 1e-12 + ? NormalizeOrDefault(rd[0], rd[1], rd[2]) + : (Nx: 1.0, Ny: 0.0, Nz: 0.0); + var yCross = Cross(zDir.Nx, zDir.Ny, zDir.Nz, xRaw.Nx, xRaw.Ny, xRaw.Nz); + var yDir = NormalizeOrDefault(yCross.X, yCross.Y, yCross.Z); + + double hw = solid.ProfileWidth * 0.5; + double hd = solid.ProfileDepth * 0.5; + double h = solid.ExtrusionHeight; + + double[] Pt(double u, double v, double w) => + [ + pos[0] + u * xRaw.Nx + v * yDir.Nx + w * zDir.Nx, + pos[1] + u * xRaw.Ny + v * yDir.Ny + w * zDir.Ny, + pos[2] + u * xRaw.Nz + v * yDir.Nz + w * zDir.Nz, + ]; + + // Collect valid rectangular openings projected onto the (u, w) wall face + var validOpenings = new List<(double u0, double w0, double u1, double w1)>(); + if (solid.Openings is { Count: > 0 }) + { + foreach (var op in solid.Openings) + { + if (op.Width <= 1e-5 || op.Height <= 1e-5 || op.Position is not { Length: >= 3 }) continue; + double cu = op.Position[0], cw = op.Position[2]; + double u0 = Math.Max(-hw + 1e-4, cu - op.Width * 0.5); + double u1 = Math.Min(hw - 1e-4, cu + op.Width * 0.5); + double w0 = Math.Max(0.0, cw - op.Height * 0.5); + double w1 = Math.Min(h, cw + op.Height * 0.5); + if (u1 - u0 > 1e-4 && w1 - w0 > 1e-4) + validOpenings.Add((u0, w0, u1, w1)); + } + } + + // Front (-hd, normal -yDir) and Back (+hd, normal +yDir) faces, with opening holes if any + var frontFace = new GeoFace + { + Normal = [-yDir.Nx, -yDir.Ny, -yDir.Nz], + XVector = [xRaw.Nx, xRaw.Ny, xRaw.Nz], + OuterVertices = [Pt(-hw, -hd, 0), Pt(hw, -hd, 0), Pt(hw, -hd, h), Pt(-hw, -hd, h)], + InnerVertices = validOpenings.Select(o => new List + { + Pt(o.u0, -hd, o.w0), Pt(o.u1, -hd, o.w0), Pt(o.u1, -hd, o.w1), Pt(o.u0, -hd, o.w1), + }).ToList(), + }; + AppendPlanarFace(frontFace, null, builder, mapPointFeet, mapDir); + + var backFace = new GeoFace + { + Normal = [yDir.Nx, yDir.Ny, yDir.Nz], + XVector = [-xRaw.Nx, -xRaw.Ny, -xRaw.Nz], + OuterVertices = [Pt(hw, hd, 0), Pt(-hw, hd, 0), Pt(-hw, hd, h), Pt(hw, hd, h)], + InnerVertices = validOpenings.Select(o => new List + { + Pt(o.u1, hd, o.w0), Pt(o.u0, hd, o.w0), Pt(o.u0, hd, o.w1), Pt(o.u1, hd, o.w1), + }).ToList(), + }; + AppendPlanarFace(backFace, null, builder, mapPointFeet, mapDir); + + // Four perimeter cap faces (bottom, top, left -hw, right +hw) + void AddQuad(double[] a, double[] b, double[] c, double[] d, (double Nx, double Ny, double Nz) n) + { + var nr = mapDir(n.Nx, n.Ny, n.Nz); + var nm = NormalizeOrDefault(nr.X, nr.Y, nr.Z); + var pa = mapPointFeet(a[0], a[1], a[2]); + var pb = mapPointFeet(b[0], b[1], b[2]); + var pc = mapPointFeet(c[0], c[1], c[2]); + var pd = mapPointFeet(d[0], d[1], d[2]); + builder.AddFlatTriangle(pa, pb, pc, nm); + builder.AddFlatTriangle(pa, pc, pd, nm); + } + + AddQuad(Pt(-hw, hd, 0), Pt(hw, hd, 0), Pt(hw, -hd, 0), Pt(-hw, -hd, 0), (-zDir.Nx, -zDir.Ny, -zDir.Nz)); + AddQuad(Pt(-hw, -hd, h), Pt(hw, -hd, h), Pt(hw, hd, h), Pt(-hw, hd, h), zDir); + AddQuad(Pt(-hw, hd, 0), Pt(-hw, -hd, 0), Pt(-hw, -hd, h), Pt(-hw, hd, h), (-xRaw.Nx, -xRaw.Ny, -xRaw.Nz)); + AddQuad(Pt(hw, -hd, 0), Pt(hw, hd, 0), Pt(hw, hd, h), Pt(hw, -hd, h), xRaw); + + // Inner reveal jambs/sill/head for each rectangular opening + foreach (var (u0, w0, u1, w1) in validOpenings) + { + AddQuad(Pt(u0, -hd, w0), Pt(u1, -hd, w0), Pt(u1, hd, w0), Pt(u0, hd, w0), zDir); // sill (faces up) + AddQuad(Pt(u0, hd, w1), Pt(u1, hd, w1), Pt(u1, -hd, w1), Pt(u0, -hd, w1), (-zDir.Nx, -zDir.Ny, -zDir.Nz)); // head (faces down) + AddQuad(Pt(u0, -hd, w0), Pt(u0, hd, w0), Pt(u0, hd, w1), Pt(u0, -hd, w1), xRaw); // left jamb (faces +u) + AddQuad(Pt(u1, hd, w0), Pt(u1, -hd, w0), Pt(u1, -hd, w1), Pt(u1, hd, w1), (-xRaw.Nx, -xRaw.Ny, -xRaw.Nz)); // right jamb (faces -u) + } + } + + internal static double DeterminantRowMajor3x4(double[] t) => + t[0] * (t[4] * t[8] - t[5] * t[7]) + - t[1] * (t[3] * t[8] - t[5] * t[6]) + + t[2] * (t[3] * t[7] - t[4] * t[6]); + + internal static bool TryInvertRowMajor3x4(double[] t, out double[] inv) + { + inv = new double[12]; + if (t.Length < 12 || t.Take(12).Any(v => !double.IsFinite(v))) + return false; + + double det = DeterminantRowMajor3x4(t); + if (Math.Abs(det) < 1e-12) + return false; + + double invDet = 1.0 / det; + inv[0] = (t[4] * t[8] - t[5] * t[7]) * invDet; + inv[1] = (t[2] * t[7] - t[1] * t[8]) * invDet; + inv[2] = (t[1] * t[5] - t[2] * t[4]) * invDet; + + inv[3] = (t[5] * t[6] - t[3] * t[8]) * invDet; + inv[4] = (t[0] * t[8] - t[2] * t[6]) * invDet; + inv[5] = (t[2] * t[3] - t[0] * t[5]) * invDet; + + inv[6] = (t[3] * t[7] - t[4] * t[6]) * invDet; + inv[7] = (t[1] * t[6] - t[0] * t[7]) * invDet; + inv[8] = (t[0] * t[4] - t[1] * t[3]) * invDet; + + inv[9] = -(inv[0] * t[9] + inv[1] * t[10] + inv[2] * t[11]); + inv[10] = -(inv[3] * t[9] + inv[4] * t[10] + inv[5] * t[11]); + inv[11] = -(inv[6] * t[9] + inv[7] * t[10] + inv[8] * t[11]); + return true; + } + + internal static SceneMeshPrototype CreateMirroredVariant(SceneMeshPrototype source, int newIndex) + { + var flipped = new int[source.Indices.Length]; + for (int i = 0; i + 2 < source.Indices.Length; i += 3) + { + flipped[i] = source.Indices[i]; + flipped[i + 1] = source.Indices[i + 2]; + flipped[i + 2] = source.Indices[i + 1]; + } + return new SceneMeshPrototype + { + Index = newIndex, + SourceSymbolId = source.SourceSymbolId, + IsMirroredVariant = true, + Positions = source.Positions, + Normals = source.Normals, + Indices = flipped, + LocalBoundsMin = (float[])source.LocalBoundsMin.Clone(), + LocalBoundsMax = (float[])source.LocalBoundsMax.Clone(), + }; + } + + private static List<(double X, double Y, double Z)> CleanRingFeet(List ring, double[]? trf) + { + var clean = new List<(double X, double Y, double Z)>(ring.Count); + foreach (var pt in ring) + { + if (pt is not { Length: >= 3 } || !double.IsFinite(pt[0]) || !double.IsFinite(pt[1]) || !double.IsFinite(pt[2])) + continue; + double x = pt[0], y = pt[1], z = pt[2]; + if (trf != null) + { + double nx = trf[9] + x * trf[0] + y * trf[1] + z * trf[2]; + double ny = trf[10] + x * trf[3] + y * trf[4] + z * trf[5]; + double nz = trf[11] + x * trf[6] + y * trf[7] + z * trf[8]; + x = nx; y = ny; z = nz; + } + if (clean.Count > 0 && DistSq(clean[^1], (x, y, z)) < 1e-16) + continue; + clean.Add((x, y, z)); + } + while (clean.Count > 2 && DistSq(clean[0], clean[^1]) < 1e-16) + clean.RemoveAt(clean.Count - 1); + return clean; + } + + private static bool IsConvexQuad(List<(double X, double Y, double Z)> q, (double Nx, double Ny, double Nz) normal) + { + double sign = 0; + for (int i = 0; i < 4; i++) + { + var a = q[i]; + var b = q[(i + 1) & 3]; + var c = q[(i + 2) & 3]; + var cr = Cross(b.X - a.X, b.Y - a.Y, b.Z - a.Z, c.X - b.X, c.Y - b.Y, c.Z - b.Z); + double d = Dot(cr.X, cr.Y, cr.Z, normal.Nx, normal.Ny, normal.Nz); + if (Math.Abs(d) <= 1e-12) return false; + if (sign == 0) sign = Math.Sign(d); + else if (Math.Sign(d) != sign) return false; + } + return true; + } + + private static (double Nx, double Ny, double Nz) NewellNormal(List<(double X, double Y, double Z)> pts) + { + double nx = 0, ny = 0, nz = 0; + for (int i = 0; i < pts.Count; i++) + { + var cur = pts[i]; + var nxt = pts[(i + 1) % pts.Count]; + nx += (cur.Y - nxt.Y) * (cur.Z + nxt.Z); + ny += (cur.Z - nxt.Z) * (cur.X + nxt.X); + nz += (cur.X - nxt.X) * (cur.Y + nxt.Y); + } + return NormalizeOrDefault(nx, ny, nz); + } + + private static void BuildPlaneBasis( + (double Nx, double Ny, double Nz) normal, + double[]? hintX, + out (double X, double Y, double Z) uAxis, + out (double X, double Y, double Z) vAxis) + { + if (hintX is { Length: >= 3 } hx && double.IsFinite(hx[0]) && double.IsFinite(hx[1]) && double.IsFinite(hx[2])) + { + double dot = Dot(hx[0], hx[1], hx[2], normal.Nx, normal.Ny, normal.Nz); + double ux = hx[0] - dot * normal.Nx; + double uy = hx[1] - dot * normal.Ny; + double uz = hx[2] - dot * normal.Nz; + double lenSq = ux * ux + uy * uy + uz * uz; + if (lenSq > 1e-10) + { + double inv = 1.0 / Math.Sqrt(lenSq); + uAxis = (ux * inv, uy * inv, uz * inv); + var v = Cross(normal.Nx, normal.Ny, normal.Nz, uAxis.X, uAxis.Y, uAxis.Z); + var vn = NormalizeOrDefault(v.X, v.Y, v.Z); + vAxis = (vn.Nx, vn.Ny, vn.Nz); + return; + } + } + + var seed = Math.Abs(normal.Nz) < 0.9 ? (X: 0.0, Y: 0.0, Z: 1.0) : (X: 1.0, Y: 0.0, Z: 0.0); + var uRaw = Cross(seed.X, seed.Y, seed.Z, normal.Nx, normal.Ny, normal.Nz); + var un = NormalizeOrDefault(uRaw.X, uRaw.Y, uRaw.Z); + uAxis = (un.Nx, un.Ny, un.Nz); + var vRaw = Cross(normal.Nx, normal.Ny, normal.Nz, uAxis.X, uAxis.Y, uAxis.Z); + var vNorm = NormalizeOrDefault(vRaw.X, vRaw.Y, vRaw.Z); + vAxis = (vNorm.Nx, vNorm.Ny, vNorm.Nz); + } + + private static double SignedArea2D(List<(double U, double V)> poly) + { + double sum = 0; + for (int i = 0; i < poly.Count; i++) + { + var a = poly[i]; + var b = poly[(i + 1) % poly.Count]; + sum += a.U * b.V - b.U * a.V; + } + return 0.5 * sum; + } + + private static double DistSq((double X, double Y, double Z) a, (double X, double Y, double Z) b) + { + double dx = a.X - b.X, dy = a.Y - b.Y, dz = a.Z - b.Z; + return dx * dx + dy * dy + dz * dz; + } + + private static double Dot(double ax, double ay, double az, double bx, double by, double bz) => + ax * bx + ay * by + az * bz; + + private static (double X, double Y, double Z) Cross(double ax, double ay, double az, double bx, double by, double bz) => + (ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx); + + private static (double Nx, double Ny, double Nz) NormalizeOrDefault(double x, double y, double z) + { + double lenSq = x * x + y * y + z * z; + if (!(lenSq > 1e-24) || !double.IsFinite(lenSq)) + return (0, 0, 1); + double inv = 1.0 / Math.Sqrt(lenSq); + return (x * inv, y * inv, z * inv); + } + + private sealed class SubpartBuilder + { + private readonly int _materialSlotId; + private readonly List _positions = new(256); + private readonly List _normals = new(256); + private readonly Dictionary<(long, long, long, int, int, int), int> _vertexCache = new(); + public List Indices { get; } = new(256); + + private float _minX = float.MaxValue, _minY = float.MaxValue, _minZ = float.MaxValue; + private float _maxX = float.MinValue, _maxY = float.MinValue, _maxZ = float.MinValue; + + public SubpartBuilder(int materialSlotId) => _materialSlotId = materialSlotId; + + public int AddVertex((double X, double Y, double Z) pos, (double Nx, double Ny, double Nz) norm) + { + float px = (float)pos.X, py = (float)pos.Y, pz = (float)pos.Z; + float nx = (float)norm.Nx, ny = (float)norm.Ny, nz = (float)norm.Nz; + if (!float.IsFinite(px) || !float.IsFinite(py) || !float.IsFinite(pz)) + return -1; + + // Normalize float normal to guarantee unit length for strict glTF 2.0 validators + float nLen = MathF.Sqrt(nx * nx + ny * ny + nz * nz); + if (nLen > 1e-6f && float.IsFinite(nLen)) + { + nx /= nLen; ny /= nLen; nz /= nLen; + } + else + { + nx = 0f; ny = 0f; nz = 1f; + } + + long kx = (long)MathF.Round(px / WeldCellSizeMetres); + long ky = (long)MathF.Round(py / WeldCellSizeMetres); + long kz = (long)MathF.Round(pz / WeldCellSizeMetres); + int knx = (int)MathF.Round(nx * 1000f); + int kny = (int)MathF.Round(ny * 1000f); + int knz = (int)MathF.Round(nz * 1000f); + + var key = (kx, ky, kz, knx, kny, knz); + if (_vertexCache.TryGetValue(key, out int existing)) + return existing; + + int idx = _positions.Count / 3; + _positions.Add(px); _positions.Add(py); _positions.Add(pz); + _normals.Add(nx); _normals.Add(ny); _normals.Add(nz); + + if (px < _minX) _minX = px; if (px > _maxX) _maxX = px; + if (py < _minY) _minY = py; if (py > _maxY) _maxY = py; + if (pz < _minZ) _minZ = pz; if (pz > _maxZ) _maxZ = pz; + + _vertexCache[key] = idx; + return idx; + } + + public void AddTriangleIndices(int i0, int i1, int i2) + { + if (i0 < 0 || i1 < 0 || i2 < 0 || i0 == i1 || i1 == i2 || i0 == i2) + return; + Indices.Add(i0); + Indices.Add(i1); + Indices.Add(i2); + } + + public void AddFlatTriangle( + (double X, double Y, double Z) p0, + (double X, double Y, double Z) p1, + (double X, double Y, double Z) p2, + (double Nx, double Ny, double Nz)? expectedNormal) + { + var cr = Cross(p1.X - p0.X, p1.Y - p0.Y, p1.Z - p0.Z, p2.X - p0.X, p2.Y - p0.Y, p2.Z - p0.Z); + double area2Sq = cr.X * cr.X + cr.Y * cr.Y + cr.Z * cr.Z; + if (!(area2Sq > 1e-20) || !double.IsFinite(area2Sq)) + return; + + (double Nx, double Ny, double Nz) norm; + if (expectedNormal is { } exp) + { + if (Dot(cr.X, cr.Y, cr.Z, exp.Nx, exp.Ny, exp.Nz) < 0) + { + (p1, p2) = (p2, p1); + } + norm = exp; + } + else + { + double inv = 1.0 / Math.Sqrt(area2Sq); + norm = (cr.X * inv, cr.Y * inv, cr.Z * inv); + } + + int i0 = AddVertex(p0, norm); + int i1 = AddVertex(p1, norm); + int i2 = AddVertex(p2, norm); + AddTriangleIndices(i0, i1, i2); + } + + public bool TryBuild(out MeshedSubpart subpart) + { + if (Indices.Count < 3 || _positions.Count < 9) + { + subpart = null!; + return false; + } + subpart = new MeshedSubpart( + _materialSlotId, + _positions.ToArray(), + _normals.ToArray(), + Indices.ToArray(), + [_minX, _minY, _minZ], + [_maxX, _maxY, _maxZ]); + return true; + } + } +} diff --git a/src/RvtIO/Scene/SceneJsonSerializer.cs b/src/RvtIO/Scene/SceneJsonSerializer.cs new file mode 100644 index 0000000..3a29141 --- /dev/null +++ b/src/RvtIO/Scene/SceneJsonSerializer.cs @@ -0,0 +1,498 @@ +using System.Buffers.Binary; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace RvtIO.Scene; + +/// +/// Serializes and deserializes instances using the versioned rvtio-scene/1 JSON contract +/// with compact Base64 little-endian typed geometry buffers. +/// +internal static class SceneJsonSerializer +{ + internal static void Write(ModelScene scene, Stream output, bool indented = false) + { + ArgumentNullException.ThrowIfNull(scene); + ArgumentNullException.ThrowIfNull(output); + + var writerOptions = new JsonWriterOptions + { + Indented = indented, + // Default encoder escapes '<', '>', '&', '+' as \uXXXX, making output safe inside HTML "; + const string imgOnError = " & 'quotes'"; + + var level = new Element + { + Id = 1, + Name = $"Lvl {scriptBreakout}", + IsNativeLevel = true, + CategoryId = (int)BuiltInCategory.OST_Levels, + Category = "OST_Levels", + }; + + var mat = new Element + { + Id = 2, + Name = $"Mat {imgOnError}", + CategoryId = (int)BuiltInCategory.OST_Materials, + Category = "OST_Materials", + Parameters = [SceneExportTests.MakeIntParam(-1001201, "MATERIAL_PARAM_COLOR", 0x00808080)], + }; + + var grid = new Element + { + Id = 3, + Name = $"G{scriptBreakout}", + CategoryId = (int)BuiltInCategory.OST_Grids, + Category = "OST_Grids", + Location = new LocationCurve { CurveType = "Line", StartPoint = [0, 0, 0], EndPoint = [10, 0, 0] }, + }; + + var wall = new Element + { + Id = 10, + UniqueId = $"uid-{scriptBreakout}", + Name = $"Wall {scriptBreakout} {imgOnError}", + FamilyName = $"Fam {scriptBreakout}", + CategoryId = (int)BuiltInCategory.OST_Walls, + Category = "OST_Walls", + Geometry = SceneExportTests.CreateBoxGeometry(0, 0, 0, 8, 1, 9, renderStyleId: 2), + Parameters = + [ + SceneExportTests.MakeElementIdParam(-1001107, "WALL_BASE_CONSTRAINT", 1), + SceneExportTests.MakeStringParam(7001, $"Param {imgOnError}", $"Value {scriptBreakout} {imgOnError}"), + ], + }; + + var info = new RevitFileInfo + { + ProjectInfo = new ProjectInfo { Name = $"Project {scriptBreakout}" }, + Elements = [level, mat, grid, wall], + }; + + var options = new HtmlViewerOptions + { + Title = $"Title {scriptBreakout} {imgOnError}", + }; + + var scene = SceneExporter.Build(info, options); + string html = HtmlViewerExporter.ExportToString(scene, options); + + // 1. Raw XSS payloads MUST NOT appear unescaped anywhere in the HTML document + Assert.DoesNotContain(scriptBreakout, html, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("window.__rvtio_xss", html, StringComparison.OrdinalIgnoreCase); + + // 2. Exactly two ", RegexOptions.IgnoreCase).Count); + + // 3. Parsing the embedded JSON recovers the exact original strings losslessly for safe .textContent rendering + string embeddedJson = ExtractEmbeddedSceneJson(html); + var reloaded = SceneJsonSerializer.ReadFromString(embeddedJson); + var reloadedWall = Assert.Single(reloaded.Elements); + Assert.Equal(wall.Name, reloadedWall.Name); + Assert.Equal(level.Name, reloadedWall.Level); + var allProps = reloadedWall.PropertyGroups.SelectMany(g => g.Properties).ToList(); + var customProp = Assert.Single(allProps, p => p.Name == $"Param {imgOnError}"); + Assert.Equal($"Value {scriptBreakout} {imgOnError}", customProp.Value); + Assert.Contains(allProps, p => p.Name == "Base Constraint" && p.Value == level.Name); + } + + [Fact] + public void ExportHtml_HandlesEmptyAndUndecodedModelsGracefully() + { + var emptyScene = SceneExporter.Build(new RevitFileInfo()); + string emptyHtml = HtmlViewerExporter.ExportToString(emptyScene); + var parsedEmpty = SceneJsonSerializer.ReadFromString(ExtractEmbeddedSceneJson(emptyHtml)); + Assert.Empty(parsedEmpty.Elements); + Assert.NotEmpty(parsedEmpty.Completeness.Notes); + + var undecodedInfo = new RevitFileInfo + { + GeometryUndecoded = true, + Elements = + [ + new Element { Id = 55, Name = "Wall 55", CategoryId = (int)BuiltInCategory.OST_Walls, Category = "OST_Walls" }, + ], + }; + var undecodedScene = SceneExporter.Build(undecodedInfo); + string undecodedHtml = HtmlViewerExporter.ExportToString(undecodedScene); + var parsedUndecoded = SceneJsonSerializer.ReadFromString(ExtractEmbeddedSceneJson(undecodedHtml)); + Assert.True(parsedUndecoded.Completeness.IsPartialGeometry); + Assert.NotEmpty(parsedUndecoded.Completeness.Notes); + Assert.Contains("3D geometry was not decoded", parsedUndecoded.Completeness.Notes[0], StringComparison.Ordinal); + } + + [Fact] + public void ExportHtml_ScalesToThousandsOfElementsAndReportsPartialNewerFormatAccurately() + { + var symbol = new Element + { + Id = 500, + Name = "M_Sprinkler_Pendent", + FamilyName = "M_Sprinkler", + CategoryId = (int)BuiltInCategory.OST_Sprinklers, + Category = "OST_Sprinklers", + Geometry = SceneExportTests.CreateBoxGeometry(-0.2, -0.2, -0.4, 0.2, 0.2, 0), + }; + + var elements = new List(2502) { symbol }; + for (int i = 0; i < 2500; i++) + { + double x = (i % 50) * 10.0; + double y = (i / 50) * 10.0; + elements.Add(new Element + { + Id = 1000 + i, + TypeId = 500, + UniqueId = $"spr-{i:D4}", + Name = $"Sprinkler #{i + 1}", + FamilyName = "M_Sprinkler", + CategoryId = (int)BuiltInCategory.OST_Sprinklers, + Category = "OST_Sprinklers", + Geometry = new GeometryData + { + Instances = [new GeoInstance { SymbolId = 500, Transform = [1, 0, 0, 0, 1, 0, 0, 0, 1, x, y, 9.0] }], + }, + }); + } + + var info = new RevitFileInfo + { + GeometryUndecoded = true, // e.g. a few newer-release analytical records were skipped while 3D instances decoded + Elements = elements, + }; + + var scene = SceneExporter.Build(info); + Assert.Equal(2500, scene.Completeness.ExportedElements); + Assert.Equal(2500, scene.Completeness.InstancedElements); + Assert.Equal(1, scene.Completeness.ReusableMeshCount); + Assert.True(scene.Completeness.IsPartialGeometry); + Assert.Contains("Exported 2500 3D elements", scene.Completeness.Notes[0], StringComparison.Ordinal); + Assert.Contains(scene.Completeness.Notes, n => n.Contains("Some newer-format records", StringComparison.Ordinal)); + Assert.DoesNotContain(scene.Completeness.Notes, n => n.Contains("3D geometry was not decoded", StringComparison.Ordinal)); + + string html = HtmlViewerExporter.ExportToString(scene); + Assert.Contains("TREE_PAGE_SIZE", html, StringComparison.Ordinal); + Assert.Contains("opaqueDrawOrder", html, StringComparison.Ordinal); + Assert.Contains("rayAabbEntry", html, StringComparison.Ordinal); + } + + [Fact] + public void SceneCliOptions_ParsesValidFlagsAndRejectsMalformedArguments() + { + Assert.True(SceneCliOptions.TryParse( + [ + "in.rvt", "out.html", + "--viewer-title", "My Model", + "--viewer-theme", "light", + "--scene-categories", "Walls,OST_Doors,-2000014", + "--scene-ids", "101, 102, 205", + "--scene-parameter-names", "Mark,Fire Rating", + "--scene-shared-coordinates", + "--scene-include-rooms", + "--scene-no-grids", + ], + out var parsed, + out string? error)); + + Assert.Null(error); + Assert.NotNull(parsed); + Assert.Equal("My Model", parsed.Title); + Assert.False(parsed.DarkTheme); + Assert.True(parsed.ApplySharedCoordinates); + Assert.True(parsed.IncludeRooms); + Assert.False(parsed.IncludeGrids); + Assert.NotNull(parsed.CategoryIds); + Assert.Contains((int)BuiltInCategory.OST_Walls, parsed.CategoryIds); + Assert.Contains((int)BuiltInCategory.OST_Doors, parsed.CategoryIds); + Assert.Contains(-2000014, parsed.CategoryIds); + Assert.Equal([101, 102, 205], parsed.ElementIds!.OrderBy(x => x).ToArray()); + Assert.Contains("Mark", parsed.ParameterNames!); + Assert.Contains("Fire Rating", parsed.ParameterNames!); + + // Reject invalid theme + Assert.False(SceneCliOptions.TryParse(["in.rvt", "out.html", "--viewer-theme", "neon"], out _, out string? themeErr)); + Assert.Contains("dark or light", themeErr, StringComparison.Ordinal); + + // Reject unknown category + Assert.False(SceneCliOptions.TryParse(["in.rvt", "out.glb", "--scene-categories", "NotARealCategory"], out _, out string? catErr)); + Assert.Contains("NotARealCategory", catErr, StringComparison.Ordinal); + + // Reject non-integer element ID + Assert.False(SceneCliOptions.TryParse(["in.rvt", "out.glb", "--scene-ids", "100,abc"], out _, out string? idErr)); + Assert.Contains("abc", idErr, StringComparison.Ordinal); + } + + [Fact] + public void CliProcess_ValidatesSceneArgumentsAndPreservesReportAndExitCodes() + { + // --help lists .glb, .html and --scene-json + var (helpCode, helpOut, _) = RunCli("--help"); + Assert.Equal(RvtExitCodes.Success, helpCode); + Assert.Contains("output.glb", helpOut, StringComparison.Ordinal); + Assert.Contains("output.html", helpOut, StringComparison.Ordinal); + Assert.Contains("--scene-json", helpOut, StringComparison.Ordinal); + + // Mutually exclusive flags exit with Usage (1) + Assert.Equal(RvtExitCodes.Usage, RunCli("model.rvt", "model.glb", "--compact-json").ExitCode); + Assert.Equal(RvtExitCodes.Usage, RunCli("model.rvt", "model.html", "--json-full").ExitCode); + Assert.Equal(RvtExitCodes.Usage, RunCli("model.rvt", "model.html", "--scene-json").ExitCode); + Assert.Equal(RvtExitCodes.Usage, RunCli("model.rvt", "model.glb", "--ifc-phase", "last").ExitCode); + Assert.Equal(RvtExitCodes.Usage, RunCli("model.rvt", "model.html", "--viewer-theme", "sepia").ExitCode); + + // Unreadable file with .glb and .html output writes failed --report and exits with InputError (2) + string tempDir = Path.Combine(Path.GetTempPath(), "rvtio-scene-cli-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + string badRvt = Path.Combine(tempDir, "invalid.rvt"); + string outHtml = Path.Combine(tempDir, "viewer.html"); + string reportPath = Path.Combine(tempDir, "report.json"); + File.WriteAllText(badRvt, "not a compound file"); + + var (code, _, _) = RunCli(badRvt, outHtml, "--report", reportPath); + Assert.Equal(RvtExitCodes.InputError, code); + Assert.False(File.Exists(outHtml)); + using var reportJson = JsonDocument.Parse(File.ReadAllText(reportPath)); + Assert.Equal("failed", reportJson.RootElement.GetProperty("status").GetString()); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + private static string ExtractEmbeddedSceneJson(string html) + { + const string openTag = "", start, StringComparison.Ordinal); + Assert.True(end > start, "Closing tag for rvtio-scene-data was not found."); + return html[start..end]; + } + + private static (int ExitCode, string StdOut, string StdErr) RunCli(params string[] arguments) + { + var start = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "RvtIO.Cli.dll")); + foreach (string argument in arguments) + start.ArgumentList.Add(argument); + + using var process = Process.Start(start)!; + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + Assert.True(process.WaitForExit(120_000), "The CLI process timed out."); + Task.WaitAll(stdoutTask, stderrTask); + return (process.ExitCode, stdoutTask.Result, stderrTask.Result); + } +} diff --git a/tests/RvtIO.Tests/RvtIO.Tests.csproj b/tests/RvtIO.Tests/RvtIO.Tests.csproj index c9b32cd..7be7e0f 100644 --- a/tests/RvtIO.Tests/RvtIO.Tests.csproj +++ b/tests/RvtIO.Tests/RvtIO.Tests.csproj @@ -33,6 +33,7 @@ + diff --git a/tests/RvtIO.Tests/SceneExportTests.cs b/tests/RvtIO.Tests/SceneExportTests.cs new file mode 100644 index 0000000..0b9d04e --- /dev/null +++ b/tests/RvtIO.Tests/SceneExportTests.cs @@ -0,0 +1,839 @@ +using System.Buffers.Binary; +using System.Numerics; +using System.Text; +using System.Text.Json; +using RvtIO.BuiltIns; +using RvtIO.Model; +using RvtIO.Scene; +using Xunit; + +namespace RvtIO.Tests; + +/// Tests for Issue #24: browser-ready 3D scene export (rvtio-scene/1 JSON and binary glTF 2.0 .glb). +public class SceneExportTests +{ + private const double FeetToMetres = 0.3048; + + [Fact] + public void PlanarFaceWithInnerWindowOpening_TriangulatesHoleAndExcludesVoidArea() + { + var wall = new Element + { + Id = 101, + UniqueId = "doc-guid-00000065", + Name = "Exterior Wall", + CategoryId = (int)BuiltInCategory.OST_Walls, + Category = "OST_Walls", + Geometry = new GeometryData + { + Faces = + [ + new GeoFace + { + Normal = [0, -1, 0], + PlaneSurface = true, + // 10 ft wide (X: 0..10) by 10 ft tall (Z: 0..10) at Y = 0 => gross 100 sq ft + OuterVertices = + [ + [0, 0, 0], + [10, 0, 0], + [10, 0, 10], + [0, 0, 10], + ], + // 2 ft wide by 4 ft tall window opening => 8 sq ft hole => net 92 sq ft + InnerVertices = + [ + [ + [4, 0, 3], + [4, 0, 7], + [6, 0, 7], + [6, 0, 3], + ], + ], + }, + ], + }, + }; + + var info = new RevitFileInfo { Elements = [wall] }; + var scene = SceneExporter.Build(info); + + Assert.Single(scene.Elements); + var mesh = Assert.Single(scene.Meshes); + Assert.True(mesh.TriangleCount >= 6); + + double expectedAreaSqM = (100.0 - 8.0) * FeetToMetres * FeetToMetres; + double actualAreaSqM = ComputeMeshTriangleArea(mesh); + Assert.Equal(expectedAreaSqM, actualAreaSqM, precision: 4); + + // Every vertex normal must be unit length and point along (0, -1, 0) + for (int v = 0; v < mesh.VertexCount; v++) + { + var n = new Vector3(mesh.Normals[v * 3], mesh.Normals[v * 3 + 1], mesh.Normals[v * 3 + 2]); + Assert.InRange(n.Length(), 0.999f, 1.001f); + Assert.True(Vector3.Dot(n, new Vector3(0, -1, 0)) > 0.99f); + } + + // No triangle centroid may fall inside the window opening (X in 4.1..5.9 m*ft, Z in 3.1..6.9 m*ft) + float minHoleX = (float)(4.1 * FeetToMetres), maxHoleX = (float)(5.9 * FeetToMetres); + float minHoleZ = (float)(3.1 * FeetToMetres), maxHoleZ = (float)(6.9 * FeetToMetres); + for (int t = 0; t < mesh.TriangleCount; t++) + { + int i0 = mesh.Indices[t * 3], i1 = mesh.Indices[t * 3 + 1], i2 = mesh.Indices[t * 3 + 2]; + float cx = (mesh.Positions[i0 * 3] + mesh.Positions[i1 * 3] + mesh.Positions[i2 * 3]) / 3f; + float cz = (mesh.Positions[i0 * 3 + 2] + mesh.Positions[i1 * 3 + 2] + mesh.Positions[i2 * 3 + 2]) / 3f; + bool insideOpening = cx > minHoleX && cx < maxHoleX && cz > minHoleZ && cz < maxHoleZ; + Assert.False(insideOpening, $"Triangle {t} centroid ({cx}, {cz}) fell inside the window opening."); + } + } + + [Fact] + public void SolidFallbackWithRectangularOpening_GeneratesPiercedWallWithReveals() + { + var wall = new Element + { + Id = 102, + Name = "Solid Fallback Wall", + CategoryId = (int)BuiltInCategory.OST_Walls, + Category = "OST_Walls", + Geometry = new GeometryData + { + Solids = + [ + new GeoSolid + { + Position = [10, 0, 0], + RefDirection = [1, 0, 0], + ExtrusionDirection = [0, 0, 1], + ProfileWidth = 20, + ProfileDepth = 1, + ExtrusionHeight = 10, + Openings = + [ + new GeoOpening + { + Position = [0, 0, 3.5], + Width = 4, + Height = 7, + }, + ], + }, + ], + }, + }; + + var scene = SceneExporter.Build(new RevitFileInfo { Elements = [wall] }); + var mesh = Assert.Single(scene.Meshes); + Assert.True(mesh.TriangleCount >= 16); + Assert.Equal(0.0, scene.BoundsMin[0], precision: 3); + Assert.Equal(20.0 * FeetToMetres, scene.BoundsMax[0], precision: 3); + Assert.Equal(10.0 * FeetToMetres, scene.BoundsMax[2], precision: 3); + } + + [Fact] + public void RepeatedFamilyInstances_SharePrototypesAndPreserveOutwardWindingForMirroredTransforms() + { + // Canonical family symbol: 2 ft x 3 ft x 4 ft box centered at local (0, 0, 2) + var symbol = new Element + { + Id = 500, + Name = "Desk 1500x750", + FamilyName = "Office Desk", + CategoryId = (int)BuiltInCategory.OST_Furniture, + Category = "OST_Furniture", + Geometry = CreateBoxGeometry(-1, -1.5, 0, 1, 1.5, 4), + }; + + var elements = new List { symbol }; + for (int i = 0; i < 20; i++) + { + double angle = (i % 4) * (Math.PI / 4.0); + double cos = Math.Cos(angle); + double sin = Math.Sin(angle); + // Odd instances are mirrored across local X (negative determinant!) + double mirrorSign = (i % 2 == 1) ? -1.0 : 1.0; + double tx = (i % 5) * 8.0; + double ty = (i / 5) * 10.0; + double tz = 0.0; + + // GeoInstance.Transform layout: [r00,r01,r02, r10,r11,r12, r20,r21,r22, tx,ty,tz] + // where worldX = tx + x*r00 + y*r01 + z*r02 + double[] trf = + [ + mirrorSign * cos, -sin, 0, + mirrorSign * sin, cos, 0, + 0, 0, 1, + tx, ty, tz, + ]; + + elements.Add(new Element + { + Id = 1000 + i, + TypeId = 500, + UniqueId = $"desk-guid-{i:D4}", + Name = $"Desk Instance {i + 1}", + FamilyName = "Office Desk", + CategoryId = (int)BuiltInCategory.OST_Furniture, + Category = "OST_Furniture", + Geometry = new GeometryData + { + Instances = [new GeoInstance { SymbolId = 500, Transform = trf }], + }, + }); + } + + var scene = SceneExporter.Build(new RevitFileInfo { Elements = elements }); + + Assert.Equal(20, scene.Elements.Count); + Assert.Equal(20, scene.Completeness.ExportedElements); + Assert.Equal(20, scene.Completeness.InstancedElements); + Assert.Equal(20, scene.Instances.Count); + + // Exactly 2 prototypes: 1 normal + 1 mirrored variant (NOT 20 duplicated meshes!) + Assert.Equal(2, scene.Meshes.Count); + Assert.Single(scene.Meshes, m => !m.IsMirroredVariant && m.SourceSymbolId == 500); + Assert.Single(scene.Meshes, m => m.IsMirroredVariant && m.SourceSymbolId == 500); + + // Distinct element identities preserved + Assert.Equal(20, scene.Elements.Select(e => e.Id).Distinct().Count()); + Assert.Equal(20, scene.Elements.Select(e => e.UniqueId).Distinct().Count()); + + // Verify EVERY placed instance (including mirrored ones!) has outward CCW geometric winding AND outward normals + foreach (var inst in scene.Instances) + { + var mesh = scene.Meshes[inst.MeshIndex]; + Matrix4x4 m = ToMatrix4x4(inst.Matrix); + Assert.True(Matrix4x4.Invert(m, out Matrix4x4 inv)); + Matrix4x4 normalMat = Matrix4x4.Transpose(inv); + + Vector3 worldCenter = Vector3.Transform(new Vector3(0, 0, (float)(2.0 * FeetToMetres)), m); + + for (int t = 0; t < mesh.TriangleCount; t++) + { + int i0 = mesh.Indices[t * 3]; + int i1 = mesh.Indices[t * 3 + 1]; + int i2 = mesh.Indices[t * 3 + 2]; + + Vector3 p0 = Vector3.Transform(GetVec3(mesh.Positions, i0), m); + Vector3 p1 = Vector3.Transform(GetVec3(mesh.Positions, i1), m); + Vector3 p2 = Vector3.Transform(GetVec3(mesh.Positions, i2), m); + + Vector3 triCenter = (p0 + p1 + p2) / 3f; + Vector3 outwardFromCenter = Vector3.Normalize(triCenter - worldCenter); + + // Geometric CCW winding normal: (p1 - p0) x (p2 - p0) MUST point outward! + Vector3 ccwFaceNormal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); + Assert.True(Vector3.Dot(ccwFaceNormal, outwardFromCenter) > 0.25f, + $"Instance for element {inst.ElementId} (mirrored={mesh.IsMirroredVariant}) has inward-facing triangle winding."); + + // Inverse-transpose vertex normal MUST agree with geometric CCW face normal! + Vector3 n0 = Vector3.Normalize(Vector3.TransformNormal(GetVec3(mesh.Normals, i0), normalMat)); + Assert.True(Vector3.Dot(n0, ccwFaceNormal) > 0.98f, + $"Instance for element {inst.ElementId} vertex normal disagrees with CCW face normal."); + } + } + } + + [Fact] + public void MaterialsAndTransparency_DecodePackedColorAndEmitBlendAlphaInGlb() + { + // Revit packed color 0x00BBGGRR: R = 64, G = 160, B = 230 + int packedBgr = (230 << 16) | (160 << 8) | 64; + var glassMat = new Element + { + Id = 300, + Name = "Architectural Low-E Glass", + CategoryId = (int)BuiltInCategory.OST_Materials, + Category = "OST_Materials", + Parameters = + [ + MakeIntParam(-1001201, "MATERIAL_PARAM_COLOR", packedBgr), + MakeIntParam(-1001202, "MATERIAL_PARAM_TRANSPARENCY", 70), + ], + }; + + var panelGeo = CreateBoxGeometry(0, 0, 0, 4, 0.1, 8, renderStyleId: 300); + var panel = new Element + { + Id = 301, + Name = "Glazed Curtain Panel", + CategoryId = (int)BuiltInCategory.OST_CurtainWallPanels, + Category = "OST_CurtainWallPanels", + Geometry = panelGeo, + }; + + var scene = SceneExporter.Build(new RevitFileInfo { Elements = [glassMat, panel] }); + + var mat = Assert.Single(scene.Materials); + Assert.Equal(300, mat.RevitMaterialId); + Assert.Equal("Architectural Low-E Glass", mat.Name); + Assert.Equal(64f / 255f, mat.Color[0], precision: 2); + Assert.Equal(160f / 255f, mat.Color[1], precision: 2); + Assert.Equal(230f / 255f, mat.Color[2], precision: 2); + Assert.Equal(0.30f, mat.Opacity, precision: 2); + + using var glbStream = new MemoryStream(); + SceneExporter.ExportGlb(scene, glbStream); + using var gltfDoc = ParseGlbJson(glbStream.ToArray(), out _); + var gltfMat = gltfDoc.RootElement.GetProperty("materials")[0]; + Assert.Equal("BLEND", gltfMat.GetProperty("alphaMode").GetString()); + var bcf = gltfMat.GetProperty("pbrMetallicRoughness").GetProperty("baseColorFactor"); + Assert.Equal(0.30, bcf[3].GetDouble(), precision: 2); + } + + [Fact] + public void LargeGeoreferencedCoordinates_ApplyRtcOffsetWithSubMillimetrePrecision() + { + // Far survey coordinates: ~152.4 km East, ~365.76 km North, ~259 m Elevation + const double baseXFeet = 500_000.0; + const double baseYFeet = 1_200_000.0; + const double baseZFeet = 850.0; + + var wall = new Element + { + Id = 401, + Name = "Georeferenced Wall", + CategoryId = (int)BuiltInCategory.OST_Walls, + Category = "OST_Walls", + Geometry = CreateBoxGeometry(baseXFeet, baseYFeet, baseZFeet, baseXFeet + 12.5, baseYFeet + 1.0, baseZFeet + 10.0), + }; + + // Also attach a shared transform shifting +100,000 ft East and +200,000 ft North + double[] sharedTrf = + [ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1, + 100_000.0, 200_000.0, 50.0, + ]; + + var info = new RevitFileInfo + { + SharedTransform = sharedTrf, + Elements = [wall], + }; + + var scene = SceneExporter.Build(info, new SceneExportOptions { ApplySharedCoordinates = true }); + + Assert.True(scene.RtcOffset[0] > 180_000.0); + Assert.True(scene.RtcOffset[1] > 420_000.0); + + var mesh = Assert.Single(scene.Meshes); + // Local Float32 positions must stay small around the RTC center (< 20 m) + foreach (float coord in mesh.Positions) + Assert.InRange(coord, -20f, 20f); + + // Reconstructed world min/max X in metres must match exact double-precision value within 0.05 mm! + double exactMinWorldX = (baseXFeet + 100_000.0) * FeetToMetres; + double exactMaxWorldX = (baseXFeet + 12.5 + 100_000.0) * FeetToMetres; + double reconstructedMinX = scene.BoundsMin[0] + scene.RtcOffset[0]; + double reconstructedMaxX = scene.BoundsMax[0] + scene.RtcOffset[0]; + + Assert.True(Math.Abs(exactMinWorldX - reconstructedMinX) < 0.00005, + $"World MinX drift {Math.Abs(exactMinWorldX - reconstructedMinX)} m exceeded 0.05 mm."); + Assert.True(Math.Abs(exactMaxWorldX - reconstructedMaxX) < 0.00005, + $"World MaxX drift {Math.Abs(exactMaxWorldX - reconstructedMaxX)} m exceeded 0.05 mm."); + } + + [Fact] + public void SubsetFiltersAndCallerMetadataControls_FilterElementsParametersGridsAndRooms() + { + var level = new Element + { + Id = 10, + Name = "Level 1", + IsNativeLevel = true, + CategoryId = (int)BuiltInCategory.OST_Levels, + Category = "OST_Levels", + }; + + var wallType = new Element + { + Id = 20, + Name = "Generic - 200mm", + CategoryId = (int)BuiltInCategory.OST_Walls, + Category = "OST_Walls", + Parameters = + [ + MakeStringParam(9001, "Fire Rating", "REI 120"), + ], + }; + + var wall = new Element + { + Id = 21, + TypeId = 20, + Name = "Core Wall", + CategoryId = (int)BuiltInCategory.OST_Walls, + Category = "OST_Walls", + Geometry = CreateBoxGeometry(0, 0, 0, 10, 0.65, 10), + Parameters = + [ + MakeElementIdParam(-1001107, "WALL_BASE_CONSTRAINT", 10), + MakeStringParam(9002, "Comments", "Load-bearing core wall"), + MakeStringParam(9003, "Mark", "W-01"), + ], + }; + + var door = new Element + { + Id = 22, + HostId = 21, + Name = "Single Flush 900x2100", + CategoryId = (int)BuiltInCategory.OST_Doors, + Category = "OST_Doors", + Geometry = CreateBoxGeometry(4, -0.1, 0, 7, 0.75, 7), + Parameters = + [ + MakeStringParam(9003, "Mark", "D-101"), + ], + }; + + var grid = new Element + { + Id = 30, + Name = "A", + CategoryId = (int)BuiltInCategory.OST_Grids, + Category = "OST_Grids", + Location = new LocationCurve + { + CurveType = "Line", + StartPoint = [0, -5, 0], + EndPoint = [0, 25, 0], + }, + }; + + var room = new Element + { + Id = 40, + Name = "Lobby 101", + CategoryId = (int)BuiltInCategory.OST_Rooms, + Category = "OST_Rooms", + SerializedSpaceBoundary = CreateBoxGeometry(0, 1, 0, 10, 12, 9), + }; + + var info = new RevitFileInfo { Elements = [level, wallType, wall, door, grid, room] }; + + // 1. Default export: includes Wall + Door + Grid, excludes Room and Type/Level + var defaultScene = SceneExporter.Build(info); + Assert.Equal([21, 22], defaultScene.Elements.Select(e => e.Id).ToArray()); + Assert.Single(defaultScene.Grids); + Assert.Equal("Level 1", defaultScene.Elements.Single(e => e.Id == 21).Level); + Assert.Equal(21, defaultScene.Elements.Single(e => e.Id == 22).HostId); + + // 2. IncludeRooms = true adds translucent room volume + var withRooms = SceneExporter.Build(info, new SceneExportOptions { IncludeRooms = true }); + Assert.Equal([21, 22, 40], withRooms.Elements.Select(e => e.Id).ToArray()); + + // 3. Category filter + IncludedParameterNames + IncludeGrids = false + var filtered = SceneExporter.Build(info, new SceneExportOptions + { + CategoryIds = new HashSet { (int)BuiltInCategory.OST_Walls }, + IncludedParameterNames = new HashSet(StringComparer.OrdinalIgnoreCase) { "Mark" }, + IncludeGrids = false, + }); + var onlyWall = Assert.Single(filtered.Elements); + Assert.Equal(21, onlyWall.Id); + Assert.Empty(filtered.Grids); + var singleProp = Assert.Single(onlyWall.PropertyGroups.SelectMany(g => g.Properties)); + Assert.Equal("Mark", singleProp.Name); + Assert.Equal("W-01", singleProp.Value); + + // 4. IncludeParameters = false omits all property groups + var noParams = SceneExporter.Build(info, new SceneExportOptions { ElementIds = new HashSet { 21 }, IncludeParameters = false }); + Assert.Empty(Assert.Single(noParams.Elements).PropertyGroups); + } + + [Fact] + public void UndecodedOrPartialGeometry_PopulatesUserReadableCompletenessWithoutInternalClassLeaks() + { + var wallWithoutGeo = new Element + { + Id = 701, + Name = "Wall Without Decoded Mesh", + CategoryId = (int)BuiltInCategory.OST_Walls, + Category = "OST_Walls", + }; + + var info = new RevitFileInfo + { + GeometryUndecoded = true, + Elements = [wallWithoutGeo], + }; + + var skipLog = new SkipLog(); + var scene = SceneExporter.Build(info, new SceneExportOptions { SkipLog = skipLog }); + + Assert.True(scene.Completeness.IsPartialGeometry); + Assert.Equal(0, scene.Completeness.ExportedElements); + Assert.NotEmpty(scene.Completeness.Notes); + Assert.NotEmpty(scene.Completeness.OmittedGroups); + Assert.Equal(1, skipLog.Total); + + string combinedText = string.Join(" | ", scene.Completeness.Notes.Concat(scene.Completeness.OmittedGroups.Select(g => g.Reason))); + string[] forbiddenInternalTerms = ["GRep", "TfBlock", "ElemTable", "R22Layout", "Serialized", "FaceMesher"]; + foreach (string term in forbiddenInternalTerms) + Assert.DoesNotContain(term, combinedText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SceneJsonRoundTrip_PreservesGeometryTransformsMetadataGridsAndCompleteness() + { + var info = CreateSampleBuildingModel(); + var original = SceneExporter.Build(info, new SceneExportOptions { Title = "RoundTrip Building", IncludeRooms = true }); + + using var ms = new MemoryStream(); + SceneExporter.ExportJson(original, ms, indented: true); + ms.Position = 0; + var loaded = SceneExporter.LoadJson(ms); + + Assert.Equal(ModelScene.CurrentSchema, loaded.Schema); + Assert.Equal("RoundTrip Building", loaded.Title); + Assert.Equal(original.Units, loaded.Units); + Assert.Equal(original.CoordinateSystem, loaded.CoordinateSystem); + Assert.Equal(original.Materials.Count, loaded.Materials.Count); + Assert.Equal(original.Meshes.Count, loaded.Meshes.Count); + Assert.Equal(original.Instances.Count, loaded.Instances.Count); + Assert.Equal(original.Elements.Count, loaded.Elements.Count); + Assert.Equal(original.Categories.Count, loaded.Categories.Count); + Assert.Equal(original.Grids.Count, loaded.Grids.Count); + Assert.Equal(original.Completeness.ExportedElements, loaded.Completeness.ExportedElements); + + for (int i = 0; i < original.Meshes.Count; i++) + { + Assert.Equal(original.Meshes[i].Indices, loaded.Meshes[i].Indices); + Assert.Equal(original.Meshes[i].Positions.Length, loaded.Meshes[i].Positions.Length); + for (int k = 0; k < original.Meshes[i].Positions.Length; k++) + Assert.Equal(original.Meshes[i].Positions[k], loaded.Meshes[i].Positions[k], precision: 5); + } + + for (int i = 0; i < original.Elements.Count; i++) + { + Assert.Equal(original.Elements[i].Id, loaded.Elements[i].Id); + Assert.Equal(original.Elements[i].UniqueId, loaded.Elements[i].UniqueId); + Assert.Equal(original.Elements[i].Name, loaded.Elements[i].Name); + Assert.Equal(original.Elements[i].Category, loaded.Elements[i].Category); + } + } + + [Fact] + public void GlbExport_WritesSpecCompliantBinaryGltf2WithAlignedBufferViewsAndSharedMeshes() + { + var info = CreateSampleBuildingModel(); + var scene = SceneExporter.Build(info); + + using var ms = new MemoryStream(); + SceneExporter.ExportGlb(scene, ms, convertZUpToYUp: true); + byte[] glb = ms.ToArray(); + + using var gltfDoc = ParseGlbJson(glb, out byte[] binChunk); + var root = gltfDoc.RootElement; + + Assert.Equal("2.0", root.GetProperty("asset").GetProperty("version").GetString()); + Assert.Equal(0, root.GetProperty("scene").GetInt32()); + + // Root node carries Z-up -> Y-up rotation quaternion + var rootNode = root.GetProperty("nodes")[0]; + var rot = rootNode.GetProperty("rotation"); + Assert.Equal(-0.70710678, rot[0].GetDouble(), precision: 5); + Assert.Equal(0.70710678, rot[3].GetDouble(), precision: 5); + + // Every bufferView must be 4-byte aligned and within BIN chunk bounds + foreach (var bv in root.GetProperty("bufferViews").EnumerateArray()) + { + int byteOffset = bv.GetProperty("byteOffset").GetInt32(); + int byteLength = bv.GetProperty("byteLength").GetInt32(); + Assert.Equal(0, byteOffset % 4); + Assert.True(byteOffset >= 0 && byteOffset + byteLength <= binChunk.Length); + } + + // Verify POSITION min/max and unit-length NORMAL values in the binary buffer + var bufferViews = root.GetProperty("bufferViews"); + var accessors = root.GetProperty("accessors"); + foreach (var gltfMesh in root.GetProperty("meshes").EnumerateArray()) + { + foreach (var prim in gltfMesh.GetProperty("primitives").EnumerateArray()) + { + int posAccIdx = prim.GetProperty("attributes").GetProperty("POSITION").GetInt32(); + var posAcc = accessors[posAccIdx]; + var posBv = bufferViews[posAcc.GetProperty("bufferView").GetInt32()]; + int posOffset = posBv.GetProperty("byteOffset").GetInt32(); + int vCount = posAcc.GetProperty("count").GetInt32(); + + float minX = float.MaxValue, maxX = float.MinValue; + for (int v = 0; v < vCount; v++) + { + float x = BinaryPrimitives.ReadSingleLittleEndian(binChunk.AsSpan(posOffset + v * 12, 4)); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + } + Assert.Equal(minX, (float)posAcc.GetProperty("min")[0].GetDouble(), precision: 4); + Assert.Equal(maxX, (float)posAcc.GetProperty("max")[0].GetDouble(), precision: 4); + + int normAccIdx = prim.GetProperty("attributes").GetProperty("NORMAL").GetInt32(); + var normAcc = accessors[normAccIdx]; + var normBv = bufferViews[normAcc.GetProperty("bufferView").GetInt32()]; + int normOffset = normBv.GetProperty("byteOffset").GetInt32(); + for (int v = 0; v < vCount; v++) + { + float nx = BinaryPrimitives.ReadSingleLittleEndian(binChunk.AsSpan(normOffset + v * 12, 4)); + float ny = BinaryPrimitives.ReadSingleLittleEndian(binChunk.AsSpan(normOffset + v * 12 + 4, 4)); + float nz = BinaryPrimitives.ReadSingleLittleEndian(binChunk.AsSpan(normOffset + v * 12 + 8, 4)); + float len = MathF.Sqrt(nx * nx + ny * ny + nz * nz); + Assert.InRange(len, 0.998f, 1.002f); + } + } + } + + // The 3 repeated column family instances must reference the SAME glTF mesh index! + var columnNodes = root.GetProperty("nodes").EnumerateArray() + .Where(n => n.TryGetProperty("extras", out var ex) + && ex.TryGetProperty("elementId", out _) + && ex.TryGetProperty("category", out var cat) + && cat.GetString() == "Structural Columns") + .ToList(); + Assert.Equal(3, columnNodes.Count); + var referencedMeshIndices = columnNodes + .Select(n => n.TryGetProperty("mesh", out var mProp) ? mProp.GetInt32() + : root.GetProperty("nodes")[n.GetProperty("children")[0].GetInt32()].GetProperty("mesh").GetInt32()) + .Distinct() + .ToList(); + Assert.Single(referencedMeshIndices); + } + + internal static RevitFileInfo CreateSampleBuildingModel() + { + var level1 = new Element + { + Id = 1, + Name = "Level 1", + IsNativeLevel = true, + CategoryId = (int)BuiltInCategory.OST_Levels, + Category = "OST_Levels", + }; + + var glassMat = new Element + { + Id = 50, + Name = "Curtain Glazing", + CategoryId = (int)BuiltInCategory.OST_Materials, + Category = "OST_Materials", + Parameters = + [ + MakeIntParam(-1001201, "MATERIAL_PARAM_COLOR", (235 << 16) | (195 << 8) | 120), + MakeIntParam(-1001202, "MATERIAL_PARAM_TRANSPARENCY", 65), + ], + }; + + var colSymbol = new Element + { + Id = 60, + Name = "UC 305x305x97", + FamilyName = "Universal Column", + CategoryId = (int)BuiltInCategory.OST_StructuralColumns, + Category = "OST_StructuralColumns", + Geometry = CreateBoxGeometry(-0.5, -0.5, 0, 0.5, 0.5, 12), + }; + + var slab = new Element + { + Id = 100, + UniqueId = "bldg-slab-0100", + Name = "Ground Floor Slab", + CategoryId = (int)BuiltInCategory.OST_Floors, + Category = "OST_Floors", + Geometry = CreateBoxGeometry(0, 0, -1, 30, 20, 0), + Parameters = [MakeElementIdParam(-1002003, "LEVEL_PARAM", 1)], + }; + + var wall = new Element + { + Id = 101, + UniqueId = "bldg-wall-0101", + Name = "North Facade Wall", + CategoryId = (int)BuiltInCategory.OST_Walls, + Category = "OST_Walls", + Geometry = CreateBoxGeometry(0, 19.3, 0, 30, 20, 12), + Parameters = + [ + MakeElementIdParam(-1001107, "WALL_BASE_CONSTRAINT", 1), + MakeStringParam(8001, "Mark", "W-N1"), + ], + }; + + var window = new Element + { + Id = 102, + UniqueId = "bldg-win-0102", + HostId = 101, + Name = "Fixed Window 2400x1500", + CategoryId = (int)BuiltInCategory.OST_Windows, + Category = "OST_Windows", + Geometry = CreateBoxGeometry(10, 19.2, 3, 18, 20.1, 8, renderStyleId: 50), + }; + + var room = new Element + { + Id = 103, + UniqueId = "bldg-room-0103", + Name = "Open Office", + CategoryId = (int)BuiltInCategory.OST_Rooms, + Category = "OST_Rooms", + SerializedSpaceBoundary = CreateBoxGeometry(1, 1, 0, 29, 19, 10), + }; + + var gridA = new Element + { + Id = 200, + Name = "A", + CategoryId = (int)BuiltInCategory.OST_Grids, + Category = "OST_Grids", + Location = new LocationCurve { CurveType = "Line", StartPoint = [0, -4, 0], EndPoint = [0, 24, 0] }, + }; + + var elements = new List { level1, glassMat, colSymbol, slab, wall, window, room, gridA }; + for (int i = 0; i < 3; i++) + { + double x = 5 + i * 10; + elements.Add(new Element + { + Id = 300 + i, + TypeId = 60, + UniqueId = $"bldg-col-{300 + i}", + Name = $"Column C{i + 1}", + FamilyName = "Universal Column", + CategoryId = (int)BuiltInCategory.OST_StructuralColumns, + Category = "OST_StructuralColumns", + Parameters = [MakeElementIdParam(-1001350, "FAMILY_LEVEL_PARAM", 1)], + Geometry = new GeometryData + { + Instances = + [ + new GeoInstance + { + SymbolId = 60, + Transform = [1, 0, 0, 0, 1, 0, 0, 0, 1, x, 10, 0], + }, + ], + }, + }); + } + + return new RevitFileInfo + { + ProjectInfo = new ProjectInfo { Name = "Sample Office Pavilion" }, + Elements = elements, + }; + } + + internal static GeometryData CreateBoxGeometry( + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ, + int? renderStyleId = null) + { + GeoFace Face(double[] normal, double[][] corners) => new() + { + Normal = normal, + PlaneSurface = true, + RenderStyleId = renderStyleId, + OuterVertices = corners.ToList(), + }; + + return new GeometryData + { + BoundingBox = [minX, minY, minZ, maxX, maxY, maxZ], + Faces = + [ + // Bottom (-Z) + Face([0, 0, -1], [[minX, minY, minZ], [minX, maxY, minZ], [maxX, maxY, minZ], [maxX, minY, minZ]]), + // Top (+Z) + Face([0, 0, 1], [[minX, minY, maxZ], [maxX, minY, maxZ], [maxX, maxY, maxZ], [minX, maxY, maxZ]]), + // Front (-Y) + Face([0, -1, 0], [[minX, minY, minZ], [maxX, minY, minZ], [maxX, minY, maxZ], [minX, minY, maxZ]]), + // Back (+Y) + Face([0, 1, 0], [[maxX, maxY, minZ], [minX, maxY, minZ], [minX, maxY, maxZ], [maxX, maxY, maxZ]]), + // Left (-X) + Face([-1, 0, 0], [[minX, maxY, minZ], [minX, minY, minZ], [minX, minY, maxZ], [minX, maxY, maxZ]]), + // Right (+X) + Face([1, 0, 0], [[maxX, minY, minZ], [maxX, maxY, minZ], [maxX, maxY, maxZ], [maxX, minY, maxZ]]), + ], + }; + } + + internal static Parameter MakeIntParam(int id, string name, int value) => new() + { + Id = id, + StorageType = StorageType.Integer, + Definition = new Definition { Id = id, Name = name }, + Value = value, + }; + + internal static Parameter MakeElementIdParam(int id, string name, int targetId) => new() + { + Id = id, + StorageType = StorageType.ElementId, + Definition = new Definition { Id = id, Name = name }, + Value = targetId, + }; + + internal static Parameter MakeStringParam(int id, string name, string value) => new() + { + Id = id, + StorageType = StorageType.String, + Definition = new Definition { Id = id, Name = name }, + Value = value, + }; + + private static JsonDocument ParseGlbJson(byte[] glb, out byte[] binChunk) + { + Assert.True(glb.Length >= 20); + uint magic = BinaryPrimitives.ReadUInt32LittleEndian(glb.AsSpan(0, 4)); + uint version = BinaryPrimitives.ReadUInt32LittleEndian(glb.AsSpan(4, 4)); + uint totalLen = BinaryPrimitives.ReadUInt32LittleEndian(glb.AsSpan(8, 4)); + + Assert.Equal(0x46546C67u, magic); // "glTF" + Assert.Equal(2u, version); + Assert.Equal((uint)glb.Length, totalLen); + + uint jsonLen = BinaryPrimitives.ReadUInt32LittleEndian(glb.AsSpan(12, 4)); + uint jsonType = BinaryPrimitives.ReadUInt32LittleEndian(glb.AsSpan(16, 4)); + Assert.Equal(0x4E4F534Au, jsonType); // "JSON" + Assert.Equal(0u, jsonLen % 4u); + + int binHeaderPos = 20 + (int)jsonLen; + Assert.True(glb.Length >= binHeaderPos + 8); + uint binLen = BinaryPrimitives.ReadUInt32LittleEndian(glb.AsSpan(binHeaderPos, 4)); + uint binType = BinaryPrimitives.ReadUInt32LittleEndian(glb.AsSpan(binHeaderPos + 4, 4)); + Assert.Equal(0x004E4942u, binType); // "BIN\0" + Assert.Equal(0u, binLen % 4u); + Assert.Equal(binHeaderPos + 8 + (int)binLen, glb.Length); + + binChunk = glb.AsSpan(binHeaderPos + 8, (int)binLen).ToArray(); + string jsonText = Encoding.UTF8.GetString(glb, 20, (int)jsonLen); + return JsonDocument.Parse(jsonText); + } + + private static double ComputeMeshTriangleArea(SceneMeshPrototype mesh) + { + double area = 0; + for (int t = 0; t < mesh.TriangleCount; t++) + { + Vector3 a = GetVec3(mesh.Positions, mesh.Indices[t * 3]); + Vector3 b = GetVec3(mesh.Positions, mesh.Indices[t * 3 + 1]); + Vector3 c = GetVec3(mesh.Positions, mesh.Indices[t * 3 + 2]); + area += 0.5 * Vector3.Cross(b - a, c - a).Length(); + } + return area; + } + + private static Vector3 GetVec3(float[] buffer, int vertexIndex) => + new(buffer[vertexIndex * 3], buffer[vertexIndex * 3 + 1], buffer[vertexIndex * 3 + 2]); + + // System.Numerics.Matrix4x4 uses row-vector convention (v * M), so column-major glTF matrix [m0..m15] + // maps its columns to rows of Matrix4x4 for Vector3.Transform(v, M)! + private static Matrix4x4 ToMatrix4x4(float[] m) => new( + m[0], m[1], m[2], m[3], + m[4], m[5], m[6], m[7], + m[8], m[9], m[10], m[11], + m[12], m[13], m[14], m[15]); +}