diff --git a/SAM/SAM.Geometry/Geometry/Spatial/Classes/Triangle3D.cs b/SAM/SAM.Geometry/Geometry/Spatial/Classes/Triangle3D.cs
index e81ccf9e0..d20067ce7 100644
--- a/SAM/SAM.Geometry/Geometry/Spatial/Classes/Triangle3D.cs
+++ b/SAM/SAM.Geometry/Geometry/Spatial/Classes/Triangle3D.cs
@@ -138,7 +138,7 @@ public Point3D GetCentroid()
public void Reverse()
{
- points.Reverse();
+ Array.Reverse(points);
}
public Point3D this[int index]
diff --git a/SAM/SAM.Tests/Triangle3DReverseTests.cs b/SAM/SAM.Tests/Triangle3DReverseTests.cs
new file mode 100644
index 000000000..dbdcaaae0
--- /dev/null
+++ b/SAM/SAM.Tests/Triangle3DReverseTests.cs
@@ -0,0 +1,359 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors
+
+using System.Collections.Generic;
+using System.Linq;
+using SAM.Geometry.Object.Spatial;
+using SAM.Geometry.Spatial;
+using Xunit;
+
+namespace SAM.Tests
+{
+ ///
+ /// Triangle3D.Reverse() used to call the LINQ Enumerable.Reverse on its Point3D[] and discard the result, so it
+ /// never changed anything. Its two callers (Query.Volume(Shell) and Create.Mesh3D(Shell)) rely on it to correct
+ /// triangle winding against the parent face normal.
+ ///
+ public class Triangle3DReverseTests
+ {
+ private const double Tol = 1e-9;
+
+ private static readonly Point3D A = new Point3D(0, 0, 0);
+ private static readonly Point3D B = new Point3D(4, 0, 0);
+ private static readonly Point3D C = new Point3D(0, 3, 0);
+
+ public static IEnumerable Poses()
+ {
+ foreach (string pose in new string[] { "identity", "translation", "rotationZ", "rotationXYZ", "rotationAndTranslation" })
+ {
+ yield return new object[] { pose };
+ }
+ }
+
+ private static Transform3D GetPose(string pose)
+ {
+ switch (pose)
+ {
+ case "translation":
+ return Transform3D.GetTranslation(12.5, -7.25, 3.0);
+ case "rotationZ":
+ return Transform3D.GetRotationZ(0.7);
+ case "rotationXYZ":
+ return Transform3D.GetRotationX(0.9).Multiply(Transform3D.GetRotationY(-1.1)).Multiply(Transform3D.GetRotationZ(0.4));
+ case "rotationAndTranslation":
+ return Transform3D.GetRotationY(2.3).Multiply(Transform3D.GetRotationX(0.6)).Multiply(Transform3D.GetTranslation(-3, 9, 21));
+ default:
+ return Transform3D.GetIdentity();
+ }
+ }
+
+ // ---- Triangle3D unit tests -------------------------------------------------------------------------
+
+ [Fact]
+ public void Reverse_Triangle_ReversesVertexOrder()
+ {
+ Triangle3D triangle3D = new Triangle3D(A, B, C);
+
+ triangle3D.Reverse();
+
+ List point3Ds = triangle3D.GetPoints();
+ AssertSame(C, point3Ds[0]);
+ AssertSame(B, point3Ds[1]);
+ AssertSame(A, point3Ds[2]);
+ }
+
+ [Fact]
+ public void Reverse_Triangle_IndexerReflectsNewOrder()
+ {
+ Triangle3D triangle3D = new Triangle3D(A, B, C);
+
+ triangle3D.Reverse();
+
+ AssertSame(C, triangle3D[0]);
+ AssertSame(B, triangle3D[1]);
+ AssertSame(A, triangle3D[2]);
+ }
+
+ [Fact]
+ public void Reverse_Triangle_FlipsNormal()
+ {
+ Triangle3D triangle3D = new Triangle3D(A, B, C);
+ Vector3D before = triangle3D.GetNormal();
+
+ triangle3D.Reverse();
+
+ Vector3D after = triangle3D.GetNormal();
+ Assert.Equal(-before.X, after.X, 9);
+ Assert.Equal(-before.Y, after.Y, 9);
+ Assert.Equal(-before.Z, after.Z, 9);
+ Assert.False(after.SameHalf(before));
+ }
+
+ [Theory]
+ [MemberData(nameof(Poses))]
+ public void Reverse_NonzeroSignedVolume_ChangesSign(string pose)
+ {
+ Triangle3D triangle3D = (Triangle3D)new Triangle3D(new Point3D(1, 2, 3), new Point3D(5, 2, 3.5), new Point3D(1, 6, 4)).GetTransformed(GetPose(pose));
+ double before = triangle3D.SignedVolume();
+ Assert.True(System.Math.Abs(before) > 1e-6, pose);
+
+ triangle3D.Reverse();
+
+ Assert.Equal(-before, triangle3D.SignedVolume(), 9);
+ }
+
+ [Theory]
+ [MemberData(nameof(Poses))]
+ public void Reverse_Twice_RestoresOriginalVertexSequence(string pose)
+ {
+ Triangle3D original = (Triangle3D)new Triangle3D(A, B, C).GetTransformed(GetPose(pose));
+ Triangle3D triangle3D = new Triangle3D(original);
+ triangle3D.Reverse();
+ Assert.False(original == triangle3D, pose);
+
+ triangle3D.Reverse();
+
+ Assert.True(original == triangle3D, pose);
+ Assert.True(original.Equals(triangle3D), pose);
+ }
+
+ [Theory]
+ [MemberData(nameof(Poses))]
+ public void Reverse_Triangle_PreservesAreaCentroidAndBounds(string pose)
+ {
+ Triangle3D triangle3D = (Triangle3D)new Triangle3D(A, B, C).GetTransformed(GetPose(pose));
+ double area = triangle3D.GetArea();
+ Point3D centroid = triangle3D.GetCentroid();
+ BoundingBox3D bounds = triangle3D.GetBoundingBox();
+ double length = triangle3D.GetLength();
+
+ triangle3D.Reverse();
+
+ Assert.Equal(area, triangle3D.GetArea(), 9);
+ Assert.Equal(length, triangle3D.GetLength(), 9);
+ AssertSame(centroid, triangle3D.GetCentroid());
+ BoundingBox3D boundsAfter = triangle3D.GetBoundingBox();
+ AssertSame(bounds.Min, boundsAfter.Min);
+ AssertSame(bounds.Max, boundsAfter.Max);
+ }
+
+ [Fact]
+ public void Reverse_Triangle_ReversesSegmentsAndKeepsClosedLoop()
+ {
+ Triangle3D triangle3D = new Triangle3D(A, B, C);
+
+ triangle3D.Reverse();
+
+ List segment3Ds = triangle3D.GetSegments();
+ Assert.Equal(3, segment3Ds.Count);
+ AssertSame(C, segment3Ds[0][0]);
+ AssertSame(B, segment3Ds[0][1]);
+ AssertSame(B, segment3Ds[1][0]);
+ AssertSame(A, segment3Ds[1][1]);
+ AssertSame(A, segment3Ds[2][0]);
+ AssertSame(C, segment3Ds[2][1]);
+ }
+
+ [Fact]
+ public void Reverse_Triangle_SurvivesCloneAndJsonRoundTrip()
+ {
+ Triangle3D triangle3D = new Triangle3D(A, B, C);
+ triangle3D.Reverse();
+
+ Triangle3D clone = (Triangle3D)triangle3D.Clone();
+ Triangle3D fromJson = new Triangle3D(triangle3D.ToJsonObject());
+
+ Assert.True(triangle3D == clone);
+ Assert.True(triangle3D == fromJson);
+ AssertSame(C, fromJson[0]);
+ AssertSame(A, fromJson[2]);
+ }
+
+ [Fact]
+ public void Reverse_Triangle_DoesNotAffectCopiesOrSourcePoints()
+ {
+ Triangle3D triangle3D = new Triangle3D(A, B, C);
+ Triangle3D copy = new Triangle3D(triangle3D);
+
+ triangle3D.Reverse();
+
+ AssertSame(A, copy[0]);
+ AssertSame(C, copy[2]);
+ Assert.Equal(0, A.X);
+ Assert.Equal(4, B.X);
+ Assert.Equal(3, C.Y);
+ }
+
+ [Fact]
+ public void Reverse_Triangle3DObject_ReversesAndKeepsMetadata()
+ {
+ Triangle3DObject triangle3DObject = new Triangle3DObject(new Triangle3D(A, B, C));
+ Vector3D before = triangle3DObject.GetNormal();
+
+ triangle3DObject.Reverse();
+
+ AssertSame(C, triangle3DObject[0]);
+ AssertSame(A, triangle3DObject[2]);
+ Assert.False(triangle3DObject.GetNormal().SameHalf(before));
+ }
+
+ // ---- Caller 1: Query.Volume(Shell) -----------------------------------------------------------------
+
+ public static IEnumerable VolumeCases()
+ {
+ foreach (object[] pose in Poses())
+ {
+ foreach (int flipMask in new int[] { 0, 0b111111111, 0b000010101, 0b101010010, 0b000000001 })
+ {
+ yield return new object[] { pose[0], flipMask };
+ }
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(VolumeCases))]
+ public void Volume_ConcaveLPrismWithMixedFaceWinding_EqualsAnalyticVolume(string pose, int flipMask)
+ {
+ // L footprint 2x2 minus 1x1 corner = 3, height 2 -> 6
+ Shell shell = CreateLPrism(2.0, GetPose(pose), flipMask);
+
+ Assert.Equal(6.0, shell.Volume(), 5);
+ }
+
+ [Theory]
+ [MemberData(nameof(VolumeCases))]
+ public void Volume_BoxWithMixedFaceWinding_EqualsAnalyticVolume(string pose, int flipMask)
+ {
+ Shell shell = CreateBox(2, 3, 4, GetPose(pose), flipMask);
+
+ Assert.Equal(24.0, shell.Volume(), 5);
+ }
+
+ [Theory]
+ [MemberData(nameof(VolumeCases))]
+ public void Volume_TriangularPrismWithMixedFaceWinding_EqualsAnalyticVolume(string pose, int flipMask)
+ {
+ // right triangle legs 3 and 4 (area 6), height 5 -> 30
+ List footprint = new List() { new Point3D(0, 0, 0), new Point3D(3, 0, 0), new Point3D(0, 4, 0) };
+ Shell shell = CreatePrism(footprint, 5.0, GetPose(pose), flipMask);
+
+ Assert.Equal(30.0, shell.Volume(), 5);
+ }
+
+ // ---- Caller 2: Create.Mesh3D(Shell) ----------------------------------------------------------------
+
+ [Theory]
+ [MemberData(nameof(VolumeCases))]
+ public void Mesh3D_Shell_EveryTriangleFollowsParentFaceNormal(string pose, int flipMask)
+ {
+ Shell shell = CreateLPrism(2.0, GetPose(pose), flipMask);
+ List face3Ds = shell.Face3Ds;
+
+ Mesh3D mesh3D = Geometry.Spatial.Create.Mesh3D(shell);
+
+ List triangle3Ds = mesh3D.GetTriangles();
+ Assert.NotEmpty(triangle3Ds);
+ foreach (Triangle3D triangle3D in triangle3Ds)
+ {
+ Point3D centroid = triangle3D.GetCentroid();
+ List parents = face3Ds.FindAll(x => x.Inside(centroid, 1e-6));
+ Assert.Single(parents);
+
+ Vector3D triangleNormal = triangle3D.GetNormal().GetNormalized();
+ Vector3D faceNormal = parents[0].GetPlane().Normal.GetNormalized();
+ double dot = (triangleNormal.X * faceNormal.X) + (triangleNormal.Y * faceNormal.Y) + (triangleNormal.Z * faceNormal.Z);
+ Assert.True(dot > 0.99, $"{pose} mask {flipMask}: triangle normal disagrees with face normal (dot {dot})");
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(Poses))]
+ public void Mesh3D_OutwardShell_HasPositiveClosedSignedVolume(string pose)
+ {
+ Shell shell = CreateLPrism(2.0, GetPose(pose), 0);
+
+ Mesh3D mesh3D = Geometry.Spatial.Create.Mesh3D(shell);
+
+ double signedVolume = mesh3D.GetTriangles().Sum(x => x.SignedVolume());
+ Assert.Equal(6.0, signedVolume, 5);
+ }
+
+ [Theory]
+ [MemberData(nameof(Poses))]
+ public void Mesh3D_InwardShell_HasNegativeClosedSignedVolume(string pose)
+ {
+ Shell shell = CreateLPrism(2.0, GetPose(pose), 0b111111111);
+
+ Mesh3D mesh3D = Geometry.Spatial.Create.Mesh3D(shell);
+
+ double signedVolume = mesh3D.GetTriangles().Sum(x => x.SignedVolume());
+ Assert.Equal(-6.0, signedVolume, 5);
+ }
+
+ // ---- helpers ---------------------------------------------------------------------------------------
+
+ private static void AssertSame(Point3D expected, Point3D actual)
+ {
+ Assert.NotNull(actual);
+ Assert.True(expected.Distance(actual) < Tol, $"expected {expected.X},{expected.Y},{expected.Z} but was {actual.X},{actual.Y},{actual.Z}");
+ }
+
+ private static Shell CreateLPrism(double height, Transform3D transform3D, int flipMask)
+ {
+ List footprint = new List()
+ {
+ new Point3D(0, 0, 0), new Point3D(2, 0, 0), new Point3D(2, 1, 0),
+ new Point3D(1, 1, 0), new Point3D(1, 2, 0), new Point3D(0, 2, 0)
+ };
+
+ return CreatePrism(footprint, height, transform3D, flipMask);
+ }
+
+ private static Shell CreateBox(double width, double depth, double height, Transform3D transform3D, int flipMask)
+ {
+ List footprint = new List()
+ {
+ new Point3D(0, 0, 0), new Point3D(width, 0, 0), new Point3D(width, depth, 0), new Point3D(0, depth, 0)
+ };
+
+ return CreatePrism(footprint, height, transform3D, flipMask);
+ }
+
+ ///
+ /// Outward-wound prism over a counter-clockwise (seen from +Z) footprint; every face whose bit is set in
+ /// has its vertex order reversed afterwards, giving mixed or inward winding.
+ ///
+ private static Shell CreatePrism(List footprint, double height, Transform3D transform3D, int flipMask)
+ {
+ int n = footprint.Count;
+ List> loops = new List>();
+
+ loops.Add(footprint.AsEnumerable().Reverse().ToList());
+ loops.Add(footprint.ConvertAll(x => new Point3D(x.X, x.Y, x.Z + height)));
+ for (int i = 0; i < n; i++)
+ {
+ Point3D p = footprint[i];
+ Point3D q = footprint[(i + 1) % n];
+ loops.Add(new List()
+ {
+ new Point3D(p.X, p.Y, p.Z), new Point3D(q.X, q.Y, q.Z),
+ new Point3D(q.X, q.Y, q.Z + height), new Point3D(p.X, p.Y, p.Z + height)
+ });
+ }
+
+ List face3Ds = new List();
+ for (int i = 0; i < loops.Count; i++)
+ {
+ List loop = loops[i].ConvertAll(x => (Point3D)x.GetTransformed(transform3D));
+ if ((flipMask & (1 << i)) != 0)
+ {
+ loop.Reverse();
+ }
+
+ face3Ds.Add(new Face3D(new Polygon3D(loop)));
+ }
+
+ return new Shell(face3Ds);
+ }
+ }
+}
diff --git a/documentation/Triangle3D-Reverse-Q4.md b/documentation/Triangle3D-Reverse-Q4.md
new file mode 100644
index 000000000..27008ec70
--- /dev/null
+++ b/documentation/Triangle3D-Reverse-Q4.md
@@ -0,0 +1,137 @@
+# Triangle3D.Reverse no-op — Q4 (2026-10-27)
+
+Status: draft PR into `sow/2026-Q4` (base `9d8961ec`), branch `fix/triangle3d-reverse-q4`. **Tested content `d7a00b6c`**;
+the later commit changes this record and its evidence only, and the PR description holds the final head and hosted
+checks. Not merged; no merge is authorised.
+
+## Problem
+
+`Triangle3D` keeps its vertices in a `Point3D[]`. `Reverse()` was `points.Reverse();`. With `using System.Linq;` in
+scope that binds to `Enumerable.Reverse` (`netstandard2.0`, `LangVersion 12`), which returns a new sequence; the result
+was discarded, so `Reverse()` never changed the triangle.
+
+Two callers rely on it to correct a triangle's winding against its parent face normal:
+
+| Caller | Use |
+| --- | --- |
+| `Query.Volume(Shell)` (`Geometry/Spatial/Query/Volume.cs:56`) | reverses a triangle whose normal is not in the face-normal half-space, then adds `SignedVolume()` |
+| `Create.Mesh3D(Shell)` (`Geometry/Spatial/Create/Mesh3D.cs:173`) | same test; the triangles then feed `Mesh3D(IEnumerable)`, which indexes vertices through `this[i]` |
+
+With the no-op, the shell volume depended on the raw winding of each triangulation, and the mesh triangles kept
+whatever winding `Planar.Query.Triangulate` produced instead of the face's. No other caller of `Triangle3D.Reverse()`
+exists in SAM or in the sibling repositories (SAM_OCCT, SAM_Building, SAM_Tas, SAM_Topologic, SAM_gbXML, SAM_Revit,
+SAM_SolarCalculator, SAM_Systems, SAM_UI, SAM_IFC, SAM_Rhino_UI, SAM_Tas_Grasshopper; grep of `.cs`).
+
+## Change
+
+`SAM/SAM.Geometry/Geometry/Spatial/Classes/Triangle3D.cs`: `points.Reverse();` → `Array.Reverse(points);` (in place, the
+same contract as the other `Reverse()` implementations: the stored vertex order is reversed). One line.
+
+Unchanged: public signatures, the JSON schema (`Points` array, written in stored order), coordinates, tolerances, the
+`silverSpacing` area filter in `Volume`, `OrientNormals`, the shell-normal convention, `Shell.Volume` itself, solver
+behaviour, panel order, golden files.
+
+`Triangle2D.Reverse()` has the identical defect (`Geometry/Planar/Classes/Triangle2D.cs:205`). It has **no callers** and
+was left alone (scope), recorded under "Separate".
+
+## Behaviour change (callers)
+
+| Consumer | Effect |
+| --- | --- |
+| `Shell.Volume()` | a triangle wound against its face is now corrected; closed shells whose triangulation mixes windings no longer carry a signed per-triangle error. Closed convex or consistently triangulated shells are unchanged |
+| `Create.Mesh3D(Shell)` | every mesh triangle now follows its parent face normal; a closed outward shell gives a positive signed mesh volume |
+| `Analytical.Query.Volume(Space…)`, `SpaceVolumeFilter`, `Modify.Remove` (via `Shell.Volume()`) | results move only where the volume was wrong; see the Softhouse table |
+| `Mesh3D(IEnumerable)`, `Query.Convert`, `Transform`, `Project`, `Offset`, `Face3Ds`, `TryGetSAMGeometries` | none: they never call `Reverse()` |
+
+## Tests (`SAM/SAM.Tests/Triangle3DReverseTests.cs`, xUnit, AAA, SPDX, 132 cases)
+
+- Contract (on 5 poses: identity, translation, rotation about Z, composed X·Y·Z rotation, rotation + translation):
+ vertex order reversed; indexer reflects it; normal flips; non-zero `SignedVolume` changes sign; one reversal changes
+ the triangle and two restore the sequence (`==` and `Equals`); area, length, centroid and bounds unchanged; segments
+ reversed and closed; `Clone()` and JSON round-trip keep the reversed order; a copy made earlier is unaffected;
+ `Triangle3DObject` reverses.
+- `Shell.Volume()`: a concave L-shaped prism (analytic 6), a box (24) and a right-triangle prism (30), each on 5 poses
+ with 5 face-winding masks (outward, all inward, three mixed masks).
+- `Create.Mesh3D(Shell)`: for the L prism on 5 poses × 5 masks, every mesh triangle's unit normal agrees with the face
+ that contains its centroid; an outward shell gives mesh signed volume +6 and a fully inward one −6.
+
+## Validation (local, machine `michal`, 2026-10-27)
+
+Build/test commands run from `SAM/SAM` with `APPDATA` and `USERPROFILE` redirected to a scratch profile
+(`SAM` and `SAM_OCCT` Grasshopper post-build steps copy into `%APPDATA%\SAM`). The real
+`%APPDATA%\SAM` was not used.
+
+```
+dotnet test SAM.Tests/SAM.Tests.csproj --filter "FullyQualifiedName~Triangle3DReverseTests"
+```
+
+| Check | Result |
+| --- | --- |
+| New tests on unchanged source (red; `points.Reverse()`, same build) | **76 fail / 56 pass** of 132. Failing: every contract test that needs a changed sequence (vertex order, indexer, normal, signed-volume sign, segments, JSON/clone, `Triangle3DObject`, double-reversal intermediate state), `Volume` of the concave prism (25 / 25), and all 35 `Mesh3D(Shell)` cases. Passing: the invariance rows (area, centroid, bounds, copy independence) and the `Volume` box and triangular-prism rows, whose triangulations happen to be consistently wound (kept as compatibility guards) |
+| New tests on the fix (green) | **132 / 132** |
+| SAM.Tests, full, on the fix | **3975 / 3975**, 0 skipped (`SAM.Geometry.dll` `EF2B5D4E`) |
+| SAM.Core.Grasshopper.Tests (Debug; built with the scratch profile, run `--no-build` with the normal one), on the fix | **87 / 87** (loaded `SAM.Geometry.dll` `EF2B5D4E`; this project covers `TryGetSAMGeometries`, the only Grasshopper consumer of `Triangle3D` besides `GeometryTriangulate`) |
+| Changed expectations | none: no existing test, golden or fixture changed |
+
+The test poses compose `GetRotationX/Y/Z`. `Transform3D.GetRotation(axis, angle)` was **not** used: it is marked
+"Method to be revised" and builds a non-orthonormal matrix (its `[0,2]` and `[2,0]` entries carry the same sign), so a
+solid rotated with it is distorted (a 24 m3 box became 23.87). Existing, unrelated, not changed.
+
+### Saved Softhouse R1 closeout models (read only; no solver rerun, no native rebuild)
+
+Models: `SAM_daily\2026-10-25-Softthouse\handover\evidence\2026-10-27-sam-root-face2d-loop\chains\pr84-R\model.json`
+(baseline) and `...\chains\pr84fix-R\model.json` (corrected SAM), 59 spaces each. A scratch probe
+(`documentation/evidence/triangle3d-reverse-q4/r1probe.Program.cs.txt`; a `net8.0` console referencing
+`SAM.Core/Geometry/Analytical`) loads each model and, per space, prints `ac.Shell(space).Volume()` and an
+independent winding-free volume `|Σ area × n·p / 3|` over the faces after the same `OrientNormals` call. It was run
+against the pre-fix `SAM.Geometry.dll` (`558D2B86`) and the fixed one (`EF2B5D4E`), both built from this branch.
+
+| Cell | Model | `Shell.Volume()` before fix | after fix | independent |
+| --- | --- | ---: | ---: | ---: |
+| (12.159, 6.705, 4.975) | baseline | 31.242936 | 31.242936 | 31.242939 |
+| (12.159, 6.705, 4.975) | corrected | **31.23669** | **31.242936** | 31.242939 |
+| (12.853, 4.891, 7.963) | baseline | 36.172684 | 36.172684 | 36.17269 |
+| (12.853, 4.891, 7.963) | corrected | **36.178925** | **36.17268** | 36.172689 |
+
+- Before the fix the probe reproduces the saved defect exactly (31.23669 and 36.178925, the ±0.006 m3 of the R1
+ closeout). After it, the two cells agree between the models and with the independent volume to ≤ 1e-5 m3.
+- Over the 52 closed cells of each model, the largest `|Shell.Volume() − independent|` is 0.000021 (baseline, both
+ builds), 0.006249 → 0.000022 (corrected: before → after). No other cell changed (a per-cell diff of the two builds
+ lists exactly these two cells). 7 cells per model return NaN in every build (open shells, `IsClosed` is false): an
+ existing, unrelated condition.
+- Tables: `documentation/evidence/triangle3d-reverse-q4/volumes---fix.tsv`.
+
+**This correction therefore fully explains the saved ±0.006 m3 volume defect.** No remaining mechanism was found, so the
+"stop and report" condition did not occur. The OCCT panel-order sensitivity from the R1 closeout is separate and
+unchanged.
+
+## Compatibility
+
+- Public API, JSON schema and serialised models: no change; `Triangle3D` JSON written before or after is identical for
+ the same vertex order, and a reversed triangle serialises its reversed order.
+- Callers: only the two above. Mesh output of `Create.Mesh3D(Shell)` changes only in triangle vertex order (and
+ therefore normal) for triangles that were wound against their face; positions and counts are unchanged.
+- Saved definitions: no component, parameter or GUID is touched.
+
+## Decisions and assumptions
+
+- `Array.Reverse(points)` keeps the field type and the existing contract (the other `Reverse()` methods reverse in place).
+- `Shell.Volume`'s design is not changed; its per-triangle correction was the intent and now works.
+- **Grasshopper versions: recommend no bump in this PR** (owner decision under GH-H2). No component code, parameter or
+ GUID changes; the results that change are corrected volumes and mesh winding. GH-H2 lists a different geometry result
+ as minor or major by impact, so the owner decides. Components whose output can change indirectly through
+ `Space.Volume`: `SAMAnalyticalModifyVentilationProfileByFunction`, `SAMAnalyticalUpdateInfiltrationGains`, the
+ space-volume filter and `Remove` paths. Deployed Grasshopper needs the new `SAM.Geometry.dll` (a coherent redeploy,
+ `BuildAlls_v5.bat`); the shared GH version pins in SAM_OCCT#84 are a separate decision and are not changed here.
+
+## Separate
+
+- `Triangle2D.Reverse()` has the same `points.Reverse()` no-op; no callers; a one-line fix when wanted.
+- `Transform3D.GetRotation(axis, angle)` is non-orthonormal (see above).
+- OCCT/AdjacencyCluster panel-order sensitivity and open-shell NaN volumes: unchanged.
+
+## Next step
+
+Independent review of the PR; then mark ready for review (not a merge authorisation). After merge: a docs-only
+`[skip ci]` closeout in `PROJECT_PROGRESS.md` on `sow/2026-Q4` with the merge SHA, and a coherent GH redeploy. Recommended
+for review: Claude Opus 5.5, medium effort, a new session, Plan mode (read-only review of one line and its tests).
diff --git a/documentation/evidence/triangle3d-reverse-q4/r1probe.Program.cs.txt b/documentation/evidence/triangle3d-reverse-q4/r1probe.Program.cs.txt
new file mode 100644
index 000000000..980c33dcb
--- /dev/null
+++ b/documentation/evidence/triangle3d-reverse-q4/r1probe.Program.cs.txt
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using SAM.Analytical;
+using SAM.Geometry.Spatial;
+
+// Read-only: loads saved models, never writes them. args: label modelPath
+class P
+{
+ // Winding-independent volume: (1/3) * sum(area * n.p) over the oriented faces of the shell as Shell.Volume() orients it.
+ static double Independent(Shell shell)
+ {
+ Shell t = new Shell(shell);
+ t.OrientNormals(false, SAM.Core.Tolerance.MacroDistance, SAM.Core.Tolerance.Distance);
+ double v = 0;
+ foreach (Face3D f in t.Face3Ds)
+ {
+ Plane pl = f.GetPlane(); var n = pl.Normal.GetNormalized(); var o = pl.Origin;
+ v += f.GetArea() * (n.X * o.X + n.Y * o.Y + n.Z * o.Z) / 3.0;
+ }
+ return Math.Abs(v);
+ }
+
+ static void Main(string[] a)
+ {
+ var model = SAM.Core.Convert.ToSAM(a[1]).First();
+ var ac = model.AdjacencyCluster;
+ Console.WriteLine("space\tShell.Volume\tindependent\tdelta");
+ foreach (var s in ac.GetSpaces().OrderBy(x => x.Location.Z).ThenBy(x => x.Location.X).ThenBy(x => x.Location.Y))
+ {
+ var shell = ac.Shell(s);
+ double v = shell.Volume(), i = Independent(shell);
+ Console.WriteLine($"({s.Location.X:0.000},{s.Location.Y:0.000},{s.Location.Z:0.000})\t{v:0.######}\t{i:0.######}\t{v - i:0.######}");
+ }
+ }
+}
diff --git a/documentation/evidence/triangle3d-reverse-q4/volumes-pr84-R-after-fix.tsv b/documentation/evidence/triangle3d-reverse-q4/volumes-pr84-R-after-fix.tsv
new file mode 100644
index 000000000..cb29f9dde
--- /dev/null
+++ b/documentation/evidence/triangle3d-reverse-q4/volumes-pr84-R-after-fix.tsv
@@ -0,0 +1,60 @@
+space Shell.Volume independent delta
+(-4.151,0.953,1.750) 217.020229 217.020228 0.000001
+(15.880,5.743,1.750) 7.399231 7.399232 -0.000001
+(15.880,3.783,1.750) 9.636162 9.636162 0
+(-5.031,2.115,1.750) 12.33198 12.33198 0
+(4.289,3.779,1.750) 9.775824 9.775821 0.000003
+(6.560,2.115,1.750) 12.62248 12.622481 -0.000001
+(-1.524,3.783,1.750) 9.499276 9.499277 -0.000001
+(-1.524,5.615,1.750) 6.211036 6.211034 0.000002
+(0.735,2.115,1.750) 12.558777 12.558775 0.000002
+(4.289,5.624,1.750) 6.190207 6.190207 -0
+(6.666,5.599,1.750) 68.460367 68.460366 0.000001
+(10.108,3.782,1.750) 9.444463 9.444461 0.000002
+(-4.893,5.599,1.750) 67.693723 67.693712 0.000011
+(13.541,0.953,1.750) NaN 206.206901 NaN
+(1.787,0.953,1.750) 208.047704 208.047704 -0
+(7.691,0.953,1.750) NaN 207.521772 NaN
+(12.345,2.115,1.750) 12.426243 12.426243 0
+(10.108,5.613,1.750) 6.185971 6.185975 -0.000004
+(12.456,5.599,1.750) 67.554146 67.554146 0.000001
+(0.862,5.599,3.250) 109.754859 109.754845 0.000014
+(7.821,1.563,4.968) NaN 152.772016 NaN
+(10.719,-2.707,4.968) 0.007352 0.007353 -0.000001
+(13.618,1.002,4.968) NaN 132.716693 NaN
+(15.051,5.743,4.975) 14.902144 14.902144 -0
+(11.102,4.387,4.975) 2.250542 2.25054 0.000002
+(-2.352,6.707,4.975) 31.588133 31.588128 0.000004
+(15.051,7.558,4.975) 16.887604 16.88761 -0.000005
+(6.362,7.268,4.975) 21.768141 21.768143 -0.000001
+(4.905,-2.707,4.975) 0.012389 0.012387 0.000002
+(3.449,7.268,4.975) 22.092446 22.092443 0.000002
+(9.267,7.268,4.975) 21.985391 21.985394 -0.000004
+(-5.019,1.249,4.975) 36.479618 36.479617 0
+(-5.012,3.341,4.975) 3.158507 3.158507 -0
+(-6.261,4.357,4.975) 2.407617 2.407617 -0
+(-4.979,6.013,4.975) 42.220347 42.220341 0.000006
+(-0.522,4.376,4.975) 2.347651 2.347651 -0
+(2.007,1.563,4.975) 133.584632 133.584653 -0.000021
+(5.302,4.387,4.975) 2.357952 2.357951 0.000001
+(12.159,6.705,4.975) 31.242936 31.242939 -0.000003
+(11.102,4.389,7.962) 2.244064 2.244068 -0.000004
+(5.302,4.384,7.962) 2.326787 2.326787 0
+(-0.522,4.376,7.962) 2.328087 2.328087 -0
+(2.007,0.488,7.962) 95.170111 95.170108 0.000002
+(-2.352,6.707,7.962) 31.32489 31.324891 -0.000001
+(7.821,1.002,7.962) NaN 113.341951 NaN
+(12.159,6.705,7.963) 30.966147 30.966138 0.000009
+(15.051,6.707,7.963) 31.524703 31.524702 0.000001
+(-2.142,2.150,7.963) 27.619595 27.619598 -0.000004
+(0.540,6.193,7.963) 37.670318 37.670318 -0
+(3.449,7.268,7.963) 21.908339 21.908338 0.000001
+(7.821,6.707,7.963) 62.761861 62.761859 0.000002
+(11.605,4.036,7.963) 6.675357 6.675353 0.000003
+(-5.011,5.851,7.963) NaN 44.885742 NaN
+(10.018,1.660,7.963) 18.765156 18.765161 -0.000005
+(13.618,0.148,7.963) NaN 88.698021 NaN
+(-3.771,0.146,7.963) 88.542602 88.542597 0.000004
+(-6.261,4.357,7.963) 2.387552 2.387552 0
+(2.196,3.517,7.963) 40.432523 40.432519 0.000004
+(12.853,4.891,7.963) 36.172684 36.17269 -0.000007
diff --git a/documentation/evidence/triangle3d-reverse-q4/volumes-pr84-R-before-fix.tsv b/documentation/evidence/triangle3d-reverse-q4/volumes-pr84-R-before-fix.tsv
new file mode 100644
index 000000000..ece390733
--- /dev/null
+++ b/documentation/evidence/triangle3d-reverse-q4/volumes-pr84-R-before-fix.tsv
@@ -0,0 +1,60 @@
+space Shell.Volume independent delta
+(-4.151,0.953,1.750) 217.020229 217.020228 0.000001
+(15.880,5.743,1.750) 7.399231 7.399232 -0.000001
+(15.880,3.783,1.750) 9.636162 9.636162 0
+(-5.031,2.115,1.750) 12.33198 12.33198 -0
+(4.289,3.779,1.750) 9.775824 9.775821 0.000003
+(6.560,2.115,1.750) 12.62248 12.622481 -0.000001
+(-1.524,3.783,1.750) 9.499276 9.499277 -0.000001
+(-1.524,5.615,1.750) 6.211036 6.211034 0.000002
+(0.735,2.115,1.750) 12.558777 12.558775 0.000002
+(4.289,5.624,1.750) 6.190207 6.190207 -0
+(6.666,5.599,1.750) 68.460367 68.460366 0.000001
+(10.108,3.782,1.750) 9.444463 9.444461 0.000002
+(-4.893,5.599,1.750) 67.693723 67.693712 0.000011
+(13.541,0.953,1.750) NaN 206.206901 NaN
+(1.787,0.953,1.750) 208.047704 208.047704 -0
+(7.691,0.953,1.750) NaN 207.521772 NaN
+(12.345,2.115,1.750) 12.426243 12.426243 0
+(10.108,5.613,1.750) 6.185971 6.185975 -0.000004
+(12.456,5.599,1.750) 67.554146 67.554146 0.000001
+(0.862,5.599,3.250) 109.754859 109.754845 0.000014
+(7.821,1.563,4.968) NaN 152.772016 NaN
+(10.719,-2.707,4.968) 0.007352 0.007353 -0.000001
+(13.618,1.002,4.968) NaN 132.716693 NaN
+(15.051,5.743,4.975) 14.902144 14.902144 -0
+(11.102,4.387,4.975) 2.250542 2.25054 0.000002
+(-2.352,6.707,4.975) 31.588133 31.588128 0.000004
+(15.051,7.558,4.975) 16.887604 16.88761 -0.000005
+(6.362,7.268,4.975) 21.768141 21.768143 -0.000001
+(4.905,-2.707,4.975) 0.012389 0.012387 0.000002
+(3.449,7.268,4.975) 22.092446 22.092443 0.000002
+(9.267,7.268,4.975) 21.985391 21.985394 -0.000004
+(-5.019,1.249,4.975) 36.479618 36.479617 0
+(-5.012,3.341,4.975) 3.158507 3.158507 -0
+(-6.261,4.357,4.975) 2.407617 2.407617 -0
+(-4.979,6.013,4.975) 42.220347 42.220341 0.000006
+(-0.522,4.376,4.975) 2.347651 2.347651 -0
+(2.007,1.563,4.975) 133.584632 133.584653 -0.000021
+(5.302,4.387,4.975) 2.357952 2.357951 0.000001
+(12.159,6.705,4.975) 31.242936 31.242939 -0.000003
+(11.102,4.389,7.962) 2.244064 2.244068 -0.000004
+(5.302,4.384,7.962) 2.326787 2.326787 0
+(-0.522,4.376,7.962) 2.328087 2.328087 -0
+(2.007,0.488,7.962) 95.170111 95.170108 0.000002
+(-2.352,6.707,7.962) 31.32489 31.324891 -0.000001
+(7.821,1.002,7.962) NaN 113.341951 NaN
+(12.159,6.705,7.963) 30.966147 30.966138 0.000009
+(15.051,6.707,7.963) 31.524703 31.524702 0.000001
+(-2.142,2.150,7.963) 27.619595 27.619598 -0.000004
+(0.540,6.193,7.963) 37.670318 37.670318 -0
+(3.449,7.268,7.963) 21.908339 21.908338 0.000001
+(7.821,6.707,7.963) 62.761861 62.761859 0.000002
+(11.605,4.036,7.963) 6.675357 6.675353 0.000003
+(-5.011,5.851,7.963) NaN 44.885742 NaN
+(10.018,1.660,7.963) 18.765156 18.765161 -0.000005
+(13.618,0.148,7.963) NaN 88.698021 NaN
+(-3.771,0.146,7.963) 88.542602 88.542597 0.000004
+(-6.261,4.357,7.963) 2.387552 2.387552 0
+(2.196,3.517,7.963) 40.432523 40.432519 0.000004
+(12.853,4.891,7.963) 36.172684 36.17269 -0.000007
diff --git a/documentation/evidence/triangle3d-reverse-q4/volumes-pr84fix-R-after-fix.tsv b/documentation/evidence/triangle3d-reverse-q4/volumes-pr84fix-R-after-fix.tsv
new file mode 100644
index 000000000..4786e0709
--- /dev/null
+++ b/documentation/evidence/triangle3d-reverse-q4/volumes-pr84fix-R-after-fix.tsv
@@ -0,0 +1,60 @@
+space Shell.Volume independent delta
+(-4.151,0.953,1.750) 217.020229 217.020228 0.000001
+(15.880,5.743,1.750) 7.399231 7.399232 -0.000001
+(15.880,3.783,1.750) 9.636162 9.636162 0
+(-5.031,2.115,1.750) 12.33198 12.33198 0
+(4.289,3.779,1.750) 9.775824 9.775821 0.000003
+(6.560,2.115,1.750) 12.62248 12.622481 -0.000001
+(-1.524,3.783,1.750) 9.499276 9.499277 -0.000001
+(-1.524,5.615,1.750) 6.211036 6.211034 0.000002
+(0.735,2.115,1.750) 12.558777 12.558775 0.000002
+(4.289,5.624,1.750) 6.190207 6.190207 -0
+(6.666,5.599,1.750) 68.460367 68.460366 0.000001
+(10.108,3.782,1.750) 9.444464 9.444462 0.000002
+(-4.893,5.599,1.750) 67.693723 67.693712 0.000011
+(13.541,0.953,1.750) NaN 206.206901 NaN
+(1.787,0.953,1.750) 208.047704 208.047704 -0
+(7.691,0.953,1.750) NaN 207.521772 NaN
+(12.345,2.115,1.750) 12.426243 12.426243 0
+(10.108,5.613,1.750) 6.185971 6.185975 -0.000004
+(12.456,5.599,1.750) 67.554146 67.554145 0.000001
+(0.862,5.599,3.250) 109.754859 109.754845 0.000014
+(7.821,1.563,4.968) NaN 152.772016 NaN
+(10.719,-2.707,4.968) 0.007352 0.007353 -0.000001
+(13.618,1.002,4.968) NaN 132.716694 NaN
+(15.051,5.743,4.975) 14.902144 14.902145 -0
+(11.102,4.387,4.975) 2.250542 2.25054 0.000002
+(-2.352,6.707,4.975) 31.588133 31.588128 0.000004
+(15.051,7.558,4.975) 16.887604 16.88761 -0.000005
+(6.362,7.268,4.975) 21.768141 21.768143 -0.000001
+(4.905,-2.707,4.975) 0.012389 0.012387 0.000002
+(3.449,7.268,4.975) 22.092446 22.092443 0.000002
+(9.267,7.268,4.975) 21.985391 21.985394 -0.000004
+(-5.019,1.249,4.975) 36.479618 36.479618 0
+(-5.012,3.341,4.975) 3.158507 3.158507 -0
+(-6.261,4.357,4.975) 2.407617 2.407617 -0
+(-4.979,6.013,4.975) 42.220339 42.22034 -0.000001
+(-0.522,4.376,4.975) 2.347651 2.347651 0
+(2.007,1.563,4.975) 133.58463 133.584653 -0.000022
+(5.302,4.387,4.975) 2.357952 2.357951 0.000001
+(12.159,6.705,4.975) 31.242936 31.242939 -0.000003
+(11.102,4.389,7.962) 2.244064 2.244068 -0.000004
+(5.302,4.384,7.962) 2.326787 2.326787 0
+(-0.522,4.376,7.962) 2.328087 2.328087 -0
+(2.007,0.488,7.962) 95.170116 95.170109 0.000008
+(-2.352,6.707,7.962) 31.32489 31.324891 -0.000001
+(7.821,1.002,7.962) NaN 113.341951 NaN
+(12.159,6.705,7.963) 30.966147 30.966138 0.000009
+(15.051,6.707,7.963) 31.524703 31.524702 0.000001
+(-2.142,2.150,7.963) 27.619594 27.619598 -0.000005
+(0.540,6.193,7.963) 37.670318 37.670318 -0
+(3.449,7.268,7.963) 21.908339 21.908338 0.000001
+(7.821,6.707,7.963) 62.761867 62.761859 0.000007
+(11.605,4.036,7.963) 6.675359 6.675354 0.000005
+(-5.011,5.851,7.963) NaN 44.885744 NaN
+(10.018,1.660,7.963) 18.765156 18.765161 -0.000005
+(13.618,0.148,7.963) NaN 88.698021 NaN
+(-3.771,0.146,7.963) 88.542601 88.542597 0.000004
+(-6.261,4.357,7.963) 2.387552 2.387552 0
+(2.196,3.517,7.963) 40.432519 40.432519 -0
+(12.853,4.891,7.963) 36.17268 36.172689 -0.00001
diff --git a/documentation/evidence/triangle3d-reverse-q4/volumes-pr84fix-R-before-fix.tsv b/documentation/evidence/triangle3d-reverse-q4/volumes-pr84fix-R-before-fix.tsv
new file mode 100644
index 000000000..a0ccd942a
--- /dev/null
+++ b/documentation/evidence/triangle3d-reverse-q4/volumes-pr84fix-R-before-fix.tsv
@@ -0,0 +1,60 @@
+space Shell.Volume independent delta
+(-4.151,0.953,1.750) 217.020229 217.020228 0.000001
+(15.880,5.743,1.750) 7.399231 7.399232 -0.000001
+(15.880,3.783,1.750) 9.636162 9.636162 0
+(-5.031,2.115,1.750) 12.33198 12.33198 -0
+(4.289,3.779,1.750) 9.775824 9.775821 0.000003
+(6.560,2.115,1.750) 12.62248 12.622481 -0.000001
+(-1.524,3.783,1.750) 9.499276 9.499277 -0.000001
+(-1.524,5.615,1.750) 6.211036 6.211034 0.000002
+(0.735,2.115,1.750) 12.558777 12.558775 0.000002
+(4.289,5.624,1.750) 6.190207 6.190207 -0
+(6.666,5.599,1.750) 68.460367 68.460366 0.000001
+(10.108,3.782,1.750) 9.444464 9.444462 0.000002
+(-4.893,5.599,1.750) 67.693723 67.693712 0.000011
+(13.541,0.953,1.750) NaN 206.206901 NaN
+(1.787,0.953,1.750) 208.047704 208.047704 -0
+(7.691,0.953,1.750) NaN 207.521772 NaN
+(12.345,2.115,1.750) 12.426243 12.426243 0
+(10.108,5.613,1.750) 6.185971 6.185975 -0.000004
+(12.456,5.599,1.750) 67.554146 67.554145 0.000001
+(0.862,5.599,3.250) 109.754859 109.754845 0.000014
+(7.821,1.563,4.968) NaN 152.772016 NaN
+(10.719,-2.707,4.968) 0.007352 0.007353 -0.000001
+(13.618,1.002,4.968) NaN 132.716694 NaN
+(15.051,5.743,4.975) 14.902144 14.902145 -0
+(11.102,4.387,4.975) 2.250542 2.25054 0.000002
+(-2.352,6.707,4.975) 31.588133 31.588128 0.000004
+(15.051,7.558,4.975) 16.887604 16.88761 -0.000005
+(6.362,7.268,4.975) 21.768141 21.768143 -0.000001
+(4.905,-2.707,4.975) 0.012389 0.012387 0.000002
+(3.449,7.268,4.975) 22.092446 22.092443 0.000002
+(9.267,7.268,4.975) 21.985391 21.985394 -0.000004
+(-5.019,1.249,4.975) 36.479618 36.479618 0
+(-5.012,3.341,4.975) 3.158507 3.158507 -0
+(-6.261,4.357,4.975) 2.407617 2.407617 -0
+(-4.979,6.013,4.975) 42.220339 42.22034 -0.000001
+(-0.522,4.376,4.975) 2.347651 2.347651 0
+(2.007,1.563,4.975) 133.58463 133.584653 -0.000022
+(5.302,4.387,4.975) 2.357952 2.357951 0.000001
+(12.159,6.705,4.975) 31.23669 31.242939 -0.006249
+(11.102,4.389,7.962) 2.244064 2.244068 -0.000004
+(5.302,4.384,7.962) 2.326787 2.326787 0
+(-0.522,4.376,7.962) 2.328087 2.328087 -0
+(2.007,0.488,7.962) 95.170116 95.170109 0.000008
+(-2.352,6.707,7.962) 31.32489 31.324891 -0.000001
+(7.821,1.002,7.962) NaN 113.341951 NaN
+(12.159,6.705,7.963) 30.966147 30.966138 0.000009
+(15.051,6.707,7.963) 31.524703 31.524702 0.000001
+(-2.142,2.150,7.963) 27.619594 27.619598 -0.000005
+(0.540,6.193,7.963) 37.670318 37.670318 -0
+(3.449,7.268,7.963) 21.908339 21.908338 0.000001
+(7.821,6.707,7.963) 62.761867 62.761859 0.000007
+(11.605,4.036,7.963) 6.675359 6.675354 0.000005
+(-5.011,5.851,7.963) NaN 44.885744 NaN
+(10.018,1.660,7.963) 18.765156 18.765161 -0.000005
+(13.618,0.148,7.963) NaN 88.698021 NaN
+(-3.771,0.146,7.963) 88.542601 88.542597 0.000004
+(-6.261,4.357,7.963) 2.387552 2.387552 0
+(2.196,3.517,7.963) 40.432519 40.432519 -0
+(12.853,4.891,7.963) 36.178925 36.172689 0.006236