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