diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df37d82..80e3234 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,17 @@ jobs: if ($env:GITHUB_REF_NAME -cne "v$($version.Trim())") { throw "The release tag must match the package version v$($version.Trim())." } + # Before anything is published: a tag whose commit has no changelog section for the version fails here. + - name: Extract release notes + shell: pwsh + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 + $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) + if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } + $notes = @() + for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } + $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 - run: dotnet restore IOViewer.slnx - run: dotnet build IOViewer.slnx -c Release --no-restore - run: dotnet test IOViewer.slnx -c Release --no-build @@ -37,21 +48,8 @@ jobs: with: name: packages path: artifacts/* - - name: Create release - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $version = $env:GITHUB_REF_NAME.Substring(1) - $changelog = Get-Content -LiteralPath CHANGELOG.md -Encoding utf8 - $start = [array]::FindIndex($changelog, [Predicate[string]] { param($line) $line.StartsWith("## [$version]") }) - if ($start -lt 0) { throw "CHANGELOG.md has no entry for $version" } - $notes = @() - for ($i = $start + 1; $i -lt $changelog.Count -and -not $changelog[$i].StartsWith('## ['); $i++) { $notes += $changelog[$i] } - $notes | Set-Content -LiteralPath release-notes.md -Encoding utf8 - $files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName }) - gh release create $env:GITHUB_REF_NAME --title $env:GITHUB_REF_NAME --notes-file release-notes.md @files - if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" } + # The push comes before the GitHub release: a refused first publication of a package id can be fixed and the + # job run again, because --skip-duplicate makes the push repeatable and no release exists yet. # Trusted publishing: the NuGet policy for this repository and workflow file exchanges the GitHub OIDC token for a short-lived key. - uses: NuGet/login@v1 id: nuget-login @@ -64,3 +62,15 @@ jobs: dotnet nuget push $package.FullName --api-key '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for $($package.Name) with exit code $LASTEXITCODE" } } + - name: Create release + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $version = $env:GITHUB_REF_NAME.Substring(1) + $files = @(Get-ChildItem artifacts -File | ForEach-Object { $_.FullName }) + # A version with a hyphen is a prerelease. + $flags = @() + if ($version.Contains('-')) { $flags += '--prerelease' } + gh release create $env:GITHUB_REF_NAME --title $env:GITHUB_REF_NAME --notes-file release-notes.md @flags @files + if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b1c56..e2df442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,93 @@ API between minor releases. ## [Unreleased] +Version 0.3.0 builds the viewer on the scene model of the [IOScene](https://github.com/IOExtend/IOScene) package +(`IOScene` `[0.1.0-preview.2, 0.2.0-0)`, a new dependency of the `IOViewer` package). This is a breaking change of the +library API. What breaks for existing users: + +1. The model types move to namespace `IOScene` with `Scene*` names, `ViewerClash*` included; `doc.Elements` becomes + `doc.Scene.Elements`, and the members in the table below change. +2. The documented quick start (a `ViewerMesh` with its own vertices) becomes `SceneDocument.AddMesh`. +3. Written JSON has a new shape. IOViewer 0.2.0 reads a 0.3.0 file as an empty document with exit code 0, because it + ignores unknown members. +4. A page regenerated from an old snapshot shows each mesh without an element as an element (in the element count, the + tree and hide). + +Not broken: loading 0.1/0.2 JSON, existing HTML pages, the `ioviewer` arguments, the importer to exporter pipeline +(`GlbViewerImporter` to `HtmlViewerExporter`, `ViewerDocument.Title`) and the one-package install (`IOScene` comes with +the `IOViewer` package). The `IOViewer.Cli` tool only gains inputs. + +### Migration from 0.2.0 + +| 0.2.0 (`IOViewer.Model`) | 0.3.0 | +|---|---| +| `Vector2d`, `Vector3d`, `BoundingBox3d`, `Transform3d`, `RgbaColor` | the same names in namespace `IOScene` | +| `Vector4d`, `Quaterniond` | removed, no replacement | +| `ViewerDocument.Elements`, `Meshes`, `Geometries`, `Materials`, `Textures`, `Viewpoints`, `ClashTests` | `ViewerDocument.Scene.Elements`, `Instances`, `Geometries`, `Materials`, `Textures`, `Viewpoints`, `ClashTests` | +| `ViewerDocument.SourcePath`, `Author`, `CreatedAt`, `Application` | `Scene.Models[0].Path`, `Author`, `CreatedAt`, `Application` | +| `ViewerDocument.Grids` (one grid system) | `Scene.Grids` (a list of `SceneGridSystem`, each with `ModelId`) | +| `ViewerDocument.TimeLiner` | `Scene.Schedules` (a list of `SceneSchedule`) | +| `ViewerDocument.LinkedFiles` (`ViewerLinkedFile` `Name`, `Path`, `Visible`) | `Scene.Models` (`SceneModel` with a unique `Key`, `Name`, `Path`); `Visible = false` is the model's key in `ViewerDocument.HiddenModelKeys` | +| `ViewerDocument.BoundingBox`, `ComputeBoundingBox()` | `Scene.ComputeBounds()` | +| `ViewerDocument.Title` (default `3D BIM Model`) | kept; it reads and writes `Scene.Title` (default empty) | +| `ViewerDocument.SectionPlanes`, `SectionBox`, `ViewerSectionPlane`, `ViewerSectionBox` | kept; their `Vector3d` is `IOScene.Vector3d` | +| - | `ViewerDocument.Scene`, `ViewerDocument.HiddenModelKeys`, `new ViewerDocument(SceneDocument)` | +| `ViewerElement` | `SceneElement` | +| `ViewerElement.LinkName` | `SceneElement.ModelId`, the id of a `SceneModel` | +| `ViewerElement.Level` (a string) | `SceneElement.LevelId`, the id of a `SceneLevel` in `Scene.Levels` | +| `ViewerElement.Properties`, `AddProperty(group, name, value)` | `ScenePropertySet` in `Scene.PropertySets` (`Name` = group), referenced by `SceneElement.PropertySetIds`; each `SceneProperty` has a `SceneValue` (`SceneValue.FromText` for a string) | +| `ViewerElement.MeshIndices`, `ViewerElement.BoundingBox` | removed; `SceneInstance.ElementId` links an element to its geometry | +| `ViewerMesh` | `SceneInstance`, which places a `SceneGeometry` for an element | +| `ViewerMesh.Vertices`, `Indices`, `Normals`, `UVs` (own geometry) | a `SceneGeometry` placed by one instance; `SceneDocument.AddMesh` adds both and the element | +| `ViewerMesh.ElementId` (optional) | `SceneInstance.ElementId` (required: every instance belongs to an element) | +| `ViewerMesh.GeometryId`, `Transform`, `MaterialId`, `Name` | `SceneInstance.GeometryId`, `Transform`, `MaterialId`, `Name` | +| `ViewerMesh.Color`, `ViewerMesh.Opacity`, `ViewerMaterial.Opacity` | the alpha of `SceneInstance.Color` or `SceneMaterial.Color`, the only opacity | +| `ViewerMesh.TextureIndex`, `ViewerMaterial.TextureIndex` | `SceneMaterial.TextureId`, the id of a `SceneTexture` | +| `ViewerMesh.Id`, `BoundingBox`, `ComputeBoundingBox()` | removed | +| `ViewerGeometry` | `SceneGeometry`, with `Topology` (`Triangles`, `Lines`, `Points`); empty `Indices` means non-indexed | +| `ViewerMaterial` | `SceneMaterial` | +| `ViewerTexture` (`Base64Data` string) | `SceneTexture` (`Data` bytes) | +| `ViewerGridSystem`, `ViewerGridLine` | `SceneGridSystem`, `SceneGridLine` | +| `ViewerViewpoint`, `ViewerCamera`, `ViewerProjection` | `SceneViewpoint`, `SceneCamera`, `SceneProjection` | +| `ViewerTimeLinerSchedule` | `SceneSchedule`; `GetOverallStart()` and `GetOverallEnd()` are removed | +| `ViewerTimeLinerTask`, `ViewerTimeLinerTaskType` | `SceneTask`, `SceneTaskType` | +| `ViewerTimeLinerTask.AttachedElementIds` | `SceneTask.ElementIds` | +| `ViewerTimeLinerTask.AttachedSetName` | removed | +| `ViewerClashTest`, `ViewerClashResult`, `ViewerClashStatus` | `SceneClashTest`, `SceneClashResult`, `SceneClashStatus` | +| `ViewerClashResult.Distance` (`double`) | `SceneClashResult.Distance` (`double?`, null when the source does not report it) | +| constructors with parameters (`new ViewerViewpoint(name, camera)` and others) | object initializers | + +### Added +- `HtmlViewerExporter.Save`, `Export` and `ExportToString` take a `SceneDocument` too. +- `JsonViewerImporter` and the `ioviewer` tool read three kinds of JSON: viewer JSON (`"Kind": "ioviewer"`), IOScene + scene files (`"Kind": "ioscene"`) and IOViewer 0.1/0.2 snapshots (no `Kind`). In a snapshot, meshes that share an + `ElementId` no element has get one element with that id, and each other mesh without an element gets one of its own. + A newer format, a missing or wrong `Format` and an unknown kind are rejected with `InvalidDataException`. +- The HTML viewer shows `SceneElement.SourceId`, the element's id in its source format (such as a Revit UniqueId or an + IFC GlobalId), as its own row in the properties panel ("Source ID" / "ID источника"), and the model tree search finds + it. It is written to the page's element list only when set. + +### Changed +- `JsonViewerExporter` writes viewer JSON format 2: `{ "Kind": "ioviewer", "Format": 2, "Scene", "SectionPlanes", + "SectionBox", "HiddenModelKeys" }`, with the scene in the encoding of IOScene's `SceneJson`. +- `GlbViewerImporter` and `ObjViewerImporter` return documents whose scene has one model and passes + `SceneDocument.Validate()`. +- The viewer draws triangles only: instances of line or point geometry are skipped, and the `ioviewer` tool prints a + warning with their number. +- Opacity is a colour alpha byte, so an opacity that is not a multiple of 1/255 is rounded (0.35 becomes 89/255). +- In an IOViewer 0.1/0.2 snapshot, an element `LinkName` that matches no linked file becomes a model of the scene, so + a page made from the snapshot lists it in the linked files panel, where its elements can be hidden. +- The documentation states the model's units and axes: all coordinates and lengths are in metres, with Z up. +- A clash distance is nullable. The page writes `null` for a distance the source does not report and the clash list + shows no distance for it, where it showed 0. A 0.1/0.2 snapshot keeps the distance it has (0 when it has none). +- `GlbViewerImporter` converts a material's base colour factor from glTF's linear values to sRGB. Imported GLB colours + look lighter than in 0.2.0, which is now correct. The alpha stays linear; every channel is now rounded to the nearest + byte, where it was truncated. +- A 4D task of a type this version does not know is shown as a construct task; it was left out of the schedule. +- The properties panel shows the property sets of an element that have the same name as one group, and a property name + that repeats in a group gets a numbered suffix (`Height (2)`), so no property is lost. Elements that share an id, + which `SceneDocument.Validate()` rejects, show the properties of all of them. + ## [0.2.0] - 2026-11-12 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2055bc3..f7582c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,30 @@ The library is in `src/IOViewer`, the CLI in `src/IOViewer.Cli`, and tests in `tests/IOViewer.Tests`. Tests use small synthetic models built in code. The repository ships no customer models. +### Local feed for unpublished packages + +The model data comes from the `IOScene` package. `Directory.Packages.props` +references it with a range such as `[0.1.0-preview.1, 0.2.0-0)`: a patch of +IOScene is additive and a minor is breaking. NuGet restores the lowest version +in the range that it finds. + +To build against an IOScene version that is not on nuget.org yet (an IOScene +preview, or the lower bound itself before it is published), pack IOScene into +a local folder and pass that folder: + +```sh +dotnet build IOViewer.slnx -c Release -p:RestoreAdditionalProjectSources= +dotnet test IOViewer.slnx -c Release --no-build +``` + +For a newer preview, also raise the lower bound of the range to it. Once a +restore has put the package in the NuGet global packages folder, later +restores, `dotnet run` of the tools and `npm run e2e` find it there. Do not +commit a `nuget.config` that points to a local feed, and commit a lower bound +only when that version is on nuget.org: until then, restoring the solution +fails in the CI build job, the Docs workflow and `release.yml`, so do not tag +a release before then. + ### Viewer The HTML viewer is a JavaScript project in `src/IOViewer/Viewer` (three.js, @@ -104,8 +128,10 @@ Write commit messages in the imperative mood, for example, ## Releases Push a tag matching the package version, such as `v0.2.0`, to run `release.yml`. -The workflow builds and tests the solution, attaches packages, symbols, source and checksums to a GitHub release, -and publishes the NuGet packages through Trusted Publishing. The release notes are the section of `CHANGELOG.md` for the version, so add that section before the tag. +The workflow builds and tests the solution, publishes the NuGet packages through Trusted Publishing, and then attaches +packages, symbols, source and checksums to a GitHub release, marked as a prerelease when the version has a hyphen +(`v0.3.0-preview.1`). The release notes are the section of `CHANGELOG.md` for the version, so add that section before +the tag; the workflow stops before publishing when it is missing. The NuGet policy must name this repository, `release.yml`, the configured NuGet profile and the `IOViewer*` package pattern. ## Licensing contributions diff --git a/Directory.Build.props b/Directory.Build.props index 9a67dee..cec0e6d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ - 0.2.0 + 0.3.0-preview.1 mahach666 Copyright (c) 2026 mahach666 IOViewer diff --git a/Directory.Packages.props b/Directory.Packages.props index eb93399..7ef9376 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,8 @@ true + + diff --git a/README.md b/README.md index 0c053ca..bc7883d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![Platform](https://img.shields.io/badge/.NET-8.0%20%7C%2010.0-512BD4?style=flat-square&logo=dotnet)](https://dotnet.microsoft.com/) -IOViewer is a fast, 100% managed .NET library and command line tool that generates standalone interactive 3D WebGL / HTML BIM & CAD model viewers from code, glTF/GLB, Wavefront OBJ, or JSON. Geometry read by sibling libraries ([RvtIO](https://github.com/IOExtend/RvtIO), [IfcIO](https://github.com/IOExtend/IfcIO), and [NwcIO](https://github.com/IOExtend/NwcIO)) can be passed in by building a `ViewerDocument` in code; IOViewer has no direct dependency on them. +IOViewer is a fast, 100% managed .NET library and command line tool that generates standalone interactive 3D WebGL / HTML BIM & CAD model viewers from code, glTF/GLB, Wavefront OBJ, or JSON. Geometry read by sibling libraries ([RvtIO](https://github.com/IOExtend/RvtIO), [IfcIO](https://github.com/IOExtend/IfcIO), and [NwcIO](https://github.com/IOExtend/NwcIO)) can be passed in by building a `SceneDocument` in code. The scene model comes from the [IOScene](https://github.com/IOExtend/IOScene) package; IOViewer has no direct dependency on the format libraries. The generated HTML is a single file with no external dependencies that works offline: it opens from disk (`file://`) in a current browser with WebGL 2 and makes no network requests. The viewer is built on [three.js](https://threejs.org/), which is embedded in the file (see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)). @@ -19,7 +19,7 @@ Built clean-room under the [IOExtend](https://github.com/IOExtend) organization. ## What it does - Writes one self-contained HTML file per model; the viewer, its styles and the model data are all inlined. -- Stores geometry once per shape: meshes can share a `ViewerGeometry` placed by a `Transform3d`, and the exporter also finds meshes that are exact translated copies of each other. +- Stores geometry once per shape: instances can share a `SceneGeometry` placed by a `Transform3d`, and the exporter also finds geometries that are exact translated copies of each other. - Packs geometry compactly: positions quantized to 16-bit steps of 2⁻¹⁰ m (error at most 0.49 mm, 0.85 mm for rotated shared geometry; geometry larger than about 64 m, or shared geometry that an instance scales up, stays 32-bit float), octahedral 16-bit normals, 16- or 32-bit indices, all in one base64 block. - Shifts coordinates relative to the model centre (RTC), so georeferenced models with coordinates around 10⁶ m do not jitter. - Draws large models with few draw calls: geometry is merged into spatial chunks per material (very large models draw their repeated shapes as instanced copies instead), and hiding, selection and colouring are per-object data, so they never rebuild geometry. @@ -31,7 +31,7 @@ Built clean-room under the [IOExtend](https://github.com/IOExtend) organization. - section planes (up to six, moved and rotated with an in-scene gizmo) or a section box (keep inside or cut inside, face handles, move by the centre, box around the selection); - point-to-point distance measurement, display modes (materials, textures, white, wireframe), grid lines and a global opacity slider; - saved viewpoints with a viewpoint tour, clash results, a 4D schedule player and linked-file visibility, each shown only when the document has that data. -- Imports binary glTF 2.0 (`.glb`), Wavefront OBJ (`.obj`), and JSON snapshots. +- Imports binary glTF 2.0 (`.glb`), Wavefront OBJ (`.obj`), and JSON: viewer JSON, IOScene scene files and IOViewer 0.1/0.2 snapshots. ### Mouse and keyboard @@ -64,6 +64,9 @@ Add the library to a .NET project: dotnet add package IOViewer ``` +The library examples below are for 0.3.0, which builds on the IOScene scene model and is in preview; 0.2.0 has the +earlier model, and [CHANGELOG.md](CHANGELOG.md) lists how its types map to the new ones. + The library targets .NET 8 and .NET 10. It uses managed code without native dependencies or Windows-only APIs and supports Windows, Linux, and macOS. ## Command line @@ -92,41 +95,26 @@ Diagnostics and progress go to standard error. Run `ioviewer --help` for the ful ## Library -Create a viewer document programmatically and export to HTML: +Build a scene in code and export it to HTML: ```csharp -using IOViewer; -using IOViewer.Model; +using IOScene; using IOViewer.Exporters; -// Create viewer document -var doc = new ViewerDocument -{ - Title = "Commercial Building 3D Showcase", - Author = "IOExtend" -}; - -// Add mesh geometry -var mesh = new ViewerMesh -{ - Id = 1, - Name = "Curtain Wall Panel", - Vertices = [new(0, 0, 0), new(3, 0, 0), new(3, 3, 0), new(0, 3, 0)], - Indices = [0, 1, 2, 0, 2, 3], - Color = new RgbaColor(100, 149, 237, 200), - Opacity = 0.8 -}; -doc.Meshes.Add(mesh); - -// Export to self-contained HTML -HtmlViewerExporter.Save(doc, "showcase.html", new HtmlViewerOptions -{ - Title = "Commercial Building 3D Showcase", - IncludeProperties = true, - IncludeViewpoints = true -}); +var scene = new SceneDocument { Title = "Curtain wall panel" }; +scene.AddMesh("Panel", + [new(0, 0, 0), new(3, 0, 0), new(3, 0, 3), new(0, 0, 3)], + [0, 1, 2, 0, 2, 3], + new RgbaColor(100, 149, 237, 200)); + +HtmlViewerExporter.Save(scene, "panel.html"); ``` +`AddMesh` adds the model, the geometry, the element and the instance that links them. A real importer fills the scene +lists directly (`Models`, `Elements`, `Geometries`, `Instances`, `Materials`, `PropertySets` and the rest); the +[IOScene contract](https://github.com/IOExtend/IOScene/blob/main/docs/contract.md) describes them. To open the page with +section planes, a section box or linked models hidden, wrap the scene in a `ViewerDocument` (namespace `IOViewer.Model`). + Import a glTF / GLB model and convert to HTML viewer: ```csharp @@ -154,7 +142,8 @@ The documentation site with API references and guides is at