diff --git a/CHANGELOG.md b/CHANGELOG.md index e719972..533dba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [0.1.0] - 2026-11-13 + +First stable release. The API, the file format (kind `ioscene`, format 1) and the validation codes are those of +0.1.0-preview.2. From this release a patch adds or fixes; a breaking change waits for 0.2.0 (`docs/contract.md`, +section 5). A package that depends on IOScene references `[0.1.0, 0.2.0-0)`. + +### Changed +- README, `docs/contract.md` and SECURITY.md describe a stable package, and the install command has no + `--prerelease`. +- `docs/contract.md` and the XML documentation state these rules; the code is unchanged: + - An explicit `null` reads as the default for text other than `Kind`, lists, objects and members marked `?`. + `SceneJson.Read` rejects a `null` for a number, an enum, a math value, a date-time or a property value + with `InvalidDataException`. + - `Validate()` does not check `vertex-count` and `index-count` for a topology it does not know; `index-range`, + `normal-count` and `uv-count` apply to every topology. + - A triangle's front is the side from which its vertices run counter-clockwise, and normals point to the front. An + instance transform with a negative determinant reverses the winding (the glTF rule). + - A `PlannedStart` or `PlannedEnd` at its default `0001-01-01T00:00:00+00:00` means "not given"; a consumer leaves + such a task out of time-based views. + - Date-times are written with an offset; a value without one is read in the local time zone. + - `SceneModel.Producer` is the package id and the NuGet version without build metadata, separated by one space. + - An instance colour replaces the material colour, also as the factor of the material's texture. + - A lookup by key takes the first property with the key in the first set that has it. + - `SceneJson` writes and reads JSON nested at most 128 levels deep, which holds 62 levels of tasks. +- `docs/keys.md`: the namespaces `revit:` and `ifc:` are registered; their formats are fixed from the first releases + of `RvtIO.Scene` and `IfcIO.Scene`. Shared, project and family parameters have no key yet. +- The package has its own icon instead of a copy of the IOViewer icon. + ## [0.1.0-preview.2] - 2026-11-12 First published preview of the scene contract. Names and members may still change before 0.1.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ef005f..2c1a0ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,7 +123,7 @@ Write commit messages in the imperative mood, for example, ## Releases -Push a tag matching the package version, such as `v0.1.0` or `v0.1.0-preview.1`, to run `release.yml`. +Push a tag matching the package version, such as `v0.1.1` or `v0.2.0-preview.1`, to run `release.yml`. The workflow builds and tests the solution, publishes the NuGet package through Trusted Publishing and then attaches the package, symbols, binaries, source and checksums to a GitHub release, which is marked as a prerelease when the tag contains a hyphen. The release notes are the section of `CHANGELOG.md` for the version: in the release commit, rename diff --git a/Directory.Build.props b/Directory.Build.props index 37fa360..164e734 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ - 0.1.0-preview.2 + 0.1.0 mahach666 Copyright (c) 2026 mahach666 IOScene diff --git a/README.md b/README.md index be00e1b..41cd95b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ +![IOScene](https://raw.githubusercontent.com/IOExtend/IOScene/main/docs/images/ioscene-banner.png) + # IOScene +[![NuGet](https://img.shields.io/nuget/v/IOScene?style=flat-square&color=10B981&logo=nuget)](https://www.nuget.org/packages/IOScene) +[![Release](https://img.shields.io/github/v/release/IOExtend/IOScene?style=flat-square&color=10B981)](https://github.com/IOExtend/IOScene/releases) [![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/) @@ -10,8 +14,9 @@ it from there. The package is pure managed .NET (8 and 10) and has no dependenci Built clean-room under the [IOExtend](https://github.com/IOExtend) organization. -**Status: preview.** The model is specified in [docs/contract.md][contract]; names and members may still change -before 0.1.0. +**Status: stable (0.1).** The model is specified in [docs/contract.md][contract] and changes only as its section 5 +describes: a 0.1.x patch adds or fixes, and a breaking change waits for 0.2.0. Packages outside IOExtend are +supported from IOScene 1.0; until then the supported route is a scene file. ## What is in a scene @@ -31,7 +36,7 @@ from a scene. ## Install ```sh -dotnet add package IOScene --prerelease +dotnet add package IOScene ``` A package that depends on IOScene references one 0.x line, because a 0.x minor release may break the API and a patch @@ -42,8 +47,8 @@ line: ``` -During the preview the lower bound is the preview: `[0.1.0-preview.2, 0.2.0-0)`. `dotnet pack` reports NU5104 for a -stable package with such a range; add `$(NoWarn);NU5104` to the referencing project. +`dotnet pack` of a stable package reports NU5104 for this range, because its upper bound `0.2.0-0` is a prerelease +version; add `$(NoWarn);NU5104` to the referencing project. ## Quick start diff --git a/SECURITY.md b/SECURITY.md index bcb14b8..dd7630f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,8 @@ ## Supported versions -IOScene is in preview (0.x). Security fixes are made on the latest release only. +IOScene is at 0.x: a patch release adds or fixes, and only a minor release may break. Security fixes are made on the +latest release only. ## Reporting a vulnerability diff --git a/docs/contract.md b/docs/contract.md index dea7a1d..32af567 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -4,7 +4,8 @@ This is the specification of the scene model in the `IOScene` package and of its format 1). Producers (adapters that turn an engine's model into a scene) and consumers (viewers, coordination tools, writers of other formats) are written against this text. The API reference is the XML documentation in the package. -Status: **preview**. Until `IOScene` 0.1.0 is released, names and members may still change. +Status: **stable** from `IOScene` 0.1.0. The contract changes only as [section 5](#5-how-the-contract-changes) +describes. Contents: [purpose and invariants](#1-purpose-and-invariants), [types](#2-types), [decisions by topic](#3-decisions-by-topic), [what is not in the scene](#4-what-is-not-in-the-scene), [how the contract changes](#5-how-the-contract-changes), @@ -56,7 +57,7 @@ SceneModel // one source file, or one placement of a linked file int Id; string Key (unique in the scene, frozen derivation); string Name; string? Path string? SourceFormat ("rvt", "ifc", ...); string? Application, Author, CreatedAt string? SourceLengthUnit // information only - string? Producer // "RvtIO.Scene 0.5.0" + string? Producer // "RvtIO.Scene 0.5.0": package id, one space, version SceneElement long Id; int ModelId; string? SourceId; string? IfcGuid; long? ParentId; SceneNodeKind Kind @@ -110,7 +111,7 @@ the right member. Each takes the display string; the library never invents one. | Topic | Decision | |---|---| -| Meshes | Indexed or non-indexed triangles in `SceneGeometry`, optional normals and UVs. | +| Meshes | Indexed or non-indexed triangles in `SceneGeometry`, optional normals and UVs. A triangle's front is the side from which its vertices run counter-clockwise; normals, when given, point to the front. An instance transform with a negative determinant (a mirror) reverses the winding. This is the glTF rule. | | Shared geometry and transforms | **One path only**: `SceneInstance` -> `SceneGeometry` with an affine `Transform3d` (mirror and non-uniform scale allowed; null is the identity). A unique mesh is a geometry used once. One level; producers flatten nested blocks, hotlinks and assemblies. | | Lines, points | `Topology` on the geometry, so line work shares instancing (DWG blocks). A consumer that draws only triangles skips the rest and reports it. | | Sub-range of a geometry per instance (`IndexStart`, `IndexCount`) | **Never.** It cannot be added later without a break, and it is not needed: measured on 438 real DWG drawings, batching keeps them far under the limits. | @@ -131,16 +132,16 @@ the right member. Each takes the display string; the library never invents one. | Property values | Typed. `Text` is always the display string as the source shows it. `Number` is in coherent SI when `Unit` is set. `Integer` is a separate `long` and also carries `Boolean` as 0/1. A producer that cannot type a value writes `Kind = Text`; values are never guessed from strings. `Validate()` reports a kind without its payload. | | Units | `Unit` follows one ASCII grammar ([section 9](#9-units)). `Validate()` warns on anything else. **When `Number` is present, `Unit` is authoritative and null means dimensionless.** A producer that cannot convert a dimensioned value writes `Kind = Text` with the number only in `Text`. | | Property keys | `Key` on set and property is opaque to the scene and qualified by the producer (`revit:bip:-1001107`, `ifc:Pset_WallCommon.IsExternal`). **Registry:** [keys.md](keys.md) lists each namespace, its owner package and the key format, and the `SourceFormat` values. A registered key format changes only with a breaking release of its producer. Saved searches, clash rules and writers of other formats address keys; a viewer shows names. | -| Property lookup | A key may appear in several property sets of one element. A lookup by key takes the first set in `SceneElement.PropertySetIds` that has it. Producers list occurrence (instance) sets before type sets. | +| Property lookup | A key may appear in several property sets of one element. A lookup by key takes the first set in `SceneElement.PropertySetIds` that has it, and the first property of that set with the key. Producers list occurrence (instance) sets before type sets. | | Property sharing | Sets live in a document table and elements reference them by id. A geometry-free scene file and a property-free one are both valid documents, so a split export needs no property database; a join key (`SceneDocument.ExportId`) is additive when that is built. | | Materials | Name, RGBA, roughness, metalness, optional texture by id. Opacity is stored once (alpha), with one optional colour per instance. | -| Colour | The RGB channels of `RgbaColor` are sRGB-encoded. Alpha is straight (not premultiplied) linear coverage. When a material has a texture, the colour multiplies it (glTF `baseColorFactor`), so a producer that means the plain texture writes white. | +| Colour | The RGB channels of `RgbaColor` are sRGB-encoded. Alpha is straight (not premultiplied) linear coverage. When a material has a texture, the colour multiplies it (glTF `baseColorFactor`), so a producer that means the plain texture writes white. An instance colour replaces the material colour, also as the factor of the material's texture. | | Texture coordinates | `v = 0` is the bottom row of the image. The texture is sampled at `(u / ScaleX, v / ScaleY)` with repeat wrapping. Texture offset and rotation are baked into the UVs; they never become members. | | Coordinate frame, georeference | Not in 0.1.0. `SceneModel.Frame`, `ToShared` and a geo reference are additive members. They are a precondition of any consumer that tests two models against each other. | | Links, federation | The `Models` table plus `ModelId` on elements, levels, grids and schedules. `Models[0]` is the model of the file the producer was given; the others are its links or files federated with it. A merge helper that renumbers ids is added with the first federation consumer. No nested scenes. | | Grids | `SceneGridLine.Elevation` is the Z, in scene coordinates, of the level the line is drawn on. `Start` and `End` are authoritative for drawing. | | Viewpoints | In the scene: optional `Id` (a saved-viewpoint GUID or a BCF viewpoint GUID), name, camera. For an orthographic camera the visible height is `2 * |Target - Position| * tan(FieldOfView / 2)`. Clip planes and visibility lists are additive. | -| 4D | `SceneSchedule` in the scene. `SceneTask.Progress` is a fraction from 0 to 1. | +| 4D | `SceneSchedule` in the scene. `SceneTask.Progress` is a fraction from 0 to 1. A `PlannedStart` or `PlannedEnd` at its default `0001-01-01T00:00:00+00:00` means "not given": a consumer leaves such a task out of time-based views. | | Clash results | `SceneClashTest`, `SceneClashResult` and `SceneClashStatus` in the scene: results only. Rules, tolerances, the engine and the store of statuses across runs belong to the coordination tool. Further fields of a source (assignee, comments, dates) are additive. `Distance` is in metres: below 0 it is the penetration depth, from 0 up it is the clearance (the Navisworks convention). Null means the source did not report it. | | Conversion report | Each producer returns its own result object with `Document` and `Diagnostics`, by convention. There is no base class and no interface in 0.1.0. | @@ -201,7 +202,8 @@ consumer shows, filters, measures or tests it. From then on: - the member is authoritative; - producers keep writing the old key until the next breaking `IOScene` minor; - consumers read the member and fall back to the key through one helper that ships in `IOScene` with the member; -- `SceneModel.Producer` tells "absent in the source" from "the producer is older than the member". +- `SceneModel.Producer` tells "absent in the source" from "the producer is older than the member". It is the + package id and the NuGet version without build metadata, separated by one space: `RvtIO.Scene 0.5.0`. Promotion is therefore additive by definition. It is the only allowed case of two ways to say one thing, and it ends at the next breaking minor. @@ -218,7 +220,10 @@ They make the open enums and the open ends of the model safe: - treat an unknown `SceneProjection` as `Perspective`; - treat a `Unit` you cannot parse as unknown: show `Text` and do not compare `Number`; - expect large vertex coordinates: rebase (for example on `ComputeBounds().Center`) before narrowing to 32-bit floats; -- look a property up by key in the first set of `SceneElement.PropertySetIds` that has it. +- look a property up by key in the first set of `SceneElement.PropertySetIds` that has it, and take the first + property of that set with the key; +- leave a task whose `PlannedStart` or `PlannedEnd` is the default `0001-01-01T00:00:00+00:00` out of time-based + views. The reader keeps enum numbers it does not know, and writes them back. @@ -239,9 +244,9 @@ The reader keeps enum numbers it does not know, and writes them back. ### Versions -In 0.x a patch release is additive and a minor release is breaking. A package that depends on `IOScene` 0.N references -the range `[0.N.0, 0.N+1.0-0)`; the `-0` keeps out the previews of the next breaking line. From 1.0 the range is -`[1.M.0, 2.0.0-0)`. +In 0.x a patch release is additive or fixes a defect, and a minor release is breaking. A package that depends on +`IOScene` 0.N references the range `[0.N.0, 0.N+1.0-0)`; the `-0` keeps out the previews of the next breaking line. +From 1.0 the range is `[1.M.0, 2.0.0-0)`. ## 6. Names @@ -277,28 +282,36 @@ the range `[0.N.0, 0.N+1.0-0)`; the `-0` keeps out the previews of the next brea - **`Stream` is the primary API** (`Write(doc, Stream)`, `Read(Stream)`). The `string` overloads stay as conveniences; a .NET string caps a document at about a billion characters. - Textures are `byte[]` in the model and base64 in the file. +- Date-times (the dates of a task) are ISO 8601 text with an offset, such as `2026-03-01T08:00:00+00:00`; `SceneJson` + always writes the offset and also reads `Z` for UTC. It reads a value without an offset in the local time zone of + the machine, so a file always gives the offset. - The writer emits tables in dependency order: models, materials, textures, property sets, levels, geometries, elements with parents first, instances, then grids, viewpoints, schedules and clash tests. Readers must not rely on it. - Reader rules: unknown members are ignored; a missing optional member takes its default - ([below](#required-members-and-defaults)), and so does an explicit `null`; a `null` inside a list of objects is - rejected; a newer `Format` is rejected with a clear message, never half-read. The reader does not validate: call - `Validate()`. + ([below](#required-members-and-defaults)). An explicit `null` reads as the default for text other than `Kind`, a + list, `SceneTexture.Data`, an object (`SceneViewpoint.Camera`) and a member marked `?`. A `null` for any other + member (a number or an id, an enum, a math value, a date-time, a property value) is never written, and + `SceneJson.Read` rejects it with `InvalidDataException`. A `null` inside a list of objects is rejected; a newer + `Format` is rejected with a clear message, never half-read. The reader does not validate: call `Validate()`. - Numbers that are not finite cannot be written; `SceneJson.Write` throws `ArgumentException`. `Validate()` reports every one of them as `not-finite`, also after reading a file in which a number such as `1e999` overflows. - **Limits of format 1.** One geometry holds at most 10,000,000 vertices: a vertex, normal or UV list is one JSON value, and a reader holds one value in one buffer. `SceneJson.Write` refuses a larger geometry with `ArgumentException` before it writes anything; a producer splits the mesh. A schedule nests tasks at most 30 levels deep, which keeps a file within 64 levels of JSON nesting, the default limit of common readers; - `Validate()` reports a deeper tree as `task-depth`. + `Validate()` reports a deeper tree as `task-depth`. `SceneJson` writes and reads JSON nested at most 128 levels + deep (the root object is the first), which holds 62 levels of tasks: `Write` throws `JsonException` for a deeper + schedule, and `Read` rejects a deeper file with `InvalidDataException`. - `Format` counts file encodings of kind `ioscene`. It is bumped by a breaking model change and may also be bumped by a new encoding in an additive release. Golden files of every released `Format` are committed in `tests/golden/format-N` and read by every later version in tests. ### Required members and defaults -A file needs `Kind` and `Format` and nothing else. Every other member is optional, and a member that is missing (or -`null`) reads as its default. The defaults are part of format 1; changing one is a breaking change. +A file needs `Kind` and `Format` and nothing else. Every other member is optional, and a member that is missing reads +as its default; so does a `null` where the reader rules above allow it. The defaults are part of format 1; changing +one is a breaking change. | Member | Default | |---|---| @@ -307,7 +320,7 @@ A file needs `Kind` and `Format` and nothing else. Every other member is optiona | any other text (`Title`, `Key`, `Name`, `Category`, `TypeName`, `Id` of a task or clash, `Element1Name`, `Element2Name`, `SceneValue.Text`) | empty string, except the three names below | | any other id or number (`Id`, `ModelId`, `ElementId`, `GeometryId`, `Elevation` of a grid line, `Progress`), `Vector3d` (`Start`, `End`, `Point`, `Target`) | 0 | | any enum, except `SceneClashResult.Status` | its value 0 (`Element`, `Triangles`, `Text`, `Perspective`, `Construct`) | -| `SceneTask.PlannedStart`, `PlannedEnd` | `0001-01-01T00:00:00+00:00` | +| `SceneTask.PlannedStart`, `PlannedEnd` | `0001-01-01T00:00:00+00:00`, which means "not given" ([4D](#3-decisions-by-topic)) | | `SceneMaterial.Color` | `[200,200,200,255]` | | `SceneMaterial.Roughness`, `Metalness` | 0.5, 0.1 | | `SceneTexture.MimeType` | `image/png` | @@ -366,8 +379,10 @@ stable strings. Later versions may add codes. | `ifc-guid` | warning | An `IfcGuid` is not a 22-character IFC GlobalId (characters `0-9A-Za-z_$`, the first one `0` to `3`). | | `unit-grammar` | warning | A property unit is outside the unit grammar. Reported once per unit. | -A geometry whose topology this version does not know is not checked for counts, and a property value whose kind -this version does not know is not checked for its payload. +A geometry whose topology this version does not know is not checked for `vertex-count` and `index-count`. Its indices +still point into `Vertices`, and its normals and texture coordinates are still one per vertex, so `index-range`, +`normal-count` and `uv-count` apply to every topology. A property value whose kind this version does not know is not +checked for its payload. ## 9. Units diff --git a/docs/images/ioscene-banner.png b/docs/images/ioscene-banner.png new file mode 100644 index 0000000..e3fa06d Binary files /dev/null and b/docs/images/ioscene-banner.png differ diff --git a/docs/images/ioscene-banner.svg b/docs/images/ioscene-banner.svg new file mode 100644 index 0000000..963d588 --- /dev/null +++ b/docs/images/ioscene-banner.svg @@ -0,0 +1 @@ +IOScene \ No newline at end of file diff --git a/docs/images/ioscene-logo.png b/docs/images/ioscene-logo.png new file mode 100644 index 0000000..6dc836f Binary files /dev/null and b/docs/images/ioscene-logo.png differ diff --git a/docs/images/ioscene-logo.svg b/docs/images/ioscene-logo.svg new file mode 100644 index 0000000..ba25398 --- /dev/null +++ b/docs/images/ioscene-logo.svg @@ -0,0 +1 @@ +IOScene \ No newline at end of file diff --git a/docs/images/ioscene-social.png b/docs/images/ioscene-social.png new file mode 100644 index 0000000..3282828 Binary files /dev/null and b/docs/images/ioscene-social.png differ diff --git a/docs/keys.md b/docs/keys.md index ccd2f30..751847a 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -22,14 +22,15 @@ This file is the registry of the namespaces, of their key formats and of the `Sc and producers keep writing it until the next breaking `IOScene` minor. 7. Keys are compared as whole strings (ordinal) and never parsed back. 8. A key may appear in several property sets of one element. A lookup by key takes the first set in - `SceneElement.PropertySetIds` that has it. Producers list occurrence (instance) sets before type sets. + `SceneElement.PropertySetIds` that has it, and the first property of that set with the key. Producers list + occurrence (instance) sets before type sets. ## Namespaces | Namespace | Owner package | Status | |---|---|---| -| `revit:` | `RvtIO.Scene` | planned; the formats below are fixed with the first release of the package | -| `ifc:` | `IfcIO.Scene` | planned; the formats below are fixed with the first release of the package | +| `revit:` | `RvtIO.Scene` | registered; the formats below are fixed from the first release of the package, 0.4.0 | +| `ifc:` | `IfcIO.Scene` | registered; the formats below are fixed from the first release of the package, 0.11.0 | No other namespace is registered. Producers for NWC, DWG and other formats register theirs when they are written. @@ -41,7 +42,8 @@ No other namespace is registered. Producers for NWC, DWG and other formats regis | `revit:type` | property set | The type parameters of an element; one set shared by every element of the type. | | `revit:bip:` | property | A built-in parameter, by its numeric id, which is negative: `revit:bip:-1001107`. | -Keys for shared, project and family parameters are registered with the first release of `RvtIO.Scene`. +Shared, project and family parameters have no key yet. Their formats (for example `revit:shared:`) are +registered when `RvtIO.Scene` first writes them; that is an addition. ### `ifc:` @@ -60,8 +62,8 @@ occurrence sets. | Value | Source | Producer | |---|---|---| -| `rvt` | Revit project | `RvtIO.Scene` (planned) | -| `ifc` | IFC STEP file, also zipped (`.ifczip`) | `IfcIO.Scene` (planned) | +| `rvt` | Revit project | `RvtIO.Scene` | +| `ifc` | IFC STEP file, also zipped (`.ifczip`) | `IfcIO.Scene` | | `nwc` | Navisworks cache | `NwcIO.Scene` (later) | | `dwg` | DWG drawing | `DwgIO.Scene` (later) | | `glb` | binary glTF | `IOScene.Formats` (later) | diff --git a/src/IOScene/MathTypes.cs b/src/IOScene/MathTypes.cs index ab13e89..51ba309 100644 --- a/src/IOScene/MathTypes.cs +++ b/src/IOScene/MathTypes.cs @@ -183,13 +183,13 @@ public readonly record struct RgbaColor(byte R, byte G, byte B, byte A = 255) /// Opaque light grey (180, 180, 180). public static readonly RgbaColor Gray = new(180, 180, 180); - /// Red as a number from 0 to 1. + /// Red as a number from 0 to 1, still sRGB-encoded (R / 255). public double Rf => R / 255.0; - /// Green as a number from 0 to 1. + /// Green as a number from 0 to 1, still sRGB-encoded (G / 255). public double Gf => G / 255.0; - /// Blue as a number from 0 to 1. + /// Blue as a number from 0 to 1, still sRGB-encoded (B / 255). public double Bf => B / 255.0; /// Alpha as a number from 0 to 1. diff --git a/src/IOScene/SceneDocument.Validate.cs b/src/IOScene/SceneDocument.Validate.cs index 871acea..6cdacd9 100644 --- a/src/IOScene/SceneDocument.Validate.cs +++ b/src/IOScene/SceneDocument.Validate.cs @@ -107,7 +107,7 @@ HashSet Ids(IEnumerable ids, string what) where T : notnull } foreach (var g in Geometries) { - // An unknown topology of a later version is not checked for counts. + // An unknown topology of a later version is not checked for vertex-count and index-count. int per = g.Topology switch { SceneTopology.Triangles => 3, SceneTopology.Lines => 2, _ => 1 }; if (g.Indices.Count == 0 && g.Vertices.Count % per != 0) Error("vertex-count", $"Non-indexed geometry {g.Id} has {g.Vertices.Count} vertices for topology {g.Topology}."); if (g.Indices.Count % per != 0) Error("index-count", $"Geometry {g.Id} has {g.Indices.Count} indices for topology {g.Topology}."); diff --git a/src/IOScene/SceneGeometry.cs b/src/IOScene/SceneGeometry.cs index 75581e0..9273d4f 100644 --- a/src/IOScene/SceneGeometry.cs +++ b/src/IOScene/SceneGeometry.cs @@ -23,6 +23,10 @@ public enum SceneTopology /// should put large offsets into the instance transform, but vertex coordinates may be large: a consumer rebases them /// (for example on the centre of ) before it narrows them to 32-bit floats. /// +/// +/// A triangle's front is the side from which its vertices run counter-clockwise; , when given, +/// point to the front. An instance transform with a negative determinant reverses the winding. This is the glTF rule. +/// public sealed class SceneGeometry { /// Scene-local id, referenced by . @@ -66,13 +70,19 @@ public sealed class SceneInstance /// Id of the placed . public int GeometryId { get; set; } - /// Affine transform from local to scene coordinates, mirror included; null is the identity. + /// + /// Affine transform from local to scene coordinates, mirror included; null is the identity. A transform with a + /// negative determinant (a mirror) reverses the winding of the triangles. + /// public Transform3d? Transform { get; set; } /// Id of the , or null. public int? MaterialId { get; set; } - /// Colour that overrides the material colour, or null. Its alpha is the opacity. + /// + /// Colour that replaces the material colour, also as the factor of the material's texture, or null. Its alpha is + /// the opacity. + /// public RgbaColor? Color { get; set; } /// Name of the placement, or null. diff --git a/src/IOScene/SceneJson.cs b/src/IOScene/SceneJson.cs index a090fa1..01243c2 100644 --- a/src/IOScene/SceneJson.cs +++ b/src/IOScene/SceneJson.cs @@ -40,9 +40,12 @@ public static class SceneJson /// /// Creates serializer options that carry the rules of scene JSON, for a document of another kind that holds a /// as a member. The math values keep their array encoding with any options; these - /// options add the rest: null members are omitted, an explicit null is read as a missing member, a - /// null inside a list of objects is rejected with , and text is escaped - /// as escapes it. + /// options add the rest: null members are omitted; an explicit null is read as a missing member for text + /// (except , which rejects it), lists, objects and nullable members, and makes the + /// serializer throw for a member of any other type, such as a number, an enum, a math + /// value or a date-time; a null inside a list of objects is rejected with + /// ; and text is escaped as escapes + /// it. /// /// /// The rules apply to every type that is read or written with the options, the holder included. Two things that @@ -59,7 +62,11 @@ public static class SceneJson /// A number in the scene is NaN or infinite, which reports as /// not-finite, or a geometry has more than 10,000,000 vertices, the limit of one geometry in scene JSON. /// - /// A schedule task is inside its own sub-tasks, which reports as task-tree. + /// + /// A schedule task is inside its own sub-tasks, which reports as + /// task-tree, or tasks nest more than 62 levels deep, which needs more than the 128 levels of JSON nesting + /// that scene JSON allows. reports more than 30 levels as task-depth. + /// public static void Write(SceneDocument document, Stream stream) { ArgumentNullException.ThrowIfNull(document); @@ -71,7 +78,7 @@ public static void Write(SceneDocument document, Stream stream) /// Scene to write. It is not changed. /// The JSON text. /// A number in the scene is NaN or infinite, or a geometry has more than 10,000,000 vertices. - /// A schedule task is inside its own sub-tasks. + /// A schedule task is inside its own sub-tasks, or tasks nest more than 62 levels deep. public static string Write(SceneDocument document) { ArgumentNullException.ThrowIfNull(document); @@ -85,7 +92,7 @@ public static string Write(SceneDocument document) /// Scene to write. It is not changed. /// Path of the file to create or replace. /// A number in the scene is NaN or infinite, or a geometry has more than 10,000,000 vertices. - /// A schedule task is inside its own sub-tasks. + /// A schedule task is inside its own sub-tasks, or tasks nest more than 62 levels deep. public static void Save(SceneDocument document, string path) { ArgumentNullException.ThrowIfNull(document); @@ -150,8 +157,10 @@ private static SceneDocument Checked(Func read) } } - // Reading treats an explicit null like a missing member, so no member typed as non-null comes back null, and - // rejects a null inside a list of objects. A nullable member is null by default, so skipping its null changes nothing. + // Reading treats an explicit null for a member of a reference type like a missing member, so no member typed as + // non-null comes back null, and rejects a null inside a list of objects. A nullable member is null by default, so + // skipping its null changes nothing. A null for a value type is left to the serialiser, which reads it into a + // nullable member and rejects it for any other. private static void Harden(JsonTypeInfo info) { if (info.Kind != JsonTypeInfoKind.Object) return; diff --git a/src/IOScene/SceneModel.cs b/src/IOScene/SceneModel.cs index 2a00851..7c0f1d2 100644 --- a/src/IOScene/SceneModel.cs +++ b/src/IOScene/SceneModel.cs @@ -41,8 +41,9 @@ public sealed class SceneModel public string? SourceLengthUnit { get; set; } /// - /// Package and version that produced the model, such as RvtIO.Scene 0.5.0. It tells "absent in the - /// source" from "the producer is older than the member". + /// Package and version that produced the model: the package id and the NuGet version without build metadata, + /// separated by one space, such as RvtIO.Scene 0.5.0. It tells "absent in the source" from "the producer + /// is older than the member". /// public string? Producer { get; set; } } diff --git a/src/IOScene/SceneSchedule.cs b/src/IOScene/SceneSchedule.cs index ddba5da..7036cf7 100644 --- a/src/IOScene/SceneSchedule.cs +++ b/src/IOScene/SceneSchedule.cs @@ -30,10 +30,16 @@ public sealed class SceneTask /// What the task does to its elements. public SceneTaskType TaskType { get; set; } - /// Planned start. + /// + /// Planned start. The default, 0001-01-01T00:00:00+00:00, means not given: a consumer leaves the task out of + /// time-based views. + /// public DateTimeOffset PlannedStart { get; set; } - /// Planned end. + /// + /// Planned end. The default, 0001-01-01T00:00:00+00:00, means not given: a consumer leaves the task out of + /// time-based views. + /// public DateTimeOffset PlannedEnd { get; set; } /// Actual start, or null. diff --git a/src/IOScene/icon.png b/src/IOScene/icon.png index 8dd500b..e2e89f6 100644 Binary files a/src/IOScene/icon.png and b/src/IOScene/icon.png differ diff --git a/tests/IOScene.Tests/SceneJsonTests.cs b/tests/IOScene.Tests/SceneJsonTests.cs index ab3a8d2..874e5d2 100644 --- a/tests/IOScene.Tests/SceneJsonTests.cs +++ b/tests/IOScene.Tests/SceneJsonTests.cs @@ -134,6 +134,63 @@ public void A_null_for_a_member_that_is_never_null_is_read_as_missing() Assert.Equal(string.Empty, doc.PropertySets[0].Properties[1].Value.Text); } + [Fact] + public void A_null_for_an_object_is_read_as_missing() => + Assert.Equal(0.785, SceneJson.Read(Head + ",\"Viewpoints\":[{\"Camera\":null}]}").Viewpoints[0].Camera.FieldOfView); + + [Theory] + [InlineData("\"Grids\":[{\"Lines\":[{\"Elevation\":null}]}]")] // a number + [InlineData("\"Elements\":[{\"Id\":null}]")] // an id + [InlineData("\"Elements\":[{\"Id\":1,\"Kind\":null}]")] // an enum + [InlineData("\"ClashTests\":[{\"Clashes\":[{\"Point\":null}]}]")] // a math value + [InlineData("\"Materials\":[{\"Id\":1,\"Color\":null}]")] + [InlineData("\"Schedules\":[{\"Tasks\":[{\"PlannedStart\":null}]}]")] // a date-time + [InlineData("\"PropertySets\":[{\"Id\":1,\"Properties\":[{\"Name\":\"P\",\"Value\":null}]}]")] // a property value + public void A_null_for_a_member_that_is_a_value_is_rejected(string table) + { + Assert.Throws(() => SceneJson.Read(Head + "," + table + "}")); + // In a document of another kind the serializer's own exception comes through. + Assert.Throws(() => JsonSerializer.Deserialize("{\"Scene\":" + Head + "," + table + "}}", SceneJson.CreateOptions())); + } + + [Fact] + public void A_date_time_is_written_with_its_offset_and_read_without_one_in_the_local_time_zone() + { + var start = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.FromHours(3)); + var doc = new SceneDocument { Schedules = { new SceneSchedule { Tasks = { new SceneTask { Id = "t", PlannedStart = start } } } } }; + string json = SceneJson.Write(doc); + Assert.Contains("\"PlannedStart\":\"2026-01-02T03:04:05+03:00\"", json); + Assert.True(start.EqualsExact(SceneJson.Read(json).Schedules[0].Tasks[0].PlannedStart)); + + DateTimeOffset Read(string value) => SceneJson.Read(Head + ",\"Schedules\":[{\"Tasks\":[{\"PlannedStart\":\"" + value + "\"}]}]}").Schedules[0].Tasks[0].PlannedStart; + Assert.True(new DateTimeOffset(2026, 1, 2, 0, 4, 5, TimeSpan.Zero).EqualsExact(Read("2026-01-02T00:04:05Z"))); + Assert.True(new DateTimeOffset(new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Local)).EqualsExact(Read("2026-01-02T03:04:05"))); + } + + [Fact] + public void JSON_nests_at_most_128_levels_which_holds_62_levels_of_tasks() + { + static SceneDocument Tasks(int levels) + { + var top = new SceneTask { Id = "1" }; + var at = top; + for (int level = 2; level <= levels; level++) + { + at.Children.Add(new SceneTask { Id = "t" }); + at = at.Children[0]; + } + return new SceneDocument { Schedules = { new SceneSchedule { Tasks = { top } } } }; + } + + string json = SceneJson.Write(Tasks(62)); + Assert.Equal(json, SceneJson.Write(SceneJson.Read(json))); + Assert.Throws(() => SceneJson.Write(Tasks(63))); + + // The root object and 127 arrays inside it are 128 levels. + Assert.Empty(SceneJson.Read(Head + ",\"Later\":" + new string('[', 127) + new string(']', 127) + "}").Models); + Assert.Throws(() => SceneJson.Read(Head + ",\"Later\":" + new string('[', 128) + new string(']', 128) + "}")); + } + [Fact] public void Unknown_members_and_unknown_enum_numbers_survive_a_round_trip_as_numbers() { diff --git a/tests/IOScene.Tests/ValidateTests.cs b/tests/IOScene.Tests/ValidateTests.cs index d25a3e5..a53242e 100644 --- a/tests/IOScene.Tests/ValidateTests.cs +++ b/tests/IOScene.Tests/ValidateTests.cs @@ -178,6 +178,18 @@ public void Vertex_count_of_non_indexed_geometry(SceneTopology topology, int ver Assert.Equal(expected, Codes(doc)); } + [Fact] + public void A_topology_of_a_later_version_is_still_checked_for_index_range_normal_count_and_uv_count() + { + // Four indices are not checked for the topology; they still point into the vertices, and normals and texture + // coordinates are still one per vertex. + var doc = With(d => d.Geometries.Add(new SceneGeometry + { + Id = 9, Topology = (SceneTopology)7, Vertices = [new(0, 0, 0), new(1, 0, 0)], Indices = [0, 1, 5, 1], Normals = [Vector3d.UnitZ], UVs = [Vector2d.Zero], + })); + Assert.Equal(new[] { "index-range", "normal-count", "uv-count" }, Codes(doc)); + } + [Fact] public void Index_count() => Assert.Equal(new[] { "index-count" }, Codes(With(d => d.Geometries[0].Indices = [0, 1, 2, 3])));