From 3521e3fc2373bc6f8d49de0a520742398814f8a5 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:38:58 +0300 Subject: [PATCH 1/8] Add the IOScene logo, banner, social preview and package icon The package icon was a copy of the IOViewer icon. The artwork is drawn in the style of the other IOExtend repositories: "IO" in cyan as in IOViewer, "Scene" in emerald. The README shows the banner and the NuGet and release badges. --- README.md | 6 ++++++ docs/images/ioscene-banner.png | Bin 0 -> 3291 bytes docs/images/ioscene-banner.svg | 1 + docs/images/ioscene-logo.png | Bin 0 -> 6399 bytes docs/images/ioscene-logo.svg | 1 + docs/images/ioscene-social.png | Bin 0 -> 5181 bytes src/IOScene/icon.png | Bin 1918 -> 1926 bytes 7 files changed, 8 insertions(+) create mode 100644 docs/images/ioscene-banner.png create mode 100644 docs/images/ioscene-banner.svg create mode 100644 docs/images/ioscene-logo.png create mode 100644 docs/images/ioscene-logo.svg create mode 100644 docs/images/ioscene-social.png diff --git a/README.md b/README.md index be00e1b..2603ae7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ +

+ IOScene +

+ # 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/) diff --git a/docs/images/ioscene-banner.png b/docs/images/ioscene-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..e3fa06dc9710124ea6798691841dcd579ac17c02 GIT binary patch literal 3291 zcmeHJX;@Qd7XAn>j4ZY)U_%u<(ponbL5e^UwsB#GWmKpPiGs*LD~oLaMYutz5G^(% zgG>~f;09u}0@?w>8c?JHAwdk%5+D#EK}tXZL`Wd>B|4p(%rpO{f99D#@6B`1x#xW6 zJ?H&S&IkJMFHEIzzhs%|UXgt*6aeo7gsiF<-yl-=2EO+b`4`qmjk zy+jzA0N{}UG}z=~F8wBSY`J67HP}R#KR9#X0d0)5XmTjMpPvaE74{Hyl(RWo`Bt!9 z0HEo+SmF^cg~fnlXu|odw{M@Jec$=cgV2|0wxv*6OPnhfQv*iuOTZadt1uXI)erEZ2 zot||vzIy-LxG=aSy3(r{Q%+iE-sd|MACcoVK!`$dpRvLTH28nYdLk@nuY=IN=@XvX zh4T8C0No+zRpt)MZQu{G|JplISIzoKCgd@zryjT{U(CGMfONJa$8W`_eV@;JL>F9_ z_>*PwdI@ggUA|qy^obGOTf+-`%lg>hVneTv;!I8aEl=|vkNJM_ z-YOsUpYcMcjt9r_1~+3DkCPU(T~jrKxkmw)M#D z0*jR@i(DX1-p)mS96u)@-2jk4&*pfVT2ai8@c@&7}t(KJ$vth^DOfQvybHU zF6fE8a3GzcdY*QdMe9$E$_~KwcUBVAGOIhRJWIMu%qO{m_s0T-gQQYR$kAC>XTZR;elsMQph{^ux2^W(q0{GmJ?n$2qU%?gb< zSo*^&Co+{bQ#Fx{X6oQo_HNj_2&<}%EsmGn67Et{c)H`v#lsu$LToMVeYz(8`e9tW zPPd#=ii}O|E5cpBB%Wt`x11i-PY_Ze89>Bd?0H`eDM5*|iUuxX)GqqDtCXJ77oRpc zoH-@Pd%rFrJ}6S=m(6tZ>z9YhYmYE>C)H%Ad%p3Kty9^CfKXAUqhpS4_OntiZz_$L%l4*97~{IPp6LVASfEQ0A(bw9f<{vNF q8tzs=bVE<%uc6xi>6pPw)fgKo!F@Y#%{j;>*!MespLIOScene \ 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 0000000000000000000000000000000000000000..6dc836fc3666f55c9800cc2bc9a870809c7c5432 GIT binary patch literal 6399 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3282828119ddf72875787188f47eb91aa4831020 GIT binary patch literal 5181 zcmeHLX;@QN8a_rr0fQ~5P>=>%2Rda~T%atn$U~djs%2CJM3#U811e}}qCnVea3~l= z3PBcAM#UM5A=I*@AtXSNvV`C~r7Q*lA|xOr5|#+zhPk24c$0bN=ltozpOgFKoP5js zz2E!g+)SV2ShTK*E&u?1>2cL-LxG5 z&vc*-fcast8Gu{nsqleM-{Qi%d$)zctHBm~c-27}1CY99afE>Sr$t0o{$l%0Q?(YW2XGCXH@y+sP_3e& z$0t}m+HJe1y*2~A=Lj$9yQ1j1`Ge&BPPL&%Ccr64!zcz}wCM`!jfOYr9!xy{fKaxb zV$)C72KqM*5fXm-+kjem%JO;1=l&HtI7?w2ox+4!a&JAAVD)%G9&TU#}3 z)I8Qh1Fl6J!Xd6?m$)uCX|(zKad*;dp3%u*<)Fyp=<-6!(%gOO4V&dW+EQTMIZTSY z_Zup$5qc`2Yk^b<8MDg7&A^vi4@h93L0n-8V$vDMX(hC%VgaT|kh2?5Y#f|C0LZ`% ztb0reW$=Lf9%&-#k!P$bckp+lQ;Q!_(7bKD&k~#8X(fzbdDSkU0^oxrlNU)V!7o^` zas%dI#Y#I>sAvYHA<9C{|Bm!C-z^i=eoR-7YBpHH{Zuu*1c}{DWBtDFwEqpE z@`Vplgrx{3GEnGK7aR%Q6OFh3iX%LzH^2)=iOGj|Si_(a<%;MuBG>RYMY69i z{9q_bUYzVZ8Jz4gGj7OJJQMjg3fb~UOyT=T*#2Mt-r~X~dH@)A%(htJLr@GWiCj%I zRRYwO0<~34iFw4@tG#1p(4t643iox8T7?jnA9{3=bYARX$H!**mC`?nQ(U+6^PSK) zlgPIr)#ph-T7+#5MS6ENE(YT61Wz8jGp{MHrx1$5Bt6x(ZX$Iy@w#DAG7gN!QqcGt zlrKk|rRp^%@NExr`*mHfz}zkicaz|U)eCa=eOS4}O|N1pk@iHf{|Rg2!FA5QSZL-<{|WZMekE#9=SM&-48mCRm4*C0?4|CCscC6S zvBIFRY2aaqogB}Bny5wvRrD9)2Z`T(h>txpFZheBL|CMNWXO^-P&{*Z6dxTRYwtcc z7i(4bj2uzcW#%G~p2Z;1tMoiz!s_Y4Vd?FHvKFYjTba2#!ACS{D_ZnXQ?ul=u^x`?PbiEb?h{<8t@h97-he zB#ygFjAr$`9*kc-*{WG j@nYt8_R6YKSE+Tj^>D+7CofgPng@=2eB7Jv6`KApf94_K literal 0 HcmV?d00001 diff --git a/src/IOScene/icon.png b/src/IOScene/icon.png index 8dd500b06ab17b4d4e66f15c242355fbd09b58ee..e2e89f6e7f40423001c476c465cf5f858be1617f 100644 GIT binary patch delta 1911 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 z Date: Fri, 13 Nov 2026 01:04:59 +0300 Subject: [PATCH 2/8] Pin how explicit nulls and unknown topologies are read and checked The contract and the CreateOptions documentation said that an explicit null always reads as a missing member. The reader does that only for text, lists, objects and nullable members; a null for a number, an id, an enum, a math value, a date-time or a property value is rejected. The text now says so, and a test pins the rejection for SceneJson.Read and for the options of SceneJson. The contract said that a geometry of an unknown topology is not checked for counts, but Validate skips only vertex-count and index-count: index-range, normal-count and uv-count apply to every topology. The text and the comment now say so, and a test pins it. No code changes. --- docs/contract.md | 19 ++++++++++++------- src/IOScene/SceneDocument.Validate.cs | 2 +- src/IOScene/SceneJson.cs | 15 ++++++++++----- tests/IOScene.Tests/SceneJsonTests.cs | 19 +++++++++++++++++++ tests/IOScene.Tests/ValidateTests.cs | 12 ++++++++++++ 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index dea7a1d..9b6ee08 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -281,9 +281,11 @@ the range `[0.N.0, 0.N+1.0-0)`; the `-0` keeps out the previews of the next brea 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 @@ -297,8 +299,9 @@ the range `[0.N.0, 0.N+1.0-0)`; the `-0` keeps out the previews of the next brea ### 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 | |---|---| @@ -366,8 +369,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/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/SceneJson.cs b/src/IOScene/SceneJson.cs index a090fa1..dd8fd83 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 @@ -150,8 +153,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/tests/IOScene.Tests/SceneJsonTests.cs b/tests/IOScene.Tests/SceneJsonTests.cs index ab3a8d2..0af4847 100644 --- a/tests/IOScene.Tests/SceneJsonTests.cs +++ b/tests/IOScene.Tests/SceneJsonTests.cs @@ -134,6 +134,25 @@ 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 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]))); From 032373fc97f3cb9116b9cfb93e755949f570ef9e Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:05:11 +0300 Subject: [PATCH 3/8] Document winding, planned dates and the other contract rules The contract and the XML documentation now state rules that were left open or only implied: - A triangle's front is the side from which its vertices run counter-clockwise, normals point to the front, and 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", and a consumer leaves such a task out of time-based views. - Date-times are written with an offset; SceneJson reads a value without one in the local time zone of the machine. - 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. - RgbaColor.Rf, Gf and Bf are still sRGB-encoded. docs/keys.md registers the namespaces revit: and ifc: with the first releases of their producers, and says that shared, project and family parameters have no key yet. Tests pin the date-time encoding and the nesting limits. No code changes. --- docs/contract.md | 27 ++++++++++++------- docs/keys.md | 14 +++++----- src/IOScene/MathTypes.cs | 6 ++--- src/IOScene/SceneGeometry.cs | 14 ++++++++-- src/IOScene/SceneJson.cs | 10 ++++--- src/IOScene/SceneModel.cs | 5 ++-- src/IOScene/SceneSchedule.cs | 10 +++++-- tests/IOScene.Tests/SceneJsonTests.cs | 38 +++++++++++++++++++++++++++ 8 files changed, 97 insertions(+), 27 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index 9b6ee08..d178739 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -56,7 +56,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 +110,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 +131,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 +201,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 +219,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. @@ -278,6 +282,9 @@ the range `[0.N.0, 0.N+1.0-0)`; the `-0` keeps out the previews of the next brea conveniences; a .NET string caps a document at about a billion characters. - Textures are `byte[]` in the model and base64 in the file. - The writer emits tables in dependency order: models, materials, textures, property sets, levels, geometries, +- 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. 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 @@ -292,7 +299,9 @@ the range `[0.N.0, 0.N+1.0-0)`; the `-0` keeps out the previews of the next brea 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. @@ -310,7 +319,7 @@ one is a breaking change. | 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` | 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/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 dd8fd83..01243c2 100644 --- a/src/IOScene/SceneJson.cs +++ b/src/IOScene/SceneJson.cs @@ -62,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); @@ -74,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); @@ -88,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); 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/tests/IOScene.Tests/SceneJsonTests.cs b/tests/IOScene.Tests/SceneJsonTests.cs index 0af4847..874e5d2 100644 --- a/tests/IOScene.Tests/SceneJsonTests.cs +++ b/tests/IOScene.Tests/SceneJsonTests.cs @@ -153,6 +153,44 @@ public void A_null_for_a_member_that_is_a_value_is_rejected(string table) 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() { From 4b7997a84bacdaf6ccdf84dab840f5efc83d83fe Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:05:19 +0300 Subject: [PATCH 4/8] Release 0.1.0 The 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 now on a patch only adds and every other change waits for 0.2.0. README, docs/contract.md and SECURITY.md no longer describe a preview. The install command has no --prerelease, the README gives the right reason for NU5104 with the range [0.1.0, 0.2.0-0), and says that packages outside IOExtend are supported from IOScene 1.0. CONTRIBUTING uses tags that can still be released as examples. --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- Directory.Build.props | 2 +- README.md | 11 ++++++----- SECURITY.md | 3 ++- docs/contract.md | 5 +++-- 6 files changed, 41 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e719972..6ffe5d1 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 only adds; every other 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 id, 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 with 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 2603ae7..27ca697 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,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 only adds, and a change to an existing member 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 @@ -37,7 +38,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 @@ -48,8 +49,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..5c8a616 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 only adds, and 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 d178739..f41eaff 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), @@ -281,10 +282,10 @@ 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. -- The writer emits tables in dependency order: models, materials, textures, property sets, levels, geometries, - 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 From 3b8f7ed0d97a27a758656cee57fa4d9322e098db Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:18:53 +0300 Subject: [PATCH 5/8] Show the README banner as a Markdown image nuget.org renders package readmes with raw HTML turned off, so the HTML banner would show as escaped markup on the package page. A Markdown image renders on both GitHub and nuget.org. --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 27ca697..858fed7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -

- IOScene -

+![IOScene](https://raw.githubusercontent.com/IOExtend/IOScene/main/docs/images/ioscene-banner.png) # IOScene From c056c32de3fe48f6443e4895d9c3a2347c48b073 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:19:01 +0300 Subject: [PATCH 6/8] Say that a patch adds or fixes, not that it only adds "Only adds" ruled out a bug fix or a security fix in a 0.1.x patch, which contradicted the security policy's own next sentence. A patch adds or fixes, and a breaking change, as contract section 5 defines it, waits for 0.2.0. --- README.md | 2 +- SECURITY.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 858fed7..41cd95b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ 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: 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 only adds, and a change to an existing member waits for 0.2.0. Packages outside IOExtend are +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 diff --git a/SECURITY.md b/SECURITY.md index 5c8a616..dd7630f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ ## Supported versions -IOScene is at 0.x: a patch release only adds, and a minor release may break. 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 From f5dc6b781743f019c22347e89f0962ecc3e47498 Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:19:07 +0300 Subject: [PATCH 7/8] Say in the 0.1.0 changelog that a patch adds or fixes Match README and SECURITY.md: a 0.1.x patch may fix as well as add, and only a breaking change waits for 0.2.0. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffe5d1..6ff9e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [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 only adds; every other change waits for 0.2.0 (`docs/contract.md`, +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 From a4fd40c99cf75ae0419ef6bf2bcdb757f839740b Mon Sep 17 00:00:00 2001 From: mahach666 <79989386+mahach666@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:32:16 +0300 Subject: [PATCH 8/8] Say in the contract that a 0.x patch may also fix a defect --- CHANGELOG.md | 6 +++--- docs/contract.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ff9e10..533dba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ section 5). A package that depends on IOScene references `[0.1.0, 0.2.0-0)`. `--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 id, an enum, a math value, a date-time or a property value + `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. @@ -30,8 +30,8 @@ section 5). A package that depends on IOScene references `[0.1.0, 0.2.0-0)`. - 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 with the first releases of `RvtIO.Scene` and - `IfcIO.Scene`. Shared, project and family parameters have no key yet. +- `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 diff --git a/docs/contract.md b/docs/contract.md index f41eaff..32af567 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -244,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