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
+[](https://www.nuget.org/packages/IOScene)
+[](https://github.com/IOExtend/IOScene/releases)
[](https://opensource.org/licenses/MIT)
[](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
zcmeHLdpy)@8voISaxy3FRwtq3)S)_|5K_(PD3PL&NGP`u&AOElGuX|_sn!}X?qV=Q
zxh2=RluZ}cGLqXMXTl7oVoYPq%>HJ6I;YR>&aZ#=kNsokA3yK=Jn#EF-{*P1@AE#1
z_9v{?tlqR50Dv{tM=ehQz)Cj&P$*Mg369j!RzC!QjV;!e7LHf5riUypWw8iK@A;XD
zagK+tDpw&MDOX?Ak8%ked7$)IBN441S9a&v&cf8Xa0ER+ibLvm*ETL3ii&4M#r3
HnE$TF+T()1;4y|q4lZh?rU*^+iB`kT
zVtjDUT)k`-P?>tSb(xv}n-H>Un$>}t#Wtan6J$VbN1yzXuN+@eySqS)4)+15^K>ZurgC;y<+y-;5=Jt=FZ&@mb!Aj;
zJ9q~-1@Z4+4jR(vnC*F|S-1JYgKOt0xeFY#o4sT*CX!zqdG1>X=E8NY&3JNlEFU~3
nrHn>Hn}UI}{DVURmvdsR_xhsk6JBZ7QX8!gpRl}t$TRMr#^mu1
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 @@
+
\ 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.0mahach666Copyright (c) 2026 mahach666IOScene
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
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