diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index adaab88..bb456ce 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,6 +8,7 @@ on: - 'docfx.json' - '.config/dotnet-tools.json' - 'src/IfcIO/**' + - 'src/IfcIO.Scene/**' - '.github/workflows/docs.yml' pull_request: paths: @@ -15,6 +16,7 @@ on: - 'docfx.json' - '.config/dotnet-tools.json' - 'src/IfcIO/**' + - 'src/IfcIO.Scene/**' - '.github/workflows/docs.yml' workflow_dispatch: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daf36a4..c61568e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,17 @@ jobs: if ($env:GITHUB_REF_NAME -and $env:GITHUB_REF_NAME.StartsWith("v") -and $env:GITHUB_REF_NAME -cne "v$($version.Trim())") { throw "The release tag must match the package version v$($version.Trim())." } + # Before anything is published: a commit without a changelog section for the version fails here. + - name: Extract release notes + shell: pwsh + run: | + $version = if ($env:GITHUB_REF_NAME -and $env:GITHUB_REF_NAME.StartsWith("v")) { $env:GITHUB_REF_NAME.Substring(1) } else { (dotnet msbuild src/IfcIO/IfcIO.csproj -getProperty:Version -nologo).Trim() } + $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 + $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) + if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } + $notes = @() + for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } + $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 - run: dotnet restore IfcIO.slnx - run: dotnet build IfcIO.slnx -c Release --no-restore - run: dotnet test IfcIO.slnx -c Release --no-build @@ -37,39 +48,38 @@ jobs: with: name: packages path: artifacts/* + # The push comes before the GitHub release: a refused first publication of a package id can be fixed and the + # job run again, because --skip-duplicate makes the push repeatable. + # Trusted publishing: the NuGet policy for this repository and workflow file exchanges the GitHub OIDC token for a short-lived key. + - uses: NuGet/login@v1 + id: nuget-login + with: + user: mahach + - name: Publish NuGet packages + shell: pwsh + run: | + foreach ($package in Get-ChildItem artifacts -Filter *.nupkg) { + dotnet nuget push $package.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate + if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($package.Name) with exit code $LASTEXITCODE" } + } - name: Create or update release shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $version = if ($env:GITHUB_REF_NAME -and $env:GITHUB_REF_NAME.StartsWith("v")) { $env:GITHUB_REF_NAME.Substring(1) } else { (dotnet msbuild src/IfcIO/IfcIO.csproj -getProperty:Version -nologo).Trim() } - $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 - $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) - if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } - $notes = @() - for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } - $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 $files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName }) $tagName = if ($env:GITHUB_REF_NAME -and $env:GITHUB_REF_NAME.StartsWith("v")) { $env:GITHUB_REF_NAME } else { "v$version" } + # A tag with a hyphen is a prerelease. + $flags = @() + if ($tagName.Contains('-')) { $flags += '--prerelease' } $exists = gh release view $tagName 2>&1 if ($LASTEXITCODE -eq 0) { Write-Host "Updating existing release $tagName..." - gh release edit $tagName --notes-file release-notes.md + gh release edit $tagName --notes-file release-notes.md @flags gh release upload $tagName @files --clobber } else { Write-Host "Creating release $tagName..." - gh release create $tagName --title "IfcIO $tagName" --notes-file release-notes.md @files - } - # Trusted publishing: the NuGet policy for this repository and workflow file exchanges the GitHub OIDC token for a short-lived key. - - uses: NuGet/login@v1 - id: nuget-login - with: - user: mahach - - name: Publish NuGet packages - shell: pwsh - run: | - foreach ($package in Get-ChildItem artifacts -Filter *.nupkg) { - dotnet nuget push $package.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate - if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($package.Name) with exit code $LASTEXITCODE" } + gh release create $tagName --title "IfcIO $tagName" --notes-file release-notes.md @flags @files } diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e60ac..f63748c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the `IfcIO` library are documented in this file. ## [Unreleased] +### Added +- **`IfcIO.Scene`**: `IfcSceneReader` converts an IFC file or `IfcModel` into an `IOScene` `SceneDocument` in metres with Z up (GlobalIds as `SourceId` and `IfcGuid`, storeys as levels and tree nodes, property and quantity sets keyed `ifc:`, materials, 4D schedules), with diagnostics for elements without geometry. See `docs/scene-adapter.md`. +- **`IfcIO.Scene` NuGet package**: published on the `IfcIO` release tag and depends on exactly the same `IfcIO` version (`[X.Y.Z]`). It needs the released `IOScene` 0.1.0; the release fails while it would depend on an `IOScene` preview. + ### Fixed - **World placement of geometry read from IFC (`IfcModel.Open`)**: element meshes are now in world coordinates. Previously object placements were ignored and every element sat at its local origin. - `IfcLocalPlacement` chains (`PlacementRelTo`) are resolved up to the root, relative to the `WorldCoordinateSystem` of the 3D `Model` representation context. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bdcc574..8e9ca8c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,15 @@ Report security vulnerabilities privately through the **Security** tab. - Keep nullable reference types enabled. - Warnings are treated as errors. +## Scene adapter + +`src/IfcIO.Scene` converts IfcIO models into `IOScene` documents and uses only the public API of `IfcIO`; the `IfcIO` +package itself keeps no dependencies. `IOScene` is referenced with the range `[0.1.0-preview.2, 0.2.0-0)`. To build +against an `IOScene` preview that is not on NuGet.org yet, restore from a local folder of packages: +`dotnet build IfcIO.slnx -p:RestoreAdditionalProjectSources=`. Changing the `SceneModel.Key`, `SourceId` or +`IfcGuid` the adapter writes, its `ifc:` key format, or the value form (`Text`, `Kind`, `Unit`) it writes under a key +fails the golden identity test and is a breaking release. + ## Releases & NuGet Publishing Push a tag matching the package version, such as `v0.1.0`, to trigger the `release.yml` workflow. diff --git a/Directory.Packages.props b/Directory.Packages.props index cd9da25..7d7db8e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/IfcIO.slnx b/IfcIO.slnx index 3c6feca..6cf200b 100644 --- a/IfcIO.slnx +++ b/IfcIO.slnx @@ -2,8 +2,10 @@ + + diff --git a/README.md b/README.md index 9e0f49b..c9eae5c 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ model.Save("Building_4D_5D.ifc"); See the [public API](docs/public-api.md) for the supported surface and [getting started guide](docs/getting-started.md) for detailed workflows. +`IfcIO.Scene` (a separate package released with `IfcIO`, from `IOScene` 0.1.0 on) converts IFC models into `IOScene` scene documents for viewers and coordination tools; see the [scene adapter](docs/scene-adapter.md) page for usage and limits. + ## Documentation The documentation site with API references and guides is at . [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/IOExtend/IfcIO) offers an automatically generated overview of the repository. diff --git a/docfx.json b/docfx.json index af85fd5..7bcdef3 100644 --- a/docfx.json +++ b/docfx.json @@ -2,7 +2,8 @@ "metadata": [ { "src": [ - { "src": "src/IfcIO", "files": ["IfcIO.csproj"] } + { "src": "src/IfcIO", "files": ["IfcIO.csproj"] }, + { "src": "src/IfcIO.Scene", "files": ["IfcIO.Scene.csproj"] } ], "dest": "docs/api", "properties": { "TargetFramework": "net10.0" }, diff --git a/docs/scene-adapter.md b/docs/scene-adapter.md new file mode 100644 index 0000000..34a2b58 --- /dev/null +++ b/docs/scene-adapter.md @@ -0,0 +1,104 @@ +--- +title: Scene adapter +--- + +# IfcIO.Scene: IFC to IOScene + +`IfcIO.Scene` converts an IFC file read by IfcIO into an `IOScene` `SceneDocument`: one scene model in metres with +Z up, with elements, storeys, property sets, quantities, materials and 4D schedules. Viewers and coordination tools +that read scenes work from it. The scene model and its rules are specified in the +[IOScene contract](https://github.com/IOExtend/IOScene/blob/main/docs/contract.md); property keys are registered in +its [key registry](https://github.com/IOExtend/IOScene/blob/main/docs/keys.md). + +Status: preview. The `IfcIO.Scene` package is released with IfcIO, on the same tag, and depends on exactly the same +`IfcIO` version. Its first release needs `IOScene` 0.1.0: until then it references `IOScene` with the range +`[0.1.0-preview.2, 0.2.0-0)` and a release fails on purpose. + +## Usage + +```csharp +using IfcIO.Scene; +using IOScene; + +var result = IfcSceneReader.Read("house.ifc"); +foreach (var diagnostic in result.Diagnostics) +{ + Console.WriteLine($"{diagnostic.Severity} {diagnostic.Code} {diagnostic.SourceId}: {diagnostic.Message}"); +} +SceneJson.Save(result.Document, "house.ioscene.json"); +``` + +`IfcSceneReader.Read(path, options)` opens `.ifc` and `.ifczip` files with `IfcModel.Open`. A model already in memory +is converted with `IfcSceneReader.Read(model, options)`; it has no file name, so `IfcSceneOptions.ModelKey` is +required. An empty `ModelKey` throws `ArgumentException` in both methods. `IfcSceneOptions.IncludeSpaces` also converts spaces (off by default, because closed room volumes hide the +elements inside them). + +`IfcSceneResult` holds the `Document`, the `Diagnostics` of the conversion, the model's `LengthUnit` and the exact +`MetresPerUnit` factor, and the counts `ElementCount`, `ElementsWithGeometry`, `GeometryCount` and `SkippedTasks`. + +## Mapping + +| IFC, as IfcIO reads it | Scene | +|---|---| +| the file | one `SceneModel`: `Key` = `ModelKey`, else the file name without directory (`house.ifc`); `SourceFormat` `ifc`; `SourceLengthUnit` `mm`, `m`, `ft` or `in`; `Producer` `IfcIO.Scene ` | +| storey | a `SceneLevel` (elevation in metres) and a tree node of kind `Level` with the storey's GlobalId | +| element (`IfcModel.AllElements`) | a `SceneElement` of kind `Element`, child of its storey node: `Id` = STEP id, `SourceId` = `IfcGuid` = GlobalId, `Category` = IFC class in upper case, `TypeName` = type product name | +| property set, quantity set | a `ScenePropertySet` with key `ifc:`, properties `ifc:.`; a set shared by elements in the file is one scene set. `PropertySetIds` lists the element's property sets, then its quantity sets | +| property value | `Text`, as IfcIO reads it | +| count quantity | `Number` with `Unit` null (dimensionless) | +| length, area, volume, weight quantity | `Text` only: the number as IfcIO reads it, in the file's units, not converted | +| element mesh | one `SceneGeometry` (triangles, metres, world coordinates) placed once by a `SceneInstance` without transform | +| surface style colour | a `SceneMaterial` (alpha is the opacity); elements without one get a colour for their IFC class on the instance | +| work schedule | a `SceneSchedule` per work schedule with tasks; task `Id` = task GlobalId, elements by scene id | + +`(SceneModel.Key, SourceId)` is the identity that may be stored outside the scene. How this adapter derives both, and +`IfcGuid`, the `ifc:` key format and the value form (`Text`, `Kind`, `Unit`) it writes under each key, are frozen and +covered by a golden identity test (`tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt`); changing any of them +is a breaking release. + +### Values + +When a `SceneValue` has a `Number`, its `Unit` is authoritative and a null `Unit` means dimensionless. The adapter +converts no property or quantity value to SI, so it writes a number only when the value is dimensionless: + +- A count quantity (`IfcQuantityCount`) is `Number` with `Unit` null; `Text` is the same number. +- Length, area, volume and weight quantities are in the file's units, which IfcIO does not report for quantities, so + they are `Kind = Text` with the number only in `Text` (invariant culture, as IfcIO reads it). IfcIO does not read + time quantities. +- Property values are `Text` as IfcIO reads them. IfcIO keeps a value read from a file as text without its measure + type (`IfcLengthMeasure`, `IfcInteger`, ...), so the adapter cannot tell a dimensionless value from a dimensioned + one and does not type it. + +The value form written under each key (`Text`, `Kind`, `Unit`) is frozen with the key format. + +### Coordinates + +Vertices are in world coordinates, in metres, and every instance has no transform, which the contract allows. A file +placed far from the origin (for example at map coordinates) therefore gives large vertex coordinates; a consumer +rebases them, for example on `ComputeBounds().Center`, before narrowing them to float32. + +## Limits + +- **IFC4 tessellated geometry is not read.** IfcIO's reader returns no mesh for `IfcTriangulatedFaceSet`, including + the one IfcIO itself writes for IFC4 models: the point and index lists lose their inner brackets while being parsed, + so no triangle is found. Such elements are in the scene without geometry and reported as `no-geometry`. Verified + with an IFC4 model saved by IfcIO and opened again; a test pins this behaviour. +- Mapped items (`IfcMappedItem`) are baked into each element's world mesh, so shared geometry is not shared in the + scene. +- IfcIO places an element in a storey only through `IfcRelContainedInSpatialStructure`. Elements contained in the site + or building, and spaces aggregated to a storey (`IfcRelAggregates`), are in the first storey. A file without storeys + gets IfcIO's default storey `Level 1`, which has no GlobalId. Site, building and project are not tree nodes. +- IfcIO lists type objects (`IfcWallType`, `IfcDoorStyle`) and door and window lining and panel properties among the + elements; the adapter skips them. Type property sets are not read yet; when they are, they will use the same + `ifc:` keys and come after the occurrence sets in `PropertySetIds`, so a lookup by key finds the occurrence value + first. +- Property values are text even when the IFC value is a number or a boolean: IfcIO keeps only the text. +- Length, area, volume and weight quantities are text in file units. For a file whose length unit IfcIO converts to + metres while reading (for example centimetres), geometry is in metres but quantities stay in file units. +- An element without a GlobalId in the file gets a new random GlobalId from IfcIO each time it is read, so its + `SourceId` is not stable. +- Transparency is not read from files by IfcIO; materials built in memory keep their alpha. +- Tasks without planned start and finish are left out (IfcIO reads task times from IFC4 files only); their subtasks + are kept. Task costs and progress are not converted. Openings, ports, grids, zones, systems and cost schedules are + not converted. +- The application, author and creation date of the file are not read by IfcIO, so the scene model leaves them empty. diff --git a/docs/toc.yml b/docs/toc.yml index 9382dcb..ece455a 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -5,6 +5,8 @@ items: - name: Public API href: public-api.md + - name: Scene adapter + href: scene-adapter.md - name: API reference href: api/ - name: GitHub diff --git a/src/IfcIO.Scene/IfcIO.Scene.csproj b/src/IfcIO.Scene/IfcIO.Scene.csproj new file mode 100644 index 0000000..3e837a5 --- /dev/null +++ b/src/IfcIO.Scene/IfcIO.Scene.csproj @@ -0,0 +1,30 @@ + + + + Converts IFC models read by IfcIO into IOScene documents in metres with Z up, keeping GlobalIds, storeys, property sets, quantities and 4D schedules. + true + + $(NoWarn);NU5104 + + + + + + + + + + + + + + + + + <_Pinned Include="@(_ProjectReferencesWithVersions)">[%(_ProjectReferencesWithVersions.ProjectVersion)] + <_ProjectReferencesWithVersions Remove="@(_ProjectReferencesWithVersions)" /> + <_ProjectReferencesWithVersions Include="@(_Pinned)" /> + + + + diff --git a/src/IfcIO.Scene/IfcSceneOptions.cs b/src/IfcIO.Scene/IfcSceneOptions.cs new file mode 100644 index 0000000..beeefa1 --- /dev/null +++ b/src/IfcIO.Scene/IfcSceneOptions.cs @@ -0,0 +1,21 @@ +namespace IfcIO.Scene; + +/// +/// Options for . +/// +public sealed class IfcSceneOptions +{ + /// + /// The key of the scene model (SceneModel.Key), which with each element's SourceId forms the identity + /// that may be stored outside the scene. Null uses the file name without directory, for example house.ifc. + /// Required when the model is converted from memory, where there is no file name. An empty key is invalid, and + /// throws for it. + /// + public string? ModelKey { get; set; } + + /// + /// Also converts spaces (IfcSpace, ), drawn translucent unless their + /// material says otherwise. False by default, because closed room volumes hide the elements inside them. + /// + public bool IncludeSpaces { get; set; } +} diff --git a/src/IfcIO.Scene/IfcSceneReader.cs b/src/IfcIO.Scene/IfcSceneReader.cs new file mode 100644 index 0000000..605d95a --- /dev/null +++ b/src/IfcIO.Scene/IfcSceneReader.cs @@ -0,0 +1,366 @@ +using System.Globalization; +using System.Reflection; +using IfcIO.Model; +using IfcIO.Model.Sequence; +using IfcIO.Schema; +using IOScene; + +namespace IfcIO.Scene; + +/// +/// Converts an IFC file, or an read by IfcIO, into a . +/// +/// +/// +/// The scene has one : Key is , else the file +/// name without directory; SourceFormat is ifc. Geometry is IfcIO's world mesh of each element +/// (), scaled from to metres by an exact +/// factor; IFC is Z up like the scene, so axes are not swapped. Each mesh becomes one geometry placed once, without +/// a transform. +/// +/// +/// Each storey becomes a level (elevation in metres) and a tree node of kind . +/// Each element of the storey (and each space with ) becomes a child of +/// that node, except the type objects (for example IFCWALLTYPE, IFCDOORSTYLE) and lining and panel +/// properties that IfcIO also lists as elements. is , which +/// for a model opened from a file is the STEP entity id (#123 gives 123); +/// and are the GlobalId; is the IFC class as +/// IfcIO reports it (upper case, for example IFCWALL). IfcIO places an element in a storey only through +/// IfcRelContainedInSpatialStructure, so elements contained in the site or building, and spaces aggregated to +/// a storey, are in the first storey. +/// +/// +/// Property sets and quantity sets become property sets keyed ifc:<SetName>, their properties +/// ifc:<SetName>.<Name>; a set that IfcIO shares between elements is one set in the scene. +/// Occurrence sets come first in , property sets before quantity sets; type +/// property sets are not read. A number is written only when it is dimensionless, since a null +/// means dimensionless and the adapter converts no value to SI. Property values are text +/// as IfcIO reads them, because IfcIO does not report the measure of a value read from a file. A count quantity is a +/// number with a null unit; length, area, volume and weight quantities are in the file's units, so they are text with +/// the number as IfcIO reads it. Colour and transparency come from ; +/// elements without one get a colour for their IFC class. Work schedules become schedules. Openings are not converted. +/// +/// +public static class IfcSceneReader +{ + private const int ModelId = 1; + + /// + /// Reads the IFC file at (.ifc or .ifczip) with + /// and converts it. + /// + /// The IFC file. + /// Conversion options; null uses the defaults. + /// The scene with a report of the conversion. + /// + /// is empty, or two converted elements have the same . + /// + public static IfcSceneResult Read(string path, IfcSceneOptions? options = null) + { + ArgumentException.ThrowIfNullOrEmpty(path); + options ??= new IfcSceneOptions(); + if (options.ModelKey is "") + { + throw new ArgumentException("IfcSceneOptions.ModelKey must not be empty; leave it null to use the file name.", nameof(options)); + } + return Convert(IfcModel.Open(path), options.ModelKey ?? Path.GetFileName(path), path, options); + } + + /// + /// Converts , which has no file name, so is required. + /// + /// The model, for example from . + /// Conversion options with . + /// The scene with a report of the conversion. + /// + /// is empty, or two converted elements have the same . + /// + public static IfcSceneResult Read(IfcModel model, IfcSceneOptions options) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrEmpty(options.ModelKey)) + { + throw new ArgumentException("A model converted from memory needs IfcSceneOptions.ModelKey.", nameof(options)); + } + return Convert(model, options.ModelKey, null, options); + } + + private static IfcSceneResult Convert(IfcModel model, string key, string? path, IfcSceneOptions options) + { + double scale = MetresPerUnit(model.LengthUnit); + var doc = new SceneDocument { Title = model.ProjectName }; + doc.Models.Add(new SceneModel + { + Id = ModelId, + Key = key, + Name = model.ProjectName, + Path = path, + SourceFormat = "ifc", + SourceLengthUnit = UnitSymbol(model.LengthUnit), + Producer = Producer, + }); + + var diagnostics = new List(); + var materials = new Dictionary(ReferenceEqualityComparer.Instance); + var sets = new Dictionary(ReferenceEqualityComparer.Instance); + var converted = new Dictionary(ReferenceEqualityComparer.Instance); + var ids = new HashSet(); + int withGeometry = 0; + + // Storey nodes take ids above every element id, which are STEP ids for a model read from a file. + long nextNodeId = model.AllElements.Concat(model.AllSpaces).Select(e => (long)e.Id).DefaultIfEmpty(0).Max() + 1; + + foreach (var storey in model.Storeys) + { + int levelId = doc.Levels.Count + 1; + doc.Levels.Add(new SceneLevel { Id = levelId, ModelId = ModelId, Name = storey.Name, Elevation = storey.Elevation * scale }); + long nodeId = nextNodeId++; + doc.Elements.Add(new SceneElement + { + Id = nodeId, + ModelId = ModelId, + SourceId = storey.GlobalId, + IfcGuid = storey.GlobalId, + Kind = SceneNodeKind.Level, + Name = storey.Name, + Category = "IFCBUILDINGSTOREY", + LevelId = levelId, + }); + + var elements = options.IncludeSpaces ? storey.Elements.Concat(storey.Spaces) : storey.Elements; + foreach (var element in elements) + { + if (IsNotProduct(element.IfcType)) continue; + + // Instances, 4D tasks and clashes all key on Id, so a repeat would merge two elements. + if (!ids.Add(element.Id)) + { + throw new ArgumentException( + $"Element id {element.Id} ('{element.Name}') is used twice. Ids are unique in models read with IfcModel.Open; " + + "a model built in memory numbers elements per storey, so save and reopen it first.", + nameof(model)); + } + + converted.Add(element, element.Id); + var issue = AddElement(doc, element, nodeId, levelId, scale, materials, sets); + if (issue is null) withGeometry++; + else diagnostics.Add(issue); + } + } + + int skippedTasks = AddSchedules(doc, model.WorkSchedules, converted); + return new IfcSceneResult(doc, diagnostics, model.LengthUnit, scale, converted.Count, withGeometry, skippedTasks); + } + + private static readonly string Producer = + "IfcIO.Scene " + (typeof(IfcSceneReader).Assembly.GetCustomAttribute()?.InformationalVersion + ?? typeof(IfcSceneReader).Assembly.GetName().Version?.ToString() + ?? "0.0.0").Split('+')[0]; + + // shortcut: IfcIO picks products by class-name prefix (IFCWALL, IFCDOOR, ...), so type objects such as IfcWallType or + // IfcDoorStyle and the door and window lining and panel properties arrive as elements without geometry; no product + // class it matches ends like these. Remove once IfcIO reads only IfcProduct subtypes. + private static bool IsNotProduct(string ifcType) => + ifcType.EndsWith("TYPE", StringComparison.OrdinalIgnoreCase) + || ifcType.EndsWith("STYLE", StringComparison.OrdinalIgnoreCase) + || ifcType.EndsWith("PROPERTIES", StringComparison.OrdinalIgnoreCase); + + // Exact lengths of the units IfcIO reports; it already converts any other file unit to metres while reading. + private static double MetresPerUnit(IfcLengthUnit unit) => unit switch + { + IfcLengthUnit.Millimetre => 0.001, + IfcLengthUnit.Metre => 1.0, + IfcLengthUnit.Foot => 0.3048, + IfcLengthUnit.Inch => 0.0254, + _ => throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unknown IFC length unit."), + }; + + private static string UnitSymbol(IfcLengthUnit unit) => unit switch + { + IfcLengthUnit.Millimetre => "mm", + IfcLengthUnit.Metre => "m", + IfcLengthUnit.Foot => "ft", + IfcLengthUnit.Inch => "in", + _ => throw new ArgumentOutOfRangeException(nameof(unit), unit, "Unknown IFC length unit."), + }; + + private static SceneDiagnostic? AddElement( + SceneDocument doc, + IfcElement element, + long parentId, + int levelId, + double scale, + Dictionary materials, + Dictionary sets) + { + var sceneElement = new SceneElement + { + Id = element.Id, + ModelId = ModelId, + SourceId = element.GlobalId, + IfcGuid = element.GlobalId, + ParentId = parentId, + Kind = SceneNodeKind.Element, + Name = element.Name, + Category = element.IfcType, + TypeName = element.TypeProduct?.Name ?? string.Empty, + LevelId = levelId, + }; + foreach (var pset in element.PropertySets) + { + sceneElement.PropertySetIds.Add(SetId(doc, sets, pset, pset.Name, + pset.Properties.Select(p => (p.Name, SceneValue.FromText(p.Value))))); + } + foreach (var qset in element.QuantitySets) + { + sceneElement.PropertySetIds.Add(SetId(doc, sets, qset, qset.Name, + qset.Quantities.Select(q => (q.Name, QuantityValue(q))))); + } + doc.Elements.Add(sceneElement); + + var source = element.GetEffectiveMesh(); + if (source is null) return Issue(SceneSeverity.Info, "no-geometry", "IfcIO returned no geometry", element); + if (source.Indices.Count == 0 || source.Vertices.Count == 0) return Issue(SceneSeverity.Warning, "empty-mesh", "The mesh has no triangles", element); + + var vertices = new Vector3d[source.Vertices.Count]; + for (int i = 0; i < vertices.Length; i++) + { + var v = source.Vertices[i]; + if (!double.IsFinite(v.X) || !double.IsFinite(v.Y) || !double.IsFinite(v.Z)) + { + return Issue(SceneSeverity.Warning, "invalid-mesh", "The mesh has a non-finite coordinate", element); + } + vertices[i] = new Vector3d(v.X * scale, v.Y * scale, v.Z * scale); + } + if (source.Indices.Count % 3 != 0 || source.Indices.Any(index => (uint)index >= (uint)vertices.Length)) + { + return Issue(SceneSeverity.Warning, "invalid-mesh", "The mesh has an index out of range or an index count not divisible by 3", element); + } + + int geometryId = doc.Geometries.Count + 1; + doc.Geometries.Add(new SceneGeometry { Id = geometryId, Topology = SceneTopology.Triangles, Vertices = vertices, Indices = [.. source.Indices] }); + var instance = new SceneInstance { ElementId = element.Id, GeometryId = geometryId }; + if (element.Material is { } material) instance.MaterialId = MaterialId(doc, material, materials); + else instance.Color = DefaultColor(element); + doc.Instances.Add(instance); + return null; + } + + private static SceneDiagnostic Issue(SceneSeverity severity, string code, string reason, IfcElement element) => + new(severity, code, $"{reason}; element #{element.Id} '{element.Name}' ({element.IfcType}) has no geometry in the scene.", element.GlobalId); + + // Quantities are doubles in IfcIO, in the file's units (not always IfcModel.LengthUnit), which IfcIO does not report. + // A number with a null Unit means dimensionless, so only a count is a number; every other quantity is text. + private static SceneValue QuantityValue(IfcPhysicalQuantity quantity) + { + double value = quantity.Value; + string text = value.ToString(CultureInfo.InvariantCulture); + return quantity is IfcQuantityCount && double.IsFinite(value) ? SceneValue.FromNumber(value, null, text) : SceneValue.FromText(text); + } + + private static int SetId( + SceneDocument doc, Dictionary sets, object source, string name, IEnumerable<(string Name, SceneValue Value)> values) + { + if (sets.TryGetValue(source, out int id)) return id; + + id = doc.PropertySets.Count + 1; + var set = new ScenePropertySet { Id = id, Name = name, Key = "ifc:" + name }; + foreach (var (propertyName, value) in values) + { + set.Properties.Add(new SceneProperty { Name = propertyName, Key = $"ifc:{name}.{propertyName}", Value = value }); + } + doc.PropertySets.Add(set); + sets.Add(source, id); + return id; + } + + private static int MaterialId(SceneDocument doc, IfcMaterial material, Dictionary materials) + { + if (materials.TryGetValue(material, out int id)) return id; + + id = doc.Materials.Count + 1; + doc.Materials.Add(new SceneMaterial + { + Id = id, + Name = material.Name, + Color = new RgbaColor(Channel(material.R), Channel(material.G), Channel(material.B), Channel(material.Alpha)), + }); + materials.Add(material, id); + return id; + } + + private static byte Channel(double value) => (byte)Math.Round(Math.Clamp(value, 0, 1) * 255); + + // Fallback colours by IFC class family, matched on the class name prefix as IfcIO lists the classes it reads. + private static RgbaColor DefaultColor(IfcElement element) + { + string type = element.IfcType.ToUpperInvariant(); + if (element is IfcSpace) return new RgbaColor(150, 200, 235, Channel(0.3)); + if (type.StartsWith("IFCWINDOW", StringComparison.Ordinal) || type.StartsWith("IFCCURTAINWALL", StringComparison.Ordinal)) + { + return new RgbaColor(140, 190, 225, Channel(0.5)); + } + if (type.StartsWith("IFCWALL", StringComparison.Ordinal)) return new RgbaColor(215, 213, 205); + if (type.StartsWith("IFCSLAB", StringComparison.Ordinal) || type.StartsWith("IFCROOF", StringComparison.Ordinal) + || type.StartsWith("IFCFOOTING", StringComparison.Ordinal)) + { + return new RgbaColor(190, 190, 190); + } + if (type.StartsWith("IFCCOLUMN", StringComparison.Ordinal) || type.StartsWith("IFCBEAM", StringComparison.Ordinal) + || type.StartsWith("IFCMEMBER", StringComparison.Ordinal)) + { + return new RgbaColor(120, 140, 185); + } + if (type.StartsWith("IFCDOOR", StringComparison.Ordinal)) return new RgbaColor(150, 105, 60); + if (type.StartsWith("IFCPIPE", StringComparison.Ordinal) || type.StartsWith("IFCFLOW", StringComparison.Ordinal)) + { + return new RgbaColor(225, 110, 60); + } + if (type.StartsWith("IFCDUCT", StringComparison.Ordinal)) return new RgbaColor(70, 190, 110); + return new RgbaColor(180, 180, 180); + } + + private static int AddSchedules(SceneDocument doc, List schedules, Dictionary converted) + { + int skipped = 0; + foreach (var schedule in schedules) + { + var sceneSchedule = new SceneSchedule { ModelId = ModelId, Name = schedule.Name }; + foreach (var task in schedule.RootTasks) AddTask(task, sceneSchedule.Tasks); + if (sceneSchedule.Tasks.Count > 0) doc.Schedules.Add(sceneSchedule); + } + return skipped; + + void AddTask(IfcTask task, List target) + { + if (task.Time?.PlannedStartDate is not { } start || task.Time.PlannedFinishDate is not { } finish) + { + skipped++; + foreach (var sub in task.SubTasks) AddTask(sub, target); + return; + } + + var sceneTask = new SceneTask + { + Id = task.GlobalId, + Name = task.Name, + TaskType = task.TaskType == IfcTaskType.Demolition ? SceneTaskType.Demolish : SceneTaskType.Construct, + PlannedStart = Instant(start), + PlannedEnd = Instant(finish), + ActualStart = task.Time.ActualStartDate is { } actualStart ? Instant(actualStart) : null, + ActualEnd = task.Time.ActualFinishDate is { } actualEnd ? Instant(actualEnd) : null, + }; + foreach (var element in task.AssignedElements) + { + if (converted.TryGetValue(element, out long id)) sceneTask.ElementIds.Add(id); + } + target.Add(sceneTask); + foreach (var sub in task.SubTasks) AddTask(sub, sceneTask.Children); + } + } + + // IfcIO reads IFC date-times as UTC (converted to local time); a date set in memory without a kind is taken as UTC too. + private static DateTimeOffset Instant(DateTime value) => + value.Kind == DateTimeKind.Unspecified ? new DateTimeOffset(value, TimeSpan.Zero) : new DateTimeOffset(value).ToUniversalTime(); +} diff --git a/src/IfcIO.Scene/IfcSceneResult.cs b/src/IfcIO.Scene/IfcSceneResult.cs new file mode 100644 index 0000000..5820d0b --- /dev/null +++ b/src/IfcIO.Scene/IfcSceneResult.cs @@ -0,0 +1,63 @@ +using IfcIO.Schema; +using IOScene; + +namespace IfcIO.Scene; + +/// +/// The result of : the scene and what happened to the model's data. +/// +public sealed class IfcSceneResult +{ + internal IfcSceneResult( + SceneDocument document, + IReadOnlyList diagnostics, + IfcLengthUnit lengthUnit, + double metresPerUnit, + int elementCount, + int elementsWithGeometry, + int skippedTasks) + { + Document = document; + Diagnostics = diagnostics; + LengthUnit = lengthUnit; + MetresPerUnit = metresPerUnit; + ElementCount = elementCount; + ElementsWithGeometry = elementsWithGeometry; + SkippedTasks = skippedTasks; + } + + /// The scene, in metres with Z up. + public SceneDocument Document { get; } + + /// + /// Findings of the conversion. Each converted element without geometry has one entry with its GlobalId as + /// : no-geometry (information: IfcIO returned no mesh), + /// empty-mesh or invalid-mesh (warnings: a mesh without triangles, or with a non-finite coordinate, + /// an index out of range or an index count not divisible by 3). These elements stay in the scene without geometry. + /// + public IReadOnlyList Diagnostics { get; } + + /// The model's length unit () the geometry was converted from. + public IfcLengthUnit LengthUnit { get; } + + /// The exact factor applied to every coordinate and storey elevation: one in metres. + public double MetresPerUnit { get; } + + /// Number of converted IFC elements, with or without geometry. The storey nodes of the tree are not counted. + public int ElementCount { get; } + + /// Number of converted elements that have a mesh. + public int ElementsWithGeometry { get; } + + /// + /// Number of geometries. IfcIO bakes mapped items (IfcMappedItem) into each element's world mesh and does not + /// expose the shared representation, so every geometry is used by one instance, one per element with geometry. + /// + public int GeometryCount => Document.Geometries.Count; + + /// + /// Number of 4D tasks left out of the schedules because they have no planned start and finish (IfcIO reads task + /// times from IFC4 files only). Their subtasks are kept. + /// + public int SkippedTasks { get; } +} diff --git a/src/IfcIO/IfcModel.cs b/src/IfcIO/IfcModel.cs index 17a445e..bca8896 100644 --- a/src/IfcIO/IfcModel.cs +++ b/src/IfcIO/IfcModel.cs @@ -2967,6 +2967,11 @@ public void ChargeVertices(int count) /// Meshes an IfcExtrudedAreaSolid or IfcRevolvedAreaSolid in the coordinates of its representation and returns /// its parametric form, which keeps only the Position location (as IfcModel stores and writes it). /// + /// The STEP file that holds the solid and the entities it references. + /// The IfcExtrudedAreaSolid or IfcRevolvedAreaSolid entity. + /// The name given to the returned mesh. + /// The parametric extrusion, or null when the entity is not an extrusion or cannot be read. + /// The parametric revolution, or null when the entity is not a revolution or cannot be read. /// True when Position or the profile Position rotates, so the parametric form does not describe the mesh. private static IfcMesh? ParseSweptSolid( StepFile step, diff --git a/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt b/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt new file mode 100644 index 0000000..6fc41b3 --- /dev/null +++ b/tests/IfcIO.Scene.Tests/Fixtures/placement.identity.txt @@ -0,0 +1,18 @@ +# Golden identity of IfcIO.Scene for placement.ifc: the SceneModel.Key, then the SourceId of every scene element in +# document order (IfcGuid equals it), then every property set key with the key, Kind, Unit (- for none) and Text of +# each value under it. Changing what IfcIO.Scene writes here is a breaking release of IfcIO.Scene. +key placement.ifc +04vctVUKr0kugbFTf53O9L +01vctVUKr0kugbFTf53O9L +05vctVUKr0kugbFTf53O9L +06vctVUKr0kugbFTf53O9L +07vctVUKr0kugbFTf53O9L +0BvctVUKr0kugbFTf53O9L +0DvctVUKr0kugbFTf53O9L +0CvctVUKr0kugbFTf53O9L +set ifc:Pset_BuildingElementProxyCommon +value ifc:Pset_BuildingElementProxyCommon.Reference Text - P-1 +value ifc:Pset_BuildingElementProxyCommon.Width Text - 2500. +set ifc:BaseQuantities +value ifc:BaseQuantities.Length Text - 1500.5 +value ifc:BaseQuantities.Count Number - 4 diff --git a/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc b/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc new file mode 100644 index 0000000..8945ed7 --- /dev/null +++ b/tests/IfcIO.Scene.Tests/Fixtures/placement.ifc @@ -0,0 +1,120 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [CoordinationView_V2.0]'),'2;1'); +FILE_NAME('placement.ifc','2026-01-01T00:00:00',(''),(''),'','',''); +FILE_SCHEMA(('IFC2X3')); +ENDSEC; +DATA; +#1=IFCPROJECT('00vctVUKr0kugbFTf53O9L',$,'Placement',$,$,$,$,(#20),#10); +#10=IFCUNITASSIGNMENT((#11)); +#11=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#20=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#21,$); +#21=IFCAXIS2PLACEMENT3D(#22,$,$); +#22=IFCCARTESIANPOINT((0.,0.,0.)); +#23=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#20,$,.MODEL_VIEW.,$); +#30=IFCDIRECTION((0.,0.,1.)); +#31=IFCDIRECTION((0.,1.,0.)); +#32=IFCDIRECTION((1.,0.,0.)); +#33=IFCDIRECTION((0.,1.)); +#34=IFCDIRECTION((-1.,0.,0.)); +#40=IFCSITE('02vctVUKr0kugbFTf53O9L',$,'Site',$,$,#41,$,$,.ELEMENT.,$,$,$,$,$); +#41=IFCLOCALPLACEMENT($,#42); +#42=IFCAXIS2PLACEMENT3D(#43,$,$); +#43=IFCCARTESIANPOINT((1000.,2000.,0.)); +#50=IFCBUILDING('03vctVUKr0kugbFTf53O9L',$,'Building',$,$,#51,$,$,.ELEMENT.,$,$,$); +#51=IFCLOCALPLACEMENT(#41,#52); +#52=IFCAXIS2PLACEMENT3D(#53,#30,#31); +#53=IFCCARTESIANPOINT((100.,0.,0.)); +#60=IFCBUILDINGSTOREY('04vctVUKr0kugbFTf53O9L',$,'Level 1',$,$,#61,$,$,.ELEMENT.,3000.); +#61=IFCLOCALPLACEMENT(#51,#62); +#62=IFCAXIS2PLACEMENT3D(#63,$,$); +#63=IFCCARTESIANPOINT((0.,0.,3000.)); +#100=IFCBUILDINGELEMENTPROXY('01vctVUKr0kugbFTf53O9L',$,'Panel',$,$,#101,#103,$,$); +#101=IFCLOCALPLACEMENT(#61,#102); +#102=IFCAXIS2PLACEMENT3D(#104,$,$); +#104=IFCCARTESIANPOINT((10.,20.,0.)); +#103=IFCPRODUCTDEFINITIONSHAPE($,$,(#105)); +#105=IFCSHAPEREPRESENTATION(#23,'Body','SurfaceModel',(#106)); +#106=IFCFACEBASEDSURFACEMODEL((#107)); +#107=IFCCONNECTEDFACESET((#108)); +#108=IFCFACE((#109)); +#109=IFCFACEOUTERBOUND(#110,.T.); +#110=IFCPOLYLOOP((#111,#112,#113)); +#111=IFCCARTESIANPOINT((0.,0.,0.)); +#112=IFCCARTESIANPOINT((1.,0.,0.)); +#113=IFCCARTESIANPOINT((0.,1.,0.)); +#200=IFCFLOWSEGMENT('05vctVUKr0kugbFTf53O9L',$,'Pipe',$,$,#201,#203,$); +#201=IFCLOCALPLACEMENT(#61,#202); +#202=IFCAXIS2PLACEMENT3D(#204,$,$); +#204=IFCCARTESIANPOINT((0.,0.,0.)); +#203=IFCPRODUCTDEFINITIONSHAPE($,$,(#205)); +#205=IFCSHAPEREPRESENTATION(#23,'Body','SweptSolid',(#206)); +#206=IFCEXTRUDEDAREASOLID(#207,#209,#30,1000.); +#207=IFCRECTANGLEPROFILEDEF(.AREA.,$,#208,20.,10.); +#208=IFCAXIS2PLACEMENT2D(#210,#33); +#210=IFCCARTESIANPOINT((0.,0.)); +#209=IFCAXIS2PLACEMENT3D(#204,#32,#31); +#300=IFCREPRESENTATIONMAP(#301,#303); +#301=IFCAXIS2PLACEMENT3D(#302,$,$); +#302=IFCCARTESIANPOINT((0.,0.,100.)); +#303=IFCSHAPEREPRESENTATION(#23,'Body','Brep',(#304)); +#304=IFCFACETEDBREP(#305); +#305=IFCCLOSEDSHELL((#306)); +#306=IFCFACE((#307)); +#307=IFCFACEOUTERBOUND(#308,.T.); +#308=IFCPOLYLOOP((#111,#309,#310)); +#309=IFCCARTESIANPOINT((10.,0.,0.)); +#310=IFCCARTESIANPOINT((0.,10.,0.)); +#320=IFCFLOWFITTING('06vctVUKr0kugbFTf53O9L',$,'Fitting A',$,$,#321,#323,$); +#321=IFCLOCALPLACEMENT($,#322); +#322=IFCAXIS2PLACEMENT3D(#204,$,$); +#323=IFCPRODUCTDEFINITIONSHAPE($,$,(#324)); +#324=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#325)); +#325=IFCMAPPEDITEM(#300,#326); +#326=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#31,#34,#327,2.,$); +#327=IFCCARTESIANPOINT((500.,0.,0.)); +#330=IFCFLOWFITTING('07vctVUKr0kugbFTf53O9L',$,'Fitting B',$,$,#331,#333,$); +#331=IFCLOCALPLACEMENT($,#332); +#332=IFCAXIS2PLACEMENT3D(#334,$,$); +#334=IFCCARTESIANPOINT((0.,0.,50.)); +#333=IFCPRODUCTDEFINITIONSHAPE($,$,(#335)); +#335=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#336)); +#336=IFCMAPPEDITEM(#300,#337); +#337=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#204,$,$); +#340=IFCFLOWFITTING('0BvctVUKr0kugbFTf53O9L',$,'Fitting C',$,$,#321,#343,$); +#343=IFCPRODUCTDEFINITIONSHAPE($,$,(#345)); +#345=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#346)); +#346=IFCMAPPEDITEM(#300,#347); +#347=IFCCARTESIANTRANSFORMATIONOPERATOR3D(#34,$,#204,$,$); +#350=IFCFLOWFITTING('0DvctVUKr0kugbFTf53O9L',$,'Fitting D',$,$,#321,#353,$); +#353=IFCPRODUCTDEFINITIONSHAPE($,$,(#355)); +#355=IFCSHAPEREPRESENTATION(#23,'Body','MappedRepresentation',(#356)); +#356=IFCMAPPEDITEM(#300,#357); +#357=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#204,$,#32); +#600=IFCBEAM('0CvctVUKr0kugbFTf53O9L',$,'Beam',$,$,#321,#603,$); +#603=IFCPRODUCTDEFINITIONSHAPE($,$,(#605)); +#605=IFCSHAPEREPRESENTATION(#23,'Body','Clipping',(#606)); +#606=IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#607,#610); +#607=IFCEXTRUDEDAREASOLID(#608,#209,#30,1000.); +#608=IFCRECTANGLEPROFILEDEF(.AREA.,$,#609,20.,10.); +#609=IFCAXIS2PLACEMENT2D(#210,$); +#610=IFCHALFSPACESOLID(#611,.F.); +#611=IFCPLANE(#612); +#612=IFCAXIS2PLACEMENT3D(#613,$,$); +#613=IFCCARTESIANPOINT((0.,0.,5000.)); +#400=IFCDISTRIBUTIONPORT('08vctVUKr0kugbFTf53O9L',$,'Pipe end',$,$,#401,$,.SOURCE.); +#401=IFCLOCALPLACEMENT(#201,#402); +#402=IFCAXIS2PLACEMENT3D(#403,#32,$); +#403=IFCCARTESIANPOINT((1000.,0.,0.)); +#410=IFCRELCONNECTSPORTTOELEMENT('09vctVUKr0kugbFTf53O9L',$,$,$,#400,#200); +#500=IFCRELCONTAINEDINSPATIALSTRUCTURE('0AvctVUKr0kugbFTf53O9L',$,$,$,(#100,#200,#320,#330,#340,#350,#600),#60); +#700=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('P-1'),$); +#701=IFCPROPERTYSINGLEVALUE('Width',$,IFCLENGTHMEASURE(2500.),$); +#702=IFCPROPERTYSET('0EvctVUKr0kugbFTf53O9L',$,'Pset_BuildingElementProxyCommon',$,(#700,#701)); +#703=IFCRELDEFINESBYPROPERTIES('0FvctVUKr0kugbFTf53O9L',$,$,$,(#100),#702); +#710=IFCQUANTITYLENGTH('Length',$,$,1500.5); +#711=IFCQUANTITYCOUNT('Count',$,$,4.); +#712=IFCELEMENTQUANTITY('0GvctVUKr0kugbFTf53O9L',$,'BaseQuantities',$,$,(#710,#711)); +#713=IFCRELDEFINESBYPROPERTIES('0HvctVUKr0kugbFTf53O9L',$,$,$,(#100),#712); +ENDSEC; +END-ISO-10303-21; diff --git a/tests/IfcIO.Scene.Tests/IfcIO.Scene.Tests.csproj b/tests/IfcIO.Scene.Tests/IfcIO.Scene.Tests.csproj new file mode 100644 index 0000000..7329a64 --- /dev/null +++ b/tests/IfcIO.Scene.Tests/IfcIO.Scene.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net10.0 + false + $(NoWarn);CA1707 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs new file mode 100644 index 0000000..e63ad04 --- /dev/null +++ b/tests/IfcIO.Scene.Tests/IfcSceneReaderTests.cs @@ -0,0 +1,508 @@ +using System.Text.RegularExpressions; +using IfcIO.Geometry; +using IfcIO.Model; +using IfcIO.Schema; +using IOScene; +using Xunit; + +namespace IfcIO.Scene.Tests; + +public class IfcSceneReaderTests +{ + private const string Millimetre = "#11=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);"; + private const string Foot = """ + #11=IFCCONVERSIONBASEDUNIT(#12,.LENGTHUNIT.,'FOOT',#13); + #12=IFCDIMENSIONALEXPONENTS(1,0,0,0,0,0,0); + #13=IFCMEASUREWITHUNIT(IFCLENGTHMEASURE(0.3048),#14); + #14=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); + """; + private const string Hostile = "\">"; + + // Site far from the origin; storey 3000 up; Panel placed at (10,20,0) in the storey and styled red. + // Fitting A and B draw one representation map (origin (0,0,100)); B's mapping moves it by (0,250,0). + private static string PlacedIfc(string lengthUnit, string extra = "") => $""" + ISO-10303-21; + HEADER; + FILE_DESCRIPTION((''),'2;1'); + FILE_NAME('adapter.ifc','2026-01-01T00:00:00',(''),(''),'','',''); + FILE_SCHEMA(('IFC2X3')); + ENDSEC; + DATA; + #1=IFCPROJECT('0AdapterProject0000001',$,'Adapter',$,$,$,$,(#20),#10); + #10=IFCUNITASSIGNMENT((#11)); + {lengthUnit} + #20=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#21,$); + #21=IFCAXIS2PLACEMENT3D(#22,$,$); + #22=IFCCARTESIANPOINT((0.,0.,0.)); + #30=IFCSITE('0AdapterSite0000000001',$,'Site',$,$,#31,$,$,.ELEMENT.,$,$,$,$,$); + #31=IFCLOCALPLACEMENT($,#32); + #32=IFCAXIS2PLACEMENT3D(#33,$,$); + #33=IFCCARTESIANPOINT((412345678.,2000.,0.)); + #40=IFCBUILDINGSTOREY('0AdapterStorey00000001',$,'Level 1',$,$,#41,$,$,.ELEMENT.,3000.); + #41=IFCLOCALPLACEMENT(#31,#42); + #42=IFCAXIS2PLACEMENT3D(#43,$,$); + #43=IFCCARTESIANPOINT((0.,0.,3000.)); + #100=IFCBUILDINGELEMENTPROXY('1AdapterPanel000000001',$,'Panel',$,$,#101,#103,$,$); + #101=IFCLOCALPLACEMENT(#41,#102); + #102=IFCAXIS2PLACEMENT3D(#104,$,$); + #104=IFCCARTESIANPOINT((10.,20.,0.)); + #103=IFCPRODUCTDEFINITIONSHAPE($,$,(#105)); + #105=IFCSHAPEREPRESENTATION(#20,'Body','Brep',(#106)); + #106=IFCFACETEDBREP(#107); + #107=IFCCLOSEDSHELL((#108)); + #108=IFCFACE((#109)); + #109=IFCFACEOUTERBOUND(#110,.T.); + #110=IFCPOLYLOOP((#22,#111,#112)); + #111=IFCCARTESIANPOINT((100.,0.,0.)); + #112=IFCCARTESIANPOINT((0.,100.,0.)); + #120=IFCCOLOURRGB($,1.,0.,0.); + #121=IFCSURFACESTYLESHADING(#120); + #122=IFCSURFACESTYLE('Red',.BOTH.,(#121)); + #123=IFCSTYLEDITEM(#106,(#122),$); + #200=IFCREPRESENTATIONMAP(#201,#203); + #201=IFCAXIS2PLACEMENT3D(#202,$,$); + #202=IFCCARTESIANPOINT((0.,0.,100.)); + #203=IFCSHAPEREPRESENTATION(#20,'Body','Brep',(#204)); + #204=IFCFACETEDBREP(#107); + #300=IFCFLOWFITTING('1AdapterFittingA000001',$,'Fitting A',$,$,#301,#303,$); + #301=IFCLOCALPLACEMENT($,#302); + #302=IFCAXIS2PLACEMENT3D(#304,$,$); + #304=IFCCARTESIANPOINT((500.,0.,0.)); + #303=IFCPRODUCTDEFINITIONSHAPE($,$,(#305)); + #305=IFCSHAPEREPRESENTATION(#20,'Body','MappedRepresentation',(#306)); + #306=IFCMAPPEDITEM(#200,#307); + #307=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#22,$,$); + #310=IFCFLOWFITTING('1AdapterFittingB000001',$,'Fitting B',$,$,#301,#313,$); + #313=IFCPRODUCTDEFINITIONSHAPE($,$,(#315)); + #315=IFCSHAPEREPRESENTATION(#20,'Body','MappedRepresentation',(#316)); + #316=IFCMAPPEDITEM(#200,#317); + #317=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#318,$,$); + #318=IFCCARTESIANPOINT((0.,250.,0.)); + #400=IFCRELCONTAINEDINSPATIALSTRUCTURE('2AdapterContainment001',$,$,$,(#100,#300,#310),#40); + {extra} + ENDSEC; + END-ISO-10303-21; + """; + + private static readonly Vector3d[] Triangle = [new(0, 0, 0), new(100, 0, 0), new(0, 100, 0)]; + + private static string FixturePath(string name) => Path.Combine(AppContext.BaseDirectory, "Fixtures", name); + + [Fact] + public void Read_PlacementFixture_MatchesGoldenIdentity() + { + var expected = File.ReadAllLines(FixturePath("placement.identity.txt")).Where(l => !l.StartsWith('#')).ToList(); + + var result = Valid(IfcSceneReader.Read(FixturePath("placement.ifc"))); + var doc = result.Document; + bool IsSet(string line) => line.StartsWith("set ", StringComparison.Ordinal) || line.StartsWith("value ", StringComparison.Ordinal); + + Assert.Equal(expected[0], "key " + Assert.Single(doc.Models).Key); + Assert.Equal(expected.Skip(1).Where(l => !IsSet(l)), doc.Elements.Select(e => e.SourceId)); + Assert.All(doc.Elements, e => Assert.Equal(e.SourceId, e.IfcGuid)); + Assert.Equal( + expected.Where(IsSet), + doc.PropertySets.SelectMany(s => s.Properties + .Select(p => $"value {p.Key} {p.Value.Kind} {p.Value.Unit ?? "-"} {p.Value.Text}") + .Prepend("set " + s.Key))); + } + + [Fact] + public void Read_File_DescribesTheModel() + { + string path = FixturePath("placement.ifc"); + + var model = Assert.Single(Valid(IfcSceneReader.Read(path)).Document.Models); + var keyed = Assert.Single(IfcSceneReader.Read(path, new IfcSceneOptions { ModelKey = "Tower A" }).Document.Models); + + Assert.Equal("placement.ifc", model.Key); + Assert.Equal("Tower A", keyed.Key); + Assert.Equal(path, model.Path); + Assert.Equal("Placement", model.Name); + Assert.Equal("ifc", model.SourceFormat); + Assert.Equal("mm", model.SourceLengthUnit); + Assert.Matches(@"^IfcIO\.Scene \d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$", model.Producer); + } + + [Fact] + public void Read_ModelWithoutKey_Throws() + { + var model = IfcModel.Create("No key"); + + var ex = Assert.Throws(() => IfcSceneReader.Read(model, new IfcSceneOptions())); + Assert.Contains("ModelKey", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Read_EmptyModelKey_Throws() + { + var empty = new IfcSceneOptions { ModelKey = "" }; + + var fromFile = Assert.Throws(() => IfcSceneReader.Read(FixturePath("placement.ifc"), empty)); + var fromModel = Assert.Throws(() => IfcSceneReader.Read(IfcModel.Create("Empty key"), empty)); + + Assert.Contains("ModelKey", fromFile.Message, StringComparison.Ordinal); + Assert.Contains("ModelKey", fromModel.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(Millimetre, IfcLengthUnit.Millimetre, 0.001)] + [InlineData(Foot, IfcLengthUnit.Foot, 0.3048)] + public void Read_PlacedAndMappedElements_LandInWorldMetres(string lengthUnit, IfcLengthUnit expectedUnit, double metres) + { + var result = Valid(Read(OpenText(PlacedIfc(lengthUnit)))); + var doc = result.Document; + + Assert.Equal(expectedUnit, result.LengthUnit); + Assert.Equal(metres, result.MetresPerUnit); + AssertPoints(Offset(new(412345678 + 10, 2000 + 20, 3000), metres), GeometryOf(doc, 100).Vertices); + AssertPoints(Offset(new(500, 0, 100), metres), GeometryOf(doc, 300).Vertices); + AssertPoints(Offset(new(500, 250, 100), metres), GeometryOf(doc, 310).Vertices); + + // IfcIO bakes mapped items into world meshes: the two fittings are two geometries, each placed once. + Assert.Equal(3, result.GeometryCount); + Assert.Equal(3, doc.Instances.Count); + Assert.All(doc.Instances, i => Assert.Null(i.Transform)); + + var panel = Assert.Single(doc.Elements, e => e.Id == 100); + Assert.Equal("1AdapterPanel000000001", panel.SourceId); + Assert.Equal("1AdapterPanel000000001", panel.IfcGuid); + Assert.Equal("IFCBUILDINGELEMENTPROXY", panel.Category); + Assert.Equal(SceneNodeKind.Element, panel.Kind); + var red = Assert.Single(doc.Materials); + Assert.Equal(new RgbaColor(255, 0, 0), red.Color); + Assert.Equal(red.Id, InstanceOf(doc, 100).MaterialId); + } + + [Fact] + public void Read_Storeys_BecomeLevelsAndTreeNodes() + { + var doc = Valid(Read(OpenText(PlacedIfc(Millimetre)))).Document; + + var level = Assert.Single(doc.Levels); + Assert.Equal("Level 1", level.Name); + Assert.Equal(3.0, level.Elevation); + var storey = Assert.Single(doc.Elements, e => e.Kind == SceneNodeKind.Level); + Assert.Equal("0AdapterStorey00000001", storey.SourceId); + Assert.Equal("IFCBUILDINGSTOREY", storey.Category); + Assert.Null(storey.ParentId); + Assert.Equal(level.Id, storey.LevelId); + Assert.All(doc.Elements.Where(e => e != storey), e => + { + Assert.Equal(storey.Id, e.ParentId); + Assert.Equal(level.Id, e.LevelId); + }); + } + + [Fact] + public void Read_TypeObjectsAndLiningProperties_AreNotElements() + { + const string NonProducts = """ + #500=IFCWALLTYPE('1AdapterWallType000001',$,'WT',$,$,$,$,$,$,.STANDARD.); + #501=IFCDOORSTYLE('1AdapterDoorStyle00001',$,'DS',$,$,$,$,$,.SINGLE_SWING_LEFT.,.WOOD.,.F.,.F.); + #502=IFCDOORLININGPROPERTIES('1AdapterDoorLining0001',$,'DL',$,50.,$,$,$,$,$,$,$,$,$,$); + """; + var model = OpenText(PlacedIfc(Millimetre, NonProducts)); + long[] nonProducts = [500, 501, 502]; + // IfcIO lists them as elements, which is what the adapter has to undo. + Assert.All(nonProducts, id => Assert.Contains(model.AllElements, e => e.Id == id)); + + var result = Valid(Read(model)); + + Assert.Equal(new long[] { 100, 300, 310 }, ElementsOfKind(result.Document).Select(e => e.Id).Order()); + Assert.Equal(3, result.ElementCount); + Assert.Empty(result.Diagnostics); + } + + [Theory] + [InlineData(IfcLengthUnit.Millimetre, 0.001)] + [InlineData(IfcLengthUnit.Metre, 1.0)] + public void Read_SavedModel_ScalesFromItsLengthUnit(IfcLengthUnit unit, double metres) + { + var model = IfcModel.Create("Units"); + model.LengthUnit = unit; + model.AddMesh("Slab", [new(1000, 0, 0), new(2000, 0, 0), new(1000, 1500, 250)], [0, 1, 2], ifcType: "IFCSLAB"); + var opened = SaveAndOpen(model); + + var result = Valid(Read(opened)); + + Assert.Equal(unit, result.LengthUnit); + var source = Assert.Single(opened.AllElements).Mesh!.Vertices; + var vertices = Assert.Single(result.Document.Geometries).Vertices; + Assert.Equal(source.Select(v => new Vector3d(v.X * metres, v.Y * metres, v.Z * metres)), vertices); + Assert.Equal(new Vector3d(2000 * metres, 1500 * metres, 250 * metres), result.Document.ComputeBounds().Max); + } + + [Fact] + public void Read_SavedModel_KeepsPropertiesQuantitiesGlobalIdsAndReportsElementsWithoutGeometry() + { + var model = IfcModel.Create("Properties"); + var storey = model.AddStorey("Level 2", 3000); + var wall = model.AddMesh("Wall", Mm(Triangle), [0, 1, 2], new IfcMaterial { Name = "Brick", R = 1, G = 0.5, B = 0 }, storey, "IFCWALL"); + wall.GetOrCreatePropertySet("Pset_WallCommon").Add("FireRating", "EI 60"); + var quantities = wall.GetOrCreateQuantitySet("Qto_WallBaseQuantities"); + quantities.AddLength("Length", 3000.5); + quantities.AddArea("NetSideArea", 9.25); + quantities.AddVolume("NetVolume", 2.5); + quantities.AddWeight("GrossWeight", 1200); + quantities.AddCount("Count", 4); + model.AddElement(new IfcElement { Id = 2, Name = "Annotation" }, storey); + var opened = SaveAndOpen(model); + var openedWall = Assert.Single(opened.AllElements, e => e.Name == "Wall"); + var openedEmpty = Assert.Single(opened.AllElements, e => e.Name == "Annotation"); + + var result = Valid(Read(opened)); + var doc = result.Document; + + var element = Assert.Single(doc.Elements, e => e.Id == openedWall.Id); + Assert.False(string.IsNullOrEmpty(openedWall.GlobalId)); + Assert.Equal(openedWall.GlobalId, element.SourceId); + Assert.Equal("IFCWALL", element.Category); + Assert.Equal("Level 2", Assert.Single(doc.Levels, l => l.Id == element.LevelId).Name); + + var sets = element.PropertySetIds.Select(id => Assert.Single(doc.PropertySets, s => s.Id == id)).ToList(); + Assert.Equal(["ifc:Pset_WallCommon", "ifc:Qto_WallBaseQuantities"], sets.Select(s => s.Key)); + Assert.Equal(["Pset_WallCommon", "Qto_WallBaseQuantities"], sets.Select(s => s.Name)); + var fireRating = Assert.Single(sets[0].Properties); + Assert.Equal("FireRating", fireRating.Name); + Assert.Equal("ifc:Pset_WallCommon.FireRating", fireRating.Key); + Assert.Equal(SceneValue.FromText("EI 60"), fireRating.Value); + // Dimensioned quantities are in file units, so only their text is written; a count is a dimensionless number. + Assert.Equal( + [ + ("ifc:Qto_WallBaseQuantities.Length", SceneValue.FromText("3000.5")), + ("ifc:Qto_WallBaseQuantities.NetSideArea", SceneValue.FromText("9.25")), + ("ifc:Qto_WallBaseQuantities.NetVolume", SceneValue.FromText("2.5")), + ("ifc:Qto_WallBaseQuantities.GrossWeight", SceneValue.FromText("1200")), + ("ifc:Qto_WallBaseQuantities.Count", SceneValue.FromNumber(4, null, "4")), + ], + sets[1].Properties.Select(p => (p.Key!, p.Value))); + Assert.Null(sets[1].Properties[^1].Value.Unit); + + var brick = Assert.Single(doc.Materials); + Assert.Equal("Brick", brick.Name); + Assert.Equal(new RgbaColor(255, 128, 0, 255), brick.Color); + + Assert.Equal(2, result.ElementCount); + Assert.Equal(1, result.ElementsWithGeometry); + var issue = Assert.Single(result.Diagnostics); + Assert.Equal((SceneSeverity.Info, "no-geometry", openedEmpty.GlobalId), (issue.Severity, issue.Code, issue.SourceId)); + Assert.DoesNotContain(doc.Instances, i => i.ElementId == openedEmpty.Id); + Assert.Contains(doc.Elements, e => e.Id == openedEmpty.Id); + } + + [Fact] + public void Read_SharedPropertySet_IsOneSceneSet() + { + const string Shared = """ + #600=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('P-1'),$); + #601=IFCPROPERTYSET('1AdapterSharedPset0001',$,'Pset_Shared',$,(#600)); + #602=IFCRELDEFINESBYPROPERTIES('1AdapterSharedRel00001',$,$,$,(#300,#310),#601); + """; + + var doc = Valid(Read(OpenText(PlacedIfc(Millimetre, Shared)))).Document; + + var set = Assert.Single(doc.PropertySets); + Assert.Equal("ifc:Pset_Shared", set.Key); + Assert.Equal([set.Id], Assert.Single(doc.Elements, e => e.Id == 300).PropertySetIds); + Assert.Equal([set.Id], Assert.Single(doc.Elements, e => e.Id == 310).PropertySetIds); + Assert.Equal(SceneValue.FromText("P-1"), Assert.Single(set.Properties).Value); + } + + [Fact] + public void Read_HostileStrings_SurviveSceneJson() + { + var model = IfcModel.Create("Hostile"); + var wall = model.AddMesh(Hostile, Mm(Triangle), [0, 1, 2], ifcType: "IFCWALL"); + wall.GetOrCreatePropertySet(Hostile).Add(Hostile, Hostile); + var opened = SaveAndOpen(model); + + var doc = Valid(Read(opened)).Document; + var read = SceneJson.Read(SceneJson.Write(doc)); + + var element = Assert.Single(ElementsOfKind(read)); + Assert.Equal(Hostile, element.Name); + var property = Assert.Single(Assert.Single(read.PropertySets).Properties); + Assert.Equal((Hostile, $"ifc:{Hostile}.{Hostile}", Hostile), (property.Name, property.Key, property.Value.Text)); + } + + [Fact] + public void Read_MaterialAlpha_BecomesColourAlphaAndElementsWithoutMaterialGetClassColour() + { + // IfcIO does not read transparency from files yet, so the material is set in memory. + var model = IfcModel.Create("Glass"); + model.AddMesh("Pane", Mm(Triangle), [0, 1, 2], new IfcMaterial { Name = "Glass", R = 0.2, G = 0.4, B = 0.6, Alpha = 0.35 }, ifcType: "IFCPLATE"); + var window = model.AddMesh("Window", Mm(Triangle), [0, 1, 2], ifcType: "IFCWINDOW"); + + var doc = Valid(Read(model)).Document; + + var glass = Assert.Single(doc.Materials); + Assert.Equal((51, 102, 153), (glass.Color.R, glass.Color.G, glass.Color.B)); + Assert.Equal(0.35, glass.Color.Af, 0.5 / 255); + var instance = InstanceOf(doc, window.Id); + Assert.Null(instance.MaterialId); + Assert.Equal(0.5, instance.Color!.Value.Af, 0.5 / 255); + } + + [Fact] + public void Read_SpacesOnlyWhenIncluded() + { + var model = IfcModel.Create("Spaces"); + model.AddMesh("Wall", Mm(Triangle), [0, 1, 2], ifcType: "IFCWALL"); + var space = model.AddSpace("Room 1", mesh: IfcMesh.Create(1, "Room 1", Mm(Triangle), [0, 1, 2])); + + Assert.DoesNotContain(Valid(Read(model)).Document.Elements, e => e.Id == space.Id); + + var doc = Valid(Read(model, new IfcSceneOptions { ModelKey = "spaces", IncludeSpaces = true })).Document; + Assert.Equal("IFCSPACE", Assert.Single(doc.Elements, e => e.Id == space.Id).Category); + Assert.Equal(0.3, InstanceOf(doc, space.Id).Color!.Value.Af, 0.5 / 255); + } + + [Fact] + public void Read_DuplicateElementIds_Throws() + { + // A model built in memory numbers elements per storey, so two storeys both start at 1. + var model = IfcModel.Create("Duplicates"); + model.AddMesh("A", Mm(Triangle), [0, 1, 2]); + model.AddMesh("B", Mm(Triangle), [0, 1, 2], storey: model.AddStorey("Level 2", 3000)); + + var ex = Assert.Throws(() => Read(model)); + Assert.Contains("used twice", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Read_InvalidMesh_IsReportedNotDrawn() + { + var model = IfcModel.Create("Invalid"); + model.AddMesh("Bad", Mm(Triangle), [0, 1, 7]); + model.AddMesh("Empty", Mm(Triangle), []); + + var result = Valid(Read(model)); + + Assert.Empty(result.Document.Geometries); + Assert.Empty(result.Document.Instances); + Assert.Equal( + [(SceneSeverity.Warning, "invalid-mesh", (string?)null), (SceneSeverity.Warning, "empty-mesh", null)], + result.Diagnostics.Select(d => (d.Severity, d.Code, d.SourceId))); + } + + [Fact] + public void Read_WorkSchedule_BecomesScheduleWithElements() + { + var model = IfcModel.Create("Schedule", IfcSchema.Ifc4); + var wall = model.AddMesh("Wall", Mm(Triangle), [0, 1, 2], ifcType: "IFCWALL"); + var schedule = model.AddWorkSchedule("Build"); + var start = new DateTime(2026, 3, 1, 8, 0, 0, DateTimeKind.Utc); + schedule.AddTask("Erect walls", "T1", start, start.AddDays(9)).AssignElement(wall); + schedule.AddTask("Undated", "T2"); + var opened = SaveAndOpen(model); + + var result = Valid(Read(opened)); + + var sceneSchedule = Assert.Single(result.Document.Schedules); + Assert.Equal("Build", sceneSchedule.Name); + Assert.Equal(1, sceneSchedule.ModelId); + var task = Assert.Single(sceneSchedule.Tasks); + Assert.Equal("Erect walls", task.Name); + Assert.Equal(new DateTimeOffset(start), task.PlannedStart); + Assert.Equal(new DateTimeOffset(start.AddDays(9)), task.PlannedEnd); + Assert.Equal([Assert.Single(opened.AllElements).Id], task.ElementIds); + Assert.Equal(1, result.SkippedTasks); + } + + [Fact] + public void Read_Ifc4TriangulatedFaceSet_IsReportedWithoutGeometry() + { + // Documented limit: IfcIO's reader returns no mesh for IfcTriangulatedFaceSet, even the one it writes itself. + // When this test fails, IfcIO reads it, and the limit in docs/scene-adapter.md goes. + var model = IfcModel.Create("Tessellated", IfcSchema.Ifc4); + model.AddMesh("Slab", Mm(Triangle), [0, 1, 2], ifcType: "IFCSLAB"); + using var writer = new StringWriter(); + model.Save(writer); + Assert.Contains("IFCTRIANGULATEDFACESET(", writer.ToString(), StringComparison.Ordinal); + + var result = Valid(Read(OpenText(writer.ToString()))); + + Assert.Empty(result.Document.Geometries); + Assert.Equal("no-geometry", Assert.Single(result.Diagnostics).Code); + } + + [IfcFileFact] + public void Read_ConfiguredIfcFile_ConvertsEveryElement() + { + string path = Environment.GetEnvironmentVariable(IfcFileFactAttribute.Variable)!; + var model = IfcModel.Open(path); + + var result = IfcSceneReader.Read(path); + + // A real file may draw warnings (for example a malformed GlobalId, copied as it is), but never errors. + Assert.DoesNotContain(result.Document.Validate(), d => d.Severity == SceneSeverity.Error); + Assert.Equal( + model.AllElements.Count(e => !Regex.IsMatch(e.IfcType, "(TYPE|STYLE|PROPERTIES)$", RegexOptions.IgnoreCase)), + result.ElementCount); + Assert.Equal(result.ElementsWithGeometry, result.GeometryCount); + Assert.All(ElementsOfKind(result.Document), e => Assert.False(string.IsNullOrEmpty(e.SourceId))); + Assert.NotEmpty(SceneJson.Write(result.Document)); + } + + // Every converted fixture must pass Validate() without errors or warnings. + private static IfcSceneResult Valid(IfcSceneResult result) + { + Assert.Empty(result.Document.Validate()); + return result; + } + + private static IfcSceneResult Read(IfcModel model, IfcSceneOptions? options = null) => + IfcSceneReader.Read(model, options ?? new IfcSceneOptions { ModelKey = "test.ifc" }); + + private static IfcModel OpenText(string ifc) + { + using var reader = new StringReader(ifc); + return IfcModel.Open(reader); + } + + // Models are saved as IFC2x3 (faceted breps) unless a test needs IFC4: IfcIO does not read back the + // IfcTriangulatedFaceSet it writes for IFC4. + private static IfcModel SaveAndOpen(IfcModel model) + { + using var writer = new StringWriter(); + model.Save(writer); + return OpenText(writer.ToString()); + } + + private static IEnumerable ElementsOfKind(SceneDocument doc) => doc.Elements.Where(e => e.Kind == SceneNodeKind.Element); + + private static IfcVector3[] Mm(Vector3d[] points) => [.. points.Select(p => new IfcVector3(p.X, p.Y, p.Z))]; + + private static SceneInstance InstanceOf(SceneDocument doc, long elementId) => Assert.Single(doc.Instances, i => i.ElementId == elementId); + + private static SceneGeometry GeometryOf(SceneDocument doc, long elementId) => + Assert.Single(doc.Geometries, g => g.Id == InstanceOf(doc, elementId).GeometryId); + + private static Vector3d[] Offset(Vector3d origin, double metres) => + [.. Triangle.Select(p => (p + origin) * metres)]; + + private static void AssertPoints(Vector3d[] expected, IReadOnlyList actual) + { + Assert.Equal(expected.Length, actual.Count); + for (int i = 0; i < expected.Length; i++) + { + // 1e-6 m: far tighter than float precision at 400 km from the origin, so doubles must be kept. + Assert.True(Vector3d.Distance(expected[i], actual[i]) <= 1e-6, $"Vertex {i}: expected {expected[i]}, actual {actual[i]}"); + } + } +} + +/// Runs only when names an IFC file; the repository ships none of real size. +public sealed class IfcFileFactAttribute : FactAttribute +{ + public const string Variable = "IFCIO_SCENE_IFC_FILE"; + + public IfcFileFactAttribute() + { + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable(Variable))) + { + Skip = $"Set {Variable} to an IFC file to run this test."; + } + } +} diff --git a/tools/pack-release.ps1 b/tools/pack-release.ps1 index f80586b..ecc498b 100644 --- a/tools/pack-release.ps1 +++ b/tools/pack-release.ps1 @@ -29,6 +29,27 @@ function Invoke-DotNet { if ($LASTEXITCODE -ne 0) { throw "dotnet $($args -join ' ') failed with exit code $LASTEXITCODE" } } +function Assert-AdapterNuspec([string]$NuspecPath, [string]$EngineId, [string]$Version) { + $dependencies = @(([xml](Get-Content -LiteralPath $NuspecPath -Raw)).SelectNodes("//*[local-name()='dependency']")) + # (a) Guards the PinProjectReferencesExactly target, which hooks a private NuGet target and would fail silently if an SDK renamed it. + $engine = @($dependencies | Where-Object { $_.GetAttribute('id') -eq $EngineId }) + if ($engine.Count -eq 0) { throw "$NuspecPath has no dependency on $EngineId." } + foreach ($dependency in $engine) { + if ($dependency.GetAttribute('version') -cne "[$Version]") { + throw "$NuspecPath depends on $EngineId '$($dependency.GetAttribute('version'))'; it must be exactly '[$Version]'." + } + } + # (b) Replaces the NU5104 protection that the IOScene range switches off: no adapter built on a preview contract reaches nuget.org. + $scene = @($dependencies | Where-Object { $_.GetAttribute('id') -eq 'IOScene' }) + if ($scene.Count -eq 0) { throw "$NuspecPath has no dependency on IOScene." } + foreach ($dependency in $scene) { + $range = $dependency.GetAttribute('version') + if ($range.TrimStart('[', '(').Split(',')[0].Contains('-')) { + throw "$NuspecPath depends on IOScene '$range', whose lower bound is a prerelease; release IOScene first and require its stable version." + } + } +} + function New-Zip([string]$SourceDir, [string]$ZipPath) { if (Test-Path -LiteralPath $ZipPath) { Remove-Item -LiteralPath $ZipPath } Compress-Archive -Path (Join-Path $SourceDir '*') -DestinationPath $ZipPath @@ -76,6 +97,17 @@ New-Zip $cli10Stage (Join-Path $Output "IfcIO-Cli-$version-net10.0.zip") Write-Host "Packing NuGet packages..." Invoke-DotNet pack (Join-Path $root 'IfcIO.slnx') -c Release --no-build -o $Output +Write-Host "Checking IfcIO.Scene dependencies..." +Add-Type -AssemblyName System.IO.Compression.FileSystem +$adapterNuspec = Join-Path $stage 'IfcIO.Scene.nuspec' +$adapterPackage = [System.IO.Compression.ZipFile]::OpenRead((Join-Path $Output "IfcIO.Scene.$version.nupkg")) +try { + $entry = @($adapterPackage.Entries | Where-Object { $_.FullName -notlike '*/*' -and $_.Name -like '*.nuspec' })[0] + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $adapterNuspec, $true) +} +finally { $adapterPackage.Dispose() } +Assert-AdapterNuspec $adapterNuspec 'IfcIO' $version + # 5. Source code archive Write-Host "Creating source code archive..." $sourceZip = Join-Path $Output "IfcIO-$version-source.zip"