From e2bfbf2db9f7fb0b9427e4c7f10da496b1a08b96 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Tue, 3 Nov 2026 21:41:54 +0100 Subject: [PATCH 1/4] feat(analytical): PanelsBySolid stamps a source-evidence record that SnapToJunctions rebinds (Q4) Producer half of the contact-aware void guard (design rev 5). A panel carries an optional, all-or-nothing SourceEvidence record: the panel's frame at extraction, the guaranteed slab thickness, the Support region where that slab is proved to be material, the panel's Origin face, and the BoundFootprint the panel had when an evidence-aware SAM tool last wrote it. - Create.PanelsBySolid certifies material only for closed, valid, layered prismatic solids: closure by edge pairing, positional (metres, not angular) parallel/perpendicular classification, one section per layer, definite-result intersections. Anything else gets no record, so behaviour is unchanged. - Panel saves and reloads the record; every copy of a panel carries it. - Query.ValidSourceEvidence accepts a record only while its frame equals the panel's and the panel's footprint equals BoundFootprint, so a same-plane edit makes it stale. - Query.SnapToJunctions rebinds only a record that is valid on the input panel, to the snapped footprint, and drops any other, so stale evidence cannot become valid by snapping back to the bound footprint. The SAM_OCCT consumer is a separate change. Co-Authored-By: Claude Sonnet 5.5 --- SAM/SAM.Analytical/Classes/Panel.cs | 42 + SAM/SAM.Analytical/Classes/SourceEvidence.cs | 222 ++++++ SAM/SAM.Analytical/Create/PanelsBySolid.cs | 14 + .../Create/PanelsBySolidSourceEvidence.cs | 364 +++++++++ SAM/SAM.Analytical/Query/SnapToJunctions.cs | 30 + .../Query/ValidSourceEvidence.cs | 213 +++++ .../PanelsBySolidSourceEvidenceTests.cs | 741 ++++++++++++++++++ 7 files changed, 1626 insertions(+) create mode 100644 SAM/SAM.Analytical/Classes/SourceEvidence.cs create mode 100644 SAM/SAM.Analytical/Create/PanelsBySolidSourceEvidence.cs create mode 100644 SAM/SAM.Analytical/Query/ValidSourceEvidence.cs create mode 100644 SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs diff --git a/SAM/SAM.Analytical/Classes/Panel.cs b/SAM/SAM.Analytical/Classes/Panel.cs index fb00aead5..cc532b56d 100644 --- a/SAM/SAM.Analytical/Classes/Panel.cs +++ b/SAM/SAM.Analytical/Classes/Panel.cs @@ -31,6 +31,13 @@ public class Panel : SAMInstance, IPanel /// Planar Boundary 3D of Panel /// private PlanarBoundary3D planarBoundary3D; + + /// + /// What was proved about the source element (see ), if anything. Never modified in + /// place, so copies of a panel share it. + /// + private SourceEvidence sourceEvidence; + /// /// Creates new instance of panel based on another panel /// @@ -40,6 +47,7 @@ internal Panel(Panel panel) { planarBoundary3D = new PlanarBoundary3D(panel.planarBoundary3D); panelType = panel.panelType; + sourceEvidence = panel.sourceEvidence; if (panel.apertures != null) apertures = new List(panel.apertures.ConvertAll(x => new Aperture(x))); @@ -50,6 +58,7 @@ internal Panel(Guid guid, Panel panel) { planarBoundary3D = new PlanarBoundary3D(panel.planarBoundary3D); panelType = panel.panelType; + sourceEvidence = panel.sourceEvidence; if (panel.apertures != null) apertures = new List(panel.apertures.ConvertAll(x => new Aperture(x))); @@ -65,6 +74,7 @@ internal Panel(Panel panel, Construction construction) { planarBoundary3D = new PlanarBoundary3D(panel.planarBoundary3D); panelType = panel.panelType; + sourceEvidence = panel.sourceEvidence; if (panel.apertures != null) apertures = new List(panel.apertures.ConvertAll(x => new Aperture(x))); @@ -80,6 +90,7 @@ internal Panel(Panel panel, PanelType panelType) { planarBoundary3D = new PlanarBoundary3D(panel.planarBoundary3D); this.panelType = panelType; + sourceEvidence = panel.sourceEvidence; if (panel.apertures != null) apertures = new List(panel.apertures.ConvertAll(x => new Aperture(x))); @@ -113,6 +124,7 @@ internal Panel(Guid guid, Panel panel, Face3D face, IEnumerable apertu : base(guid, panel) { panelType = panel.panelType; + sourceEvidence = panel.sourceEvidence; planarBoundary3D = new PlanarBoundary3D(face); List apertures_All = new List(); @@ -147,6 +159,7 @@ internal Panel(Guid guid, Panel panel, PlanarBoundary3D planarBoundary3D) : base(guid, panel) { panelType = panel.panelType; + sourceEvidence = panel.sourceEvidence; this.planarBoundary3D = planarBoundary3D; } @@ -256,6 +269,27 @@ public Plane Plane } } + /// + /// What + /// proved about the panel's source element, or null. It is carried by every copy of the panel and saved with it, but it + /// is evidence only while accepts it: use that, not this, to decide. + /// + public SourceEvidence SourceEvidence + { + get + { + return sourceEvidence; + } + } + + /// + /// Sets or clears (null) the record. Internal: only an evidence-aware SAM tool may bind a record to a footprint. + /// + internal void SetSourceEvidence(SourceEvidence sourceEvidence) + { + this.sourceEvidence = sourceEvidence != null && sourceEvidence.IsComplete ? sourceEvidence : null; + } + public bool AddAperture(Aperture aperture, double tolerance_Angle = Tolerance.Angle, double tolerance_Distance = Tolerance.Distance) { if (aperture == null) @@ -311,6 +345,11 @@ public override bool FromJsonObject(JsonObject jsonObject) if (jsonObject["Apertures"] is JsonArray aperturesArray) apertures = Core.Create.IJSAMObjects(aperturesArray); + // A record that is not complete is no evidence: it is not kept. + sourceEvidence = null; + if (jsonObject["SourceEvidence"] is JsonObject sourceEvidenceJson) + SetSourceEvidence(new SourceEvidence(sourceEvidenceJson)); + return true; } @@ -702,6 +741,9 @@ public override JsonObject ToJsonObject() jsonObject["Apertures"] = aperturesArray; } + if (sourceEvidence?.ToJsonObject() is JsonObject sourceEvidenceJson) + jsonObject["SourceEvidence"] = sourceEvidenceJson; + return jsonObject; } diff --git a/SAM/SAM.Analytical/Classes/SourceEvidence.cs b/SAM/SAM.Analytical/Classes/SourceEvidence.cs new file mode 100644 index 000000000..234a5ce73 --- /dev/null +++ b/SAM/SAM.Analytical/Classes/SourceEvidence.cs @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors + +using SAM.Core; +using SAM.Geometry.Planar; +using SAM.Geometry.Spatial; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; + +namespace SAM.Analytical +{ + /// + /// What + /// could prove about the source element of a mid-surface panel: a symmetric slab about the panel's plane that the + /// element's material fills over . One optional record per panel, all-or-nothing: a panel has + /// either every field here or no record. It is stamped only for a closed, valid solid whose faces are, to within + /// metres over their actual extent, parallel or perpendicular to the panel's plane + /// (a layered prismatic element), and for no other solid. + /// + /// A record is evidence only while it still describes the panel it is on: see + /// . Editing a panel on its own plane (trim, extension, merge, + /// split) keeps its frame, so the frame alone cannot tell that a record has gone stale; the record therefore also keeps + /// , the footprint the panel had the last time an evidence-aware SAM tool wrote it + /// ( + /// and ). Any other writer leaves + /// the footprint different from , so the evidence is dropped instead of trusted. + /// + /// + /// All regions are s in the frame , inner loops kept. The record is stored on the + /// panel and saved with it. + /// + /// + public class SourceEvidence : IAnalyticalObject + { + private Plane plane; + private List support; + private List origin; + private List boundFootprint; + + public SourceEvidence(Plane plane, double guaranteedThickness, IEnumerable support, IEnumerable origin, IEnumerable boundFootprint) + { + this.plane = plane == null ? null : new Plane(plane); + GuaranteedThickness = guaranteedThickness; + this.support = Clone(support); + this.origin = Clone(origin); + this.boundFootprint = Clone(boundFootprint); + } + + public SourceEvidence(SourceEvidence sourceEvidence) + { + if (sourceEvidence == null) + { + return; + } + + plane = sourceEvidence.plane == null ? null : new Plane(sourceEvidence.plane); + GuaranteedThickness = sourceEvidence.GuaranteedThickness; + support = Clone(sourceEvidence.support); + origin = Clone(sourceEvidence.origin); + boundFootprint = Clone(sourceEvidence.boundFootprint); + } + + public SourceEvidence(JsonObject jsonObject) + { + FromJsonObject(jsonObject); + } + + /// + /// The panel's frame at extraction (origin, normal and Y axis). Regions are expressed in it. + /// + public Plane Plane + { + get + { + return plane == null ? null : new Plane(plane); + } + } + + /// + /// Thickness [m] of the symmetric slab about that the source element's material fills over the whole + /// of . It is the least of the element's slabs, so it can be below the panel's physical thickness. + /// + public double GuaranteedThickness { get; private set; } = double.NaN; + + /// + /// The region where the slab is proved to be material: the intersection, over the layers between the element's + /// parallel faces, of the material of the section of the solid. Aperture openings and notches are not filled here. + /// + public List Support + { + get + { + return Clone(support); + } + } + + /// + /// The panel's own face at extraction (aperture-claimed openings and notches filled, unclaimed holes open). + /// + public List Origin + { + get + { + return Clone(origin); + } + } + + /// + /// The panel's footprint the last time an evidence-aware SAM tool wrote it. The record is evidence only while the + /// panel's current footprint equals it. + /// + public List BoundFootprint + { + get + { + return Clone(boundFootprint); + } + } + + /// + /// True when every field is present: a frame, a positive thickness and a non-empty , + /// and . A record that is not complete is no evidence. + /// + public bool IsComplete + { + get + { + return plane != null && GuaranteedThickness > 0 && !double.IsInfinity(GuaranteedThickness) && support != null && support.Count != 0 && origin != null && origin.Count != 0 && boundFootprint != null && boundFootprint.Count != 0; + } + } + + /// + /// This record bound to another footprint (the one an evidence-aware tool has just written). + /// + public SourceEvidence WithBoundFootprint(IEnumerable boundFootprint) + { + return new SourceEvidence(plane, GuaranteedThickness, support, origin, boundFootprint); + } + + public bool FromJsonObject(JsonObject jsonObject) + { + if (jsonObject == null) + { + return false; + } + + plane = jsonObject["Plane"] is JsonObject planeJson ? new Plane(planeJson) : null; + GuaranteedThickness = jsonObject["GuaranteedThickness"] is JsonValue jsonValue && jsonValue.TryGetValue(out double value) ? value : double.NaN; + support = Read(jsonObject["Support"]); + origin = Read(jsonObject["Origin"]); + boundFootprint = Read(jsonObject["BoundFootprint"]); + + return true; + } + + public JsonObject ToJsonObject() + { + JsonObject result = new JsonObject + { + ["_type"] = Core.Query.FullTypeName(this), + }; + + if (plane?.ToJsonObject() is JsonObject planeJson) + { + result["Plane"] = planeJson; + } + + if (!double.IsNaN(GuaranteedThickness)) + { + result["GuaranteedThickness"] = GuaranteedThickness; + } + + result["Support"] = Write(support); + result["Origin"] = Write(origin); + result["BoundFootprint"] = Write(boundFootprint); + + return result; + } + + private static List Clone(IEnumerable face2Ds) + { + return face2Ds?.Where(x => x != null).Select(x => new Face2D(x.ToJsonObject())).ToList(); + } + + private static List Read(JsonNode jsonNode) + { + if (jsonNode is not JsonArray jsonArray) + { + return null; + } + + List result = new List(); + foreach (JsonNode item in jsonArray) + { + if (item is JsonObject jsonObject) + { + result.Add(new Face2D(jsonObject)); + } + } + + return result; + } + + private static JsonArray Write(List face2Ds) + { + JsonArray result = new JsonArray(); + if (face2Ds != null) + { + foreach (Face2D face2D in face2Ds) + { + if (face2D?.ToJsonObject() is JsonObject jsonObject) + { + result.Add(jsonObject); + } + } + } + + return result; + } + } +} diff --git a/SAM/SAM.Analytical/Create/PanelsBySolid.cs b/SAM/SAM.Analytical/Create/PanelsBySolid.cs index 8131eb131..585cddc02 100644 --- a/SAM/SAM.Analytical/Create/PanelsBySolid.cs +++ b/SAM/SAM.Analytical/Create/PanelsBySolid.cs @@ -234,6 +234,12 @@ private static List PanelsByElementPlane(Shell shell, ElementPlane elemen List apertureFootprints = ApertureFootprints(apertureGeometries, midPlane, thickness, tolerance_Snap); List pairFootprints = elementPlane.Footprints.ConvertAll(x => midPlane.Convert(x)).FindAll(x => x != null); + // What the solid proves about the element's material, certified at most once per element plane and only when a host + // panel needs it. No evidence is the answer for any solid that cannot be proved (the panel is then as it always was). + double halfThickness = GuaranteedHalfThickness(elementPlane); + List evidenceSupport = null; + bool evidenceChecked = false; + int filledHoles = 0, openHoles = 0, airHoles = 0, filledNotches = 0, keptNotches = 0, crossSections = 0, failedRegions = 0; foreach (Face3D section in sections) { @@ -341,6 +347,14 @@ private static List PanelsByElementPlane(Shell shell, ElementPlane elemen if (panel != null) { StampSource(panel, sourceName, sourceId, sourceLayer, thickness, null); + + if (!evidenceChecked) + { + evidenceChecked = true; + TryCertifyMaterial(shell, midPlane, halfThickness, tolerance_Angle, tolerance_Distance, tolerance_Snap, out evidenceSupport, out string _); + } + + panel.SetSourceEvidence(SourceEvidenceOf(panel, elementPlane, halfThickness, evidenceSupport, tolerance_Snap)); result.Add(panel); } diff --git a/SAM/SAM.Analytical/Create/PanelsBySolidSourceEvidence.cs b/SAM/SAM.Analytical/Create/PanelsBySolidSourceEvidence.cs new file mode 100644 index 000000000..f6f053eef --- /dev/null +++ b/SAM/SAM.Analytical/Create/PanelsBySolidSourceEvidence.cs @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors + +using SAM.Core; +using SAM.Geometry.Planar; +using SAM.Geometry.Spatial; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SAM.Analytical +{ + public static partial class Create + { + /// + /// Proof that the material of a closed solid fills a symmetric slab about a host plane, for the solids where that can be + /// proved: closed, valid, layered prismatic ones. Anything else is no evidence (the caller keeps today's behaviour). + /// + /// Closed and valid (checked first, before any section): every face valid, at least four faces, every edge (outer + /// and inner loops) straight, and every non-degenerate edge matched by exactly one other edge, either orientation, + /// endpoints within the distance tolerance. is not used alone: it is true when the shell has + /// no naked segments to report. A face split by a T-junction fails. + /// Positional classification about the host normal n, with deviation bound delta equal to the distance + /// tolerance: a face is parallel when the offsets of all its vertices along n differ by at most delta; otherwise it is + /// perpendicular when every edge has min(|n.e|, |e - (n.e)n|) of at most delta, with at least one edge along + /// n and one across; otherwise there is no evidence. Both measures are in metres over the actual extent, so a long, slightly + /// sloped face fails whatever its angle. Nothing is judged by an angle. + /// Layers: the layers between the parallel-face offsets inside the slab (and the slab's two sides) are + /// sampled once each, at their midpoint. Within delta every face is parallel or perpendicular and every edge of a perpendicular + /// face runs along or across n, so the section changes only at a parallel face's offset, and one sample per layer proves + /// material over the layer. The support is the intersection over the layers of the material regions of the section (the + /// regions whose internal point is inside the solid; inner loops kept). A layer thinner than the snap tolerance cannot be + /// sampled reliably and fails. + /// Definite results: no Shell.Difference; an empty or missing section is a failure, never proof; + /// intersections go through . + /// + /// Not checked, stated as residual: self-intersection of the solid and the outward orientation of its faces (the proof + /// uses face geometry, not normal signs). + /// + /// The solid, in metres. + /// The host plane (the element's mid-plane). + /// Half the slab thickness [m] to certify. + /// Angle tolerance [rad] of the section. + /// Distance tolerance [m]; also the positional deviation bound and the edge-pairing tolerance. + /// Snap tolerance [m] of the section; also the thinnest layer that can be sampled. + /// The proved region, in the frame of , inner loops kept; null when there is no evidence. + /// Why there is no evidence; null when certified. + /// Section of the solid on a plane (the solid's own Section by default); a seam for tests. + /// Intersection of two regions (Face2D.Intersection by default); a seam for tests. + /// True when the slab is proved to be material over a non-empty support. + internal static bool TryCertifyMaterial(Shell shell, Plane host, double halfThickness, double tolerance_Angle, double tolerance_Distance, double tolerance_Snap, out List support, out string reason, Func> section = null, Func> intersection = null) + { + support = null; + reason = null; + + if (shell == null || host == null || double.IsNaN(halfThickness) || double.IsInfinity(halfThickness) || halfThickness <= tolerance_Distance) + { + reason = "no slab to certify"; + return false; + } + + if (!IsClosedAndValid(shell, tolerance_Distance, out reason)) + { + reason = "not a closed, valid solid: " + reason; + return false; + } + + Vector3D normal = host.Normal.Unit; + if (!TryClassifyPositionally(shell, normal, host.Origin, tolerance_Distance, out List offsets, out reason)) + { + return false; + } + + // The slab's sides and every parallel-face offset strictly inside it. + List cuts = new List { -halfThickness, halfThickness }; + foreach (double offset in offsets.OrderBy(x => x)) + { + if (offset > -halfThickness + tolerance_Distance && offset < halfThickness - tolerance_Distance && !cuts.Exists(x => System.Math.Abs(x - offset) < tolerance_Distance)) + { + cuts.Add(offset); + } + } + + cuts.Sort(); + + section = section ?? ((x, y) => x.Section(y, true, tolerance_Angle, tolerance_Distance, tolerance_Snap)); + + List result = null; + for (int i = 0; i + 1 < cuts.Count; i++) + { + if (cuts[i + 1] - cuts[i] < tolerance_Snap) + { + reason = string.Format(System.Globalization.CultureInfo.InvariantCulture, "a layer at offset {0:0.#####} m is thinner than the snap tolerance", cuts[i]); + return false; + } + + double middle = (cuts[i] + cuts[i + 1]) / 2; + List sections = section(shell, new Plane(host, new Point3D(host.Origin.X + normal.X * middle, host.Origin.Y + normal.Y * middle, host.Origin.Z + normal.Z * middle))); + if (sections == null || sections.Count == 0) + { + reason = string.Format(System.Globalization.CultureInfo.InvariantCulture, "the section at layer offset {0:0.#####} m failed or is empty", middle); + return false; + } + + // The section also returns the voids themselves as faces; keep only regions of material. + List layer = new List(); + foreach (Face3D section_Face3D in sections) + { + Point3D internalPoint = section_Face3D?.GetInternalPoint3D(tolerance_Distance); + if (internalPoint == null || !shell.Inside(internalPoint, tolerance_Snap, tolerance_Distance)) + { + continue; + } + + Face2D face2D = host.Convert(section_Face3D); + if (face2D != null && face2D.GetArea() > tolerance_Distance) + { + layer.Add(face2D); + } + } + + if (layer.Count == 0) + { + reason = string.Format(System.Globalization.CultureInfo.InvariantCulture, "no material in the layer at offset {0:0.#####} m", middle); + return false; + } + + result = result == null ? layer : Query.DefiniteIntersection(result, layer, tolerance_Distance, intersection); + if (result == null) + { + reason = string.Format(System.Globalization.CultureInfo.InvariantCulture, "the intersection of regions failed at layer offset {0:0.#####} m", middle); + return false; + } + + if (result.Count == 0) + { + reason = string.Format(System.Globalization.CultureInfo.InvariantCulture, "no region is material through the layer at offset {0:0.#####} m", middle); + return false; + } + } + + support = result; + return true; + } + + /// + /// The straight edges of a face (outer and inner loops) as point pairs; a null pair marks a loop that cannot be + /// segmented (a curve). + /// + private static IEnumerable<(Point3D Start, Point3D End)> EdgesOf(Face3D face3D) + { + List loops = new List { face3D.GetExternalEdge3D() }; + loops.AddRange(face3D.GetInternalEdge3Ds() ?? new List()); + foreach (IClosedPlanar3D loop in loops) + { + if (!(loop is ISegmentable3D segmentable3D)) + { + yield return (null, null); + continue; + } + + foreach (Segment3D segment3D in segmentable3D.GetSegments()) + { + yield return (segment3D[0], segment3D[1]); + } + } + } + + /// + /// Closed, valid shell precondition: every face valid, at least four faces, every edge straight, every non-degenerate edge + /// matched by exactly one other edge (either orientation) within . + /// + internal static bool IsClosedAndValid(Shell shell, double tolerance, out string reason) + { + reason = null; + List face3Ds = shell?.Face3Ds; + if (face3Ds == null || face3Ds.Count < 4) + { + reason = "fewer than 4 faces"; + return false; + } + + List<(int Face, Point3D Start, Point3D End, double X)> edges = new List<(int, Point3D, Point3D, double)>(); + for (int i = 0; i < face3Ds.Count; i++) + { + Face3D face3D = face3Ds[i]; + if (face3D == null || !face3D.IsValid()) + { + reason = string.Format("face {0} is not valid", i); + return false; + } + + foreach ((Point3D start, Point3D end) in EdgesOf(face3D)) + { + if (start == null || end == null) + { + reason = string.Format("face {0} has an edge that is not a straight segment", i); + return false; + } + + if (start.Distance(end) > tolerance) + { + edges.Add((i, start, end, (start.X + end.X) / 2)); + } + } + } + + // Matching edges have midpoints within tolerance of each other, so a sweep along X finds every candidate. + edges = edges.OrderBy(x => x.X).ToList(); + int[] matches = new int[edges.Count]; + for (int i = 0; i < edges.Count; i++) + { + for (int j = i + 1; j < edges.Count && edges[j].X - edges[i].X <= 2 * tolerance; j++) + { + bool same = (edges[i].Start.Distance(edges[j].Start) <= tolerance && edges[i].End.Distance(edges[j].End) <= tolerance) + || (edges[i].Start.Distance(edges[j].End) <= tolerance && edges[i].End.Distance(edges[j].Start) <= tolerance); + if (same) + { + matches[i]++; + matches[j]++; + } + } + } + + for (int i = 0; i < edges.Count; i++) + { + if (matches[i] != 1) + { + reason = string.Format("an edge of face {0} is shared by {1} other edge(s), not exactly 1", edges[i].Face, matches[i]); + return false; + } + } + + return true; + } + + /// + /// Positional classification about : every face is parallel (all vertex offsets along the normal + /// within ) or perpendicular (every edge within of along or across the + /// normal, with an edge of each kind). Gives the offsets of the parallel faces. + /// + private static bool TryClassifyPositionally(Shell shell, Vector3D normal, Point3D origin, double delta, out List parallelOffsets, out string reason) + { + parallelOffsets = new List(); + reason = null; + foreach (Face3D face3D in shell.Face3Ds) + { + List<(Point3D Start, Point3D End)> edges = EdgesOf(face3D).ToList(); + if (edges.Count == 0 || edges.Exists(x => x.Start == null || x.End == null)) + { + reason = "a face has an edge that is not a straight segment"; + return false; + } + + List offsets = edges.ConvertAll(x => normal.DotProduct(new Vector3D(origin, x.Start))); + double spread = offsets.Max() - offsets.Min(); + if (spread <= delta) + { + parallelOffsets.Add(offsets.Average()); + continue; + } + + bool across = false, along = false; + foreach ((Point3D start, Point3D end) in edges) + { + Vector3D edge = new Vector3D(start, end); + double parallelPart = System.Math.Abs(normal.DotProduct(edge)); + double perpendicularPart = System.Math.Sqrt(System.Math.Max(0, edge.DotProduct(edge) - parallelPart * parallelPart)); + double deviation = System.Math.Min(parallelPart, perpendicularPart); + if (deviation > delta) + { + reason = string.Format(System.Globalization.CultureInfo.InvariantCulture, "a face is neither parallel nor perpendicular to the element: an edge deviates {0:0.##e+0} m from along or across it over a length of {1:0.#####} m (face spread {2:0.##e+0} m)", deviation, edge.Length, spread); + return false; + } + + if (parallelPart <= delta) + { + across = true; + } + else + { + along = true; + } + } + + if (!across || !along) + { + reason = "a perpendicular face lacks an edge along or an edge across the element's normal"; + return false; + } + } + + return true; + } + + /// + /// Half the slab thickness [m] about the host plane that every slab of the element plane covers: the least, over the pairs + /// (and parallel pairs of the same element), of the distance from the host plane to the nearer side of the pair's slab. Zero or + /// negative when a pair's slab does not contain the host plane. + /// + private static double GuaranteedHalfThickness(ElementPlane elementPlane) + { + Plane host = elementPlane.MidPlane; + double result = double.PositiveInfinity; + foreach (ThicknessPair pair in elementPlane.Pairs.Concat(elementPlane.Others)) + { + double offset = Offset(host, pair.MidPlane); + double half = pair.Thickness / 2; + result = System.Math.Min(result, System.Math.Min(offset + half, half - offset)); + } + + return result; + } + + /// + /// The of a host panel of , or null. + /// is the element plane's proved support, in the frame of its mid-plane, and is certified once per element plane. + /// + private static SourceEvidence SourceEvidenceOf(Panel panel, ElementPlane elementPlane, double halfThickness, List support, double tolerance_Snap) + { + Plane frame = panel?.Plane; + Face3D face3D = panel?.GetFace3D(); + if (frame == null || face3D == null || support == null || support.Count == 0) + { + return null; + } + + Face2D footprint = frame.Convert(face3D); + BoundingBox2D bounds = footprint?.GetBoundingBox(); + if (bounds == null) + { + return null; + } + + // Support is expressed in the panel's own frame; only the regions near the panel are kept. + List support_Panel = new List(); + foreach (Face2D region in support) + { + Face3D region3D = region == null ? null : elementPlane.MidPlane.Convert(region); + Face2D region_Panel = region3D == null ? null : frame.Convert(region3D); + BoundingBox2D regionBounds = region_Panel?.GetBoundingBox(); + if (regionBounds == null) + { + return null; + } + + if (regionBounds.Max.X < bounds.Min.X - tolerance_Snap || bounds.Max.X < regionBounds.Min.X - tolerance_Snap || regionBounds.Max.Y < bounds.Min.Y - tolerance_Snap || bounds.Max.Y < regionBounds.Min.Y - tolerance_Snap) + { + continue; + } + + support_Panel.Add(region_Panel); + } + + if (support_Panel.Count == 0) + { + return null; + } + + SourceEvidence result = new SourceEvidence(frame, 2 * halfThickness, support_Panel, new List { footprint }, new List { footprint }); + return result.IsComplete ? result : null; + } + } +} diff --git a/SAM/SAM.Analytical/Query/SnapToJunctions.cs b/SAM/SAM.Analytical/Query/SnapToJunctions.cs index a67f88ea8..3de0258ad 100644 --- a/SAM/SAM.Analytical/Query/SnapToJunctions.cs +++ b/SAM/SAM.Analytical/Query/SnapToJunctions.cs @@ -212,6 +212,11 @@ internal static List SnapToJunctions(IEnumerable panels, Core.Diag Face2D face2D = Geometry.Planar.Create.Face2D(snapped[i], selves[i].Face2D.InternalEdge2Ds); Face3D face3D = face2D == null ? null : selves[i].Plane.Convert(face2D); Panel snappedPanel = face3D == null || !face3D.IsValid() ? null : Create.Panel(panel.Guid, panel, face3D, null, false); + if (snappedPanel != null) + { + RebindSourceEvidence(panel, snappedPanel, tolerance_Distance); + } + result.Add(snappedPanel ?? Create.Panel(panel)); } @@ -223,6 +228,31 @@ internal static List SnapToJunctions(IEnumerable panels, Core.Diag return result; } + /// + /// Binds the of to the footprint snapping has just written onto + /// . Snapping is the one SAM tool that legitimately changes a footprint, so it is the one that + /// rebinds. Only a record that is valid on the input panel (frame and footprint as bound) is rebound, and only if it is + /// valid on the result; any other record, including a stale one that the snapped face happens to match again, is + /// dropped, so stale evidence never becomes valid by snapping. + /// + private static void RebindSourceEvidence(Panel input, Panel snapped, double tolerance_Distance) + { + if (input.SourceEvidence == null) + { + return; + } + + SourceEvidence sourceEvidence = input.ValidSourceEvidence(tolerance_Distance); + Face2D footprint = sourceEvidence == null ? null : sourceEvidence.Plane.Convert(snapped.GetFace3D()); + snapped.SetSourceEvidence(footprint == null ? null : sourceEvidence.WithBoundFootprint(new List { footprint })); + + // The rebound record has to describe the panel it is on; if it does not, there is no evidence. + if (snapped.SourceEvidence != null && snapped.ValidSourceEvidence(tolerance_Distance) == null) + { + snapped.SetSourceEvidence(null); + } + } + /// /// A panel's name in snapping diagnostics: its source name, else its Guid. /// diff --git a/SAM/SAM.Analytical/Query/ValidSourceEvidence.cs b/SAM/SAM.Analytical/Query/ValidSourceEvidence.cs new file mode 100644 index 000000000..0305aeeee --- /dev/null +++ b/SAM/SAM.Analytical/Query/ValidSourceEvidence.cs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors + +using SAM.Core; +using SAM.Geometry.Planar; +using SAM.Geometry.Spatial; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SAM.Analytical +{ + public static partial class Query + { + /// + /// Largest cosine deficit [1 - cos] of the angle between a record's normal (and Y axis) and the panel's for the frames to + /// count as equal: the frame must be the one the panel was extracted with, not merely a similar one. + /// + private const double FrameDirectionTolerance = 1e-12; + + /// + /// The panel's when it still describes the panel, else null (no evidence). + /// + /// The record is accepted only if all hold: (a) its frame equals the panel's current frame (origin within + /// , normal and Y axis in the same direction), so a move, rotation, scaling or + /// flip is detected; (b) the panel's current footprint equals : the symmetric + /// difference is at most times the perimeter of the bound footprint, computed with + /// , so an edit on the same plane (trim, extension, merge, split) is detected even though + /// it keeps the frame; (c) the record is complete and its thickness is positive. A result that cannot be decided + /// (kernel failure) is no evidence. + /// + /// + /// This does not check what only a consumer can: that the panel's plane has not been moved since the record was read + /// (inside a solver stage) and that the panel stands for one source element. + /// + /// + /// The panel. + /// Distance tolerance [m]. + /// The record, or null. + public static SourceEvidence ValidSourceEvidence(this Panel panel, double tolerance_Distance = Tolerance.Distance) + { + return ValidSourceEvidence(panel, out string _, tolerance_Distance); + } + + /// + /// that also says why a record was not accepted. + /// + /// The panel. + /// Why there is no evidence; null when the record is valid. + /// Distance tolerance [m]. + /// The record, or null. + public static SourceEvidence ValidSourceEvidence(this Panel panel, out string reason, double tolerance_Distance = Tolerance.Distance) + { + reason = null; + + SourceEvidence sourceEvidence = panel?.SourceEvidence; + if (sourceEvidence == null) + { + reason = "the panel has no source evidence"; + return null; + } + + if (!sourceEvidence.IsComplete) + { + reason = "the record is incomplete or its thickness is not positive"; + return null; + } + + Plane plane = panel.Plane; + Face3D face3D = panel.GetFace3D(); + if (plane == null || face3D == null) + { + reason = "the panel has no face"; + return null; + } + + if (!SameFrame(sourceEvidence.Plane, plane)) + { + reason = "the frame differs from the frame the record was made on"; + return null; + } + + Face2D face2D = sourceEvidence.Plane.Convert(face3D); + if (face2D == null) + { + reason = "the panel's footprint could not be expressed in the record's frame"; + return null; + } + + List current = new List { face2D }; + List bound = sourceEvidence.BoundFootprint; + List common = DefiniteIntersection(current, bound, tolerance_Distance); + if (common == null) + { + reason = "the footprint comparison could not be decided"; + return null; + } + + double difference = RegionArea(current) + RegionArea(bound) - 2 * RegionArea(common); + double allowed = tolerance_Distance * RegionPerimeter(bound); + if (!(difference <= allowed)) + { + reason = string.Format(System.Globalization.CultureInfo.InvariantCulture, "the footprint differs from the footprint the record was bound to (symmetric difference {0:G6} m2, allowed {1:G3} m2)", difference, allowed); + return null; + } + + return sourceEvidence; + } + + /// + /// Intersection of two lists of regions in one frame whose result is definite: an empty list means certainly empty, null + /// means the kernel could not decide. intersection returns null both for disjoint inputs and, possibly, + /// when it fails to rebuild a result, so regions whose bounding boxes are certainly disjoint (exact test, with + /// ) are an empty result, and a null result for regions whose bounds overlap is a + /// failure for the whole call. Pieces of area or less are dropped. + /// + /// Regions, in one frame. + /// Regions, in the same frame. + /// Distance tolerance [m]. + /// The pieces of the intersection, an empty list when it is certainly empty, or null when it cannot be decided. + public static List DefiniteIntersection(IEnumerable face2Ds_1, IEnumerable face2Ds_2, double tolerance_Distance = Tolerance.Distance) + { + return DefiniteIntersection(face2Ds_1, face2Ds_2, tolerance_Distance, null); + } + + internal static List DefiniteIntersection(IEnumerable face2Ds_1, IEnumerable face2Ds_2, double tolerance_Distance, Func> intersection) + { + if (face2Ds_1 == null || face2Ds_2 == null) + { + return null; + } + + intersection = intersection ?? ((x, y, tolerance) => x.Intersection(y, tolerance)); + + List result = new List(); + List list_2 = face2Ds_2.ToList(); + foreach (Face2D face2D_1 in face2Ds_1) + { + BoundingBox2D boundingBox2D_1 = face2D_1?.GetBoundingBox(); + if (boundingBox2D_1 == null) + { + return null; + } + + foreach (Face2D face2D_2 in list_2) + { + BoundingBox2D boundingBox2D_2 = face2D_2?.GetBoundingBox(); + if (boundingBox2D_2 == null) + { + return null; + } + + if (boundingBox2D_1.Max.X < boundingBox2D_2.Min.X - tolerance_Distance || boundingBox2D_2.Max.X < boundingBox2D_1.Min.X - tolerance_Distance || boundingBox2D_1.Max.Y < boundingBox2D_2.Min.Y - tolerance_Distance || boundingBox2D_2.Max.Y < boundingBox2D_1.Min.Y - tolerance_Distance) + { + continue; + } + + List intersection_Pair = intersection(face2D_1, face2D_2, tolerance_Distance); + if (intersection_Pair == null) + { + return null; + } + + result.AddRange(intersection_Pair.Where(x => x != null && x.GetArea() > tolerance_Distance)); + } + } + + return result; + } + + /// + /// Total area of the regions (inner loops excluded); zero for none. + /// + internal static double RegionArea(IEnumerable face2Ds) + { + return face2Ds == null ? 0 : face2Ds.Where(x => x != null).Sum(x => x.GetArea()); + } + + /// + /// Total length of the outer and inner loops of the regions; zero for none. + /// + internal static double RegionPerimeter(IEnumerable face2Ds) + { + double result = 0; + foreach (Face2D face2D in face2Ds ?? Enumerable.Empty()) + { + if (face2D == null) + { + continue; + } + + foreach (IClosed2D closed2D in new[] { face2D.ExternalEdge2D }.Concat(face2D.InternalEdge2Ds ?? new List())) + { + result += closed2D?.Perimeter() ?? 0; + } + } + + return result; + } + + private static bool SameFrame(Plane plane_1, Plane plane_2) + { + if (plane_1 == null || plane_2 == null) + { + return false; + } + + return plane_1.Origin.Distance(plane_2.Origin) <= Tolerance.MicroDistance + && 1 - plane_1.Normal.Unit.DotProduct(plane_2.Normal.Unit) <= FrameDirectionTolerance + && 1 - plane_1.AxisY.Unit.DotProduct(plane_2.AxisY.Unit) <= FrameDirectionTolerance; + } + } +} diff --git a/SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs b/SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs new file mode 100644 index 000000000..cfc97b98c --- /dev/null +++ b/SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs @@ -0,0 +1,741 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (c) 2020–2026 Michal Dengusiak & Jakub Ziolkowski and contributors + +using SAM.Analytical; +using SAM.Core; +using SAM.Geometry.Planar; +using SAM.Geometry.Spatial; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Xunit; +using AnalyticalCreate = SAM.Analytical.Create; +using AnalyticalQuery = SAM.Analytical.Query; +using static SAM.Tests.PanelsBySolidTests; + +namespace SAM.Tests +{ + /// + /// : what + /// proves about a solid (closed, valid, layered prismatic only), how the record is saved and reloaded, when it stops being + /// evidence (), and how + /// rebinds it. Metres throughout. + /// + public class PanelsBySolidSourceEvidenceTests + { + // Host plane of a wall whose thickness runs along y, centred on y = 0. + private static readonly Plane HostY = new Plane(new Point3D(0, 0, 0), new Vector3D(0, 1, 0)); + + // Profile (u = y, v = z), extruded along x. + private static Point3D YZ(double u, double v, double w) => new Point3D(w, u, v); + + private static Panel Host(Shell shell, string name = "Wall") + { + return Assert.Single(shell.PanelsBySolid(out _, sourceName: name), x => x.PanelType != PanelType.Air); + } + + private static double Area(IEnumerable face2Ds) => face2Ds.Sum(x => x.GetArea()); + + /// + /// 3 m long (x), 2.85 m high (z), 0.548 m thick (y -0.274..0.274), optionally with a 0.1 x 0.1 m tunnel along x at depth + /// y 0.1..0.2 and z 0.3..0.4 (a rectangle, or a diamond whose faces are at 45 degrees). + /// + private static Shell Wall(bool tunnel = false, bool diamond = false) + { + List> holes = null; + if (tunnel) + { + IList<(double U, double V)> hole = diamond + ? new List<(double U, double V)> { (0.1, 0.35), (0.15, 0.3), (0.2, 0.35), (0.15, 0.4) } + : Rectangle(0.1, 0.3, 0.2, 0.4); + holes = new List> { hole }; + } + + return Prism(YZ, Rectangle(-0.274, 0, 0.274, 2.85), holes, 0, 3); + } + + /// + /// A wall x 0..length, y -0.125..0.125 with the +y face's far end metres off, z 0..3, as an XY + /// profile extruded in z. + /// + private static Shell TiltedWall(double length, double tilt) + { + IList<(double U, double V)> outline = new List<(double U, double V)> { (0, -0.125), (length, -0.125), (length, 0.125 + tilt), (0, 0.125) }; + return Prism(XY, outline, null, 0, 3); + } + + private static Face3D Rect(Plane plane, double u0, double v0, double u1, double v1) + { + return new Face3D(plane, new Polygon2D(new List { new Point2D(u0, v0), new Point2D(u1, v0), new Point2D(u1, v1), new Point2D(u0, v1) })); + } + + private static Panel Edited(Panel panel, double fromU, double toU) + { + // Same plane, same frame, a different footprint: the share [fromU, toU] of the panel's width. + BoundingBox2D bounds = panel.Plane.Convert(panel.GetFace3D()).GetBoundingBox(); + Face3D face3D = Rect(panel.Plane, bounds.Min.X + fromU * bounds.Width, bounds.Min.Y, bounds.Min.X + toU * bounds.Width, bounds.Max.Y); + return AnalyticalCreate.Panel(panel.Guid, panel, face3D, null, false); + } + + // Same plane, same frame: the panel without the part of its face west of world x = (a vertical panel in an xz plane). + private static Panel TrimmedWestOf(Panel panel, double x) + { + BoundingBox3D bounds = panel.GetFace3D().GetBoundingBox(); + Point3D[] corners = { new Point3D(x, bounds.Min.Y, bounds.Min.Z), new Point3D(bounds.Max.X, bounds.Min.Y, bounds.Min.Z), new Point3D(bounds.Max.X, bounds.Min.Y, bounds.Max.Z), new Point3D(x, bounds.Min.Y, bounds.Max.Z) }; + Face3D face3D = new Face3D(panel.Plane, new Polygon2D(corners.Select(c => panel.Plane.Convert(c)).ToList())); + Panel result = AnalyticalCreate.Panel(panel.Guid, panel, face3D, null, false); + Assert.Equal(panel.GetArea() - (x - bounds.Min.X) * (bounds.Max.Z - bounds.Min.Z), result.GetArea(), 6); + return result; + } + + // Raw box faces, for solids that are not closed or valid. + private static List BoxFaces(double x0, double y0, double z0, double x1, double y1, double z1, bool splitPlusY = false, bool omitPlusX = false) + { + Point3D P(double x, double y, double z) => new Point3D(x, y, z); + Face3D Quad(Point3D a, Point3D b, Point3D c, Point3D d) => new Face3D(new Polygon3D(new List { a, b, c, d })); + double xm = (x0 + x1) / 2; + List result = new List + { + Quad(P(x0, y0, z0), P(x1, y0, z0), P(x1, y0, z1), P(x0, y0, z1)), + Quad(P(x0, y0, z0), P(x1, y0, z0), P(x1, y1, z0), P(x0, y1, z0)), + Quad(P(x0, y0, z1), P(x1, y0, z1), P(x1, y1, z1), P(x0, y1, z1)), + Quad(P(x0, y0, z0), P(x0, y1, z0), P(x0, y1, z1), P(x0, y0, z1)), + }; + + if (splitPlusY) + { + result.Add(Quad(P(x0, y1, z0), P(xm, y1, z0), P(xm, y1, z1), P(x0, y1, z1))); + result.Add(Quad(P(xm, y1, z0), P(x1, y1, z0), P(x1, y1, z1), P(xm, y1, z1))); + } + else + { + result.Add(Quad(P(x0, y1, z0), P(x1, y1, z0), P(x1, y1, z1), P(x0, y1, z1))); + } + + if (!omitPlusX) + { + result.Add(Quad(P(x1, y0, z0), P(x1, y1, z0), P(x1, y1, z1), P(x1, y0, z1))); + } + + return result; + } + + private static bool Certify(Shell shell, double halfThickness, out List support, out string reason, Func> section = null, Func> intersection = null) + { + return AnalyticalCreate.TryCertifyMaterial(shell, HostY, halfThickness, Tolerance.Angle, Tolerance.Distance, Tolerance.MacroDistance, out support, out reason, section, intersection); + } + + // --------------------------------------------------------------------------------------------------------------- + // Certification + + [Fact] + public void PanelsBySolid_PrismaticWall_StampsRecordBoundToThePanel() + { + // Arrange + Shell wall = Box(0, 0, 0, 4, 0.25, 3); + + // Act + Panel panel = Host(wall); + + // Assert + SourceEvidence evidence = panel.SourceEvidence; + Assert.NotNull(evidence); + Assert.Equal(0.25, evidence.GuaranteedThickness, 6); + Assert.Equal(12.0, Area(evidence.Support), 6); + Assert.Equal(12.0, Area(evidence.Origin), 6); + Assert.Equal(12.0, Area(evidence.BoundFootprint), 6); + Assert.True(evidence.Plane.Origin.Distance(panel.Plane.Origin) <= 1e-9); + Assert.True(1 - evidence.Plane.Normal.Unit.DotProduct(panel.Plane.Normal.Unit) <= 1e-12); + Assert.True(1 - evidence.Plane.AxisY.Unit.DotProduct(panel.Plane.AxisY.Unit) <= 1e-12); + Assert.NotNull(panel.ValidSourceEvidence(out string reason)); + Assert.Null(reason); + } + + [Fact] + public void PanelsBySolids_PrismaticWall_StampsRecord() + { + // Arrange + List shells = new List { Box(0, 0, 0, 4, 0.25, 3) }; + + // Act + List> panels = shells.PanelsBySolids(new Core.Diagnostics()); + + // Assert + Panel panel = Assert.Single(panels[0]); + Assert.NotNull(panel.ValidSourceEvidence()); + } + + [Fact] + public void PanelsBySolid_SteppedWall_GuaranteesTheLeastSlab() + { + // Arrange: 2 m of 0.548122 m and 1 m of 0.568122 m (the D203 #250 wall), one mid-plane. + IList<(double U, double V)> outline = new List<(double U, double V)> + { + (0, -0.274061), (2, -0.274061), (2, -0.284061), (3, -0.284061), (3, 0.284061), (2, 0.284061), (2, 0.274061), (0, 0.274061), + }; + Shell wall = Prism(XY, outline, null, 0, 2.85); + + // Act + Panel panel = Host(wall); + + // Assert + SourceEvidence evidence = panel.SourceEvidence; + Assert.NotNull(evidence); + Assert.Equal(0.548122, evidence.GuaranteedThickness, 6); + Assert.Equal(8.55, Area(evidence.Support), 5); + } + + [Fact] + public void PanelsBySolid_SlabWithStairVoid_RecordsHostOnlyAndKeepsTheHoleOpen() + { + // Arrange + Shell slab = Prism(XY, Rectangle(0, 0, 6, 4), new List> { Rectangle(2, 1, 3, 3) }, 2.8, 3.0); + + // Act + List panels = slab.PanelsBySolid(out _); + + // Assert + Panel host = Assert.Single(panels, x => x.PanelType != PanelType.Air); + Panel air = Assert.Single(panels, x => x.PanelType == PanelType.Air); + Assert.Null(air.SourceEvidence); + Assert.NotNull(host.SourceEvidence); + Assert.Equal(0.2, host.SourceEvidence.GuaranteedThickness, 6); + Assert.Equal(22.0, Area(host.SourceEvidence.Support), 6); + Assert.Equal(22.0, Area(host.SourceEvidence.Origin), 6); + Assert.NotNull(host.ValidSourceEvidence()); + } + + [Fact] + public void PanelsBySolid_RectangularTunnelBeyondTheMidPlane_CertifiesAndExcludesThePocket() + { + // Arrange: the section at the tunnel's depth is 8.25 of the wall's 8.55 m2, which sections at the mid-plane never see. + Shell wall = Wall(tunnel: true); + + // Act + Panel panel = Host(wall); + + // Assert + SourceEvidence evidence = panel.SourceEvidence; + Assert.NotNull(evidence); + Assert.Equal(0.548, evidence.GuaranteedThickness, 6); + Assert.Equal(8.25, Area(evidence.Support), 5); + Assert.Equal(8.55, Area(evidence.Origin), 5); + } + + [Fact] + public void PanelsBySolid_DiamondTunnel_GivesNoEvidence() + { + // Arrange: faces at 45 degrees are neither parallel nor perpendicular to the wall. + Shell wall = Wall(tunnel: true, diamond: true); + + // Act + Panel panel = Host(wall); + + // Assert + Assert.Null(panel.SourceEvidence); + } + + [Fact] + public void PanelsBySolid_ChamferInTheSlab_GivesNoEvidence() + { + // Arrange: a 45 degree chamfer at one end of a 0.25 m wall. + IList<(double U, double V)> outline = new List<(double U, double V)> { (0, -0.125), (4, -0.125), (4, 0.05), (3.925, 0.125), (0, 0.125) }; + Shell wall = Prism(XY, outline, null, 0, 3); + + // Act + Panel panel = Host(wall); + + // Assert + Assert.Null(panel.SourceEvidence); + } + + [Fact] + public void PanelsBySolid_LongWallWithFarEndTwoMillimetresOff_GivesNoEvidence() + { + // Arrange: 30 m wall whose +y face is 2 mm off at the far end (6.7e-5 rad): an angular classifier at 1e-4 rad would accept it. + Shell wall = TiltedWall(30, 0.002); + + // Act + Panel panel = Host(wall); + + // Assert + Assert.Null(panel.SourceEvidence); + } + + [Fact] + public void PanelsBySolid_LongWallWithFarEndHalfAMicrometreOff_CertifiesWithinTheBound() + { + // Arrange + Shell wall = TiltedWall(30, 5e-7); + + // Act + Panel panel = Host(wall); + + // Assert + Assert.NotNull(panel.SourceEvidence); + } + + [Fact] + public void TryCertifyMaterial_PositionalBound_AcceptsHalfBoundRejectsTwiceBound() + { + // Arrange + Shell within = TiltedWall(30, 5e-7); + Shell beyond = TiltedWall(30, 2e-6); + + // Act + bool certifiedWithin = Certify(within, 0.125, out List support, out _); + bool certifiedBeyond = Certify(beyond, 0.125, out List supportBeyond, out string reason); + + // Assert + Assert.True(certifiedWithin); + Assert.NotEmpty(support); + Assert.False(certifiedBeyond); + Assert.Null(supportBeyond); + Assert.Contains("neither parallel nor perpendicular", reason); + } + + [Fact] + public void TryCertifyMaterial_ClosedPrism_Certifies() + { + // Arrange + Shell wall = Wall(); + + // Act + bool certified = Certify(wall, 0.274, out List support, out string reason); + + // Assert + Assert.True(certified, reason); + Assert.Equal(8.55, Area(support), 6); + } + + [Fact] + public void TryCertifyMaterial_WallWithAnEndFaceMissing_GivesNoEvidence() + { + // Arrange + Shell open = new Shell(BoxFaces(0, -0.125, 0, 3, 0.125, 2.85, omitPlusX: true)); + + // Act + bool certified = Certify(open, 0.125, out List support, out string reason); + + // Assert + Assert.False(certified); + Assert.Null(support); + Assert.Contains("not a closed, valid solid", reason); + Assert.Contains("shared by 0 other edge(s)", reason); + } + + [Fact] + public void TryCertifyMaterial_FaceSplitByATJunction_GivesNoEvidence() + { + // Arrange: the +y face is two faces, so the long edges of its neighbours have no single partner. + Shell split = new Shell(BoxFaces(0, -0.125, 0, 4, 0.125, 3, splitPlusY: true)); + Shell whole = new Shell(BoxFaces(0, -0.125, 0, 4, 0.125, 3)); + + // Act + bool certifiedSplit = Certify(split, 0.125, out _, out string reason); + bool certifiedWhole = Certify(whole, 0.125, out List support, out _); + + // Assert + Assert.False(certifiedSplit); + Assert.Contains("not a closed, valid solid", reason); + Assert.True(certifiedWhole); + Assert.Equal(12.0, Area(support), 6); + } + + [Fact] + public void TryCertifyMaterial_LayerThinnerThanTheSnapTolerance_GivesNoEvidence() + { + // Arrange: steps of the -y face at y -0.05 and -0.0495 leave a 0.5 mm layer inside the 0.25 m slab. + IList<(double U, double V)> outline = new List<(double U, double V)> + { + (0, -0.125), (1, -0.125), (1, -0.05), (2, -0.05), (2, -0.0495), (3, -0.0495), (3, 0.125), (0, 0.125), + }; + Shell wall = Prism(XY, outline, null, 0, 3); + + // Act + bool certified = Certify(wall, 0.125, out List support, out string reason); + + // Assert + Assert.False(certified); + Assert.Null(support); + Assert.Contains("thinner than the snap tolerance", reason); + } + + [Fact] + public void TryCertifyMaterial_SectionReturnsNullOrEmpty_GivesNoEvidence() + { + // Arrange + Shell wall = Wall(); + + // Act + bool certifiedNull = Certify(wall, 0.274, out _, out string reasonNull, section: (x, y) => null); + bool certifiedEmpty = Certify(wall, 0.274, out _, out string reasonEmpty, section: (x, y) => new List()); + + // Assert: an empty or missing section is never proof. + Assert.False(certifiedNull); + Assert.Contains("failed or is empty", reasonNull); + Assert.False(certifiedEmpty); + Assert.Contains("failed or is empty", reasonEmpty); + } + + [Fact] + public void TryCertifyMaterial_IntersectionReturnsNull_GivesNoEvidence() + { + // Arrange: the tunnel makes three layers, so the support is an intersection of regions. + Shell wall = Wall(tunnel: true); + + // Act + bool certified = Certify(wall, 0.274, out List support, out string reason, intersection: (x, y, tolerance) => null); + + // Assert: a null result with overlapping bounds is a failure, not an empty intersection. + Assert.False(certified); + Assert.Null(support); + Assert.Contains("intersection of regions failed", reason); + } + + [Fact] + public void DefiniteIntersection_DisjointBounds_IsEmptyAndOverlappingNullIsFailure() + { + // Arrange + Plane plane = new Plane(new Point3D(0, 0, 0), new Vector3D(0, 0, 1)); + Face2D a = plane.Convert(Rect(plane, 0, 0, 1, 1)); + Face2D b = plane.Convert(Rect(plane, 5, 5, 6, 6)); + Face2D c = plane.Convert(Rect(plane, 0.5, 0.5, 1.5, 1.5)); + + // Act + List disjoint = AnalyticalQuery.DefiniteIntersection(new[] { a }, new[] { b }, Tolerance.Distance, (x, y, t) => null); + List overlapping = AnalyticalQuery.DefiniteIntersection(new[] { a }, new[] { c }, Tolerance.Distance, (x, y, t) => null); + List real = AnalyticalQuery.DefiniteIntersection(new[] { a }, new[] { c }); + + // Assert + Assert.NotNull(disjoint); + Assert.Empty(disjoint); + Assert.Null(overlapping); + Assert.Equal(0.25, Area(real), 6); + } + + // --------------------------------------------------------------------------------------------------------------- + // Save and reload + + [Fact] + public void Panel_WithSourceEvidence_SurvivesSaveAndReload() + { + // Arrange + Panel panel = Host(Wall(tunnel: true)); + SourceEvidence before = panel.SourceEvidence; + + // Act + Panel back = Core.Create.IJSAMObject(Core.Convert.ToString(panel)); + + // Assert + SourceEvidence after = back.SourceEvidence; + Assert.NotNull(after); + Assert.True(before.Plane.Origin.Distance(after.Plane.Origin) <= 1e-9); + Assert.True(1 - before.Plane.Normal.Unit.DotProduct(after.Plane.Normal.Unit) <= 1e-12); + Assert.True(1 - before.Plane.AxisY.Unit.DotProduct(after.Plane.AxisY.Unit) <= 1e-12); + Assert.Equal(before.GuaranteedThickness, after.GuaranteedThickness, 12); + foreach ((List x, List y) in new[] { (before.Support, after.Support), (before.Origin, after.Origin), (before.BoundFootprint, after.BoundFootprint) }) + { + Assert.Equal(x.Count, y.Count); + Assert.Equal(Area(x), Area(y), 9); + Assert.Equal(x.Sum(f => AnalyticalQuery.RegionPerimeter(new[] { f })), y.Sum(f => AnalyticalQuery.RegionPerimeter(new[] { f })), 9); + } + + Assert.NotNull(back.ValidSourceEvidence(out string reason)); + Assert.Null(reason); + } + + [Fact] + public void Panel_WithSourceEvidence_RoundTripsThroughJsonUnchanged() + { + // Arrange + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + + // Act + Panel back = Helpers.RoundTrip.Once(panel); + + // Assert + Assert.NotNull(back.SourceEvidence); + } + + [Fact] + public void Panel_WithoutSourceEvidenceKey_HasNoEvidence() + { + // Arrange: a panel saved before the record existed. + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + JsonObject json = panel.ToJsonObject(); + Assert.True(json.Remove("SourceEvidence")); + + // Act + Panel back = new Panel(json); + + // Assert + Assert.Null(back.SourceEvidence); + Assert.Null(back.ValidSourceEvidence(out string reason)); + Assert.Contains("no source evidence", reason); + } + + [Fact] + public void Panel_WithIncompleteRecord_IsLoadedWithoutIt() + { + // Arrange: an all-or-nothing record with its support removed is no evidence. + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + JsonObject json = panel.ToJsonObject(); + ((JsonObject)json["SourceEvidence"]).Remove("Support"); + + // Act + Panel back = new Panel(json); + + // Assert + Assert.Null(back.SourceEvidence); + } + + // --------------------------------------------------------------------------------------------------------------- + // Validity: a record is evidence only while it describes the panel + + [Fact] + public void ValidSourceEvidence_CopyOfPanel_StaysValid() + { + // Arrange + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + + // Act + Panel copy = AnalyticalCreate.Panel(panel); + Panel copy_Guid = AnalyticalCreate.Panel(Guid.NewGuid(), panel); + + // Assert + Assert.NotNull(copy.ValidSourceEvidence()); + Assert.NotNull(copy_Guid.ValidSourceEvidence()); + } + + [Theory] + [InlineData("trim", 0.0, 0.5)] + [InlineData("extend", -0.25, 1.25)] + [InlineData("split", 0.5, 1.0)] + public void ValidSourceEvidence_SamePlaneEditKeepsTheFrame_ButNotTheEvidence(string edit, double fromU, double toU) + { + // Arrange + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + + // Act + Panel edited = Edited(panel, fromU, toU); + + // Assert: the frame survives every edit that rebuilds the face on the same plane, so only the footprint tells. + Assert.True(edited.Plane.Origin.Distance(panel.SourceEvidence.Plane.Origin) <= 1e-9, edit); + Assert.NotNull(edited.SourceEvidence); + Assert.Null(edited.ValidSourceEvidence(out string reason)); + Assert.Contains("footprint differs", reason); + } + + [Fact] + public void ValidSourceEvidence_MergedPieces_IsNoEvidence() + { + // Arrange: two panels on one plane merged into one face. + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + Panel other = Host(Box(4, 0, 0, 6, 0.25, 3)); + Plane frame = panel.Plane; + List union = Geometry.Planar.Query.Union(new List { frame.Convert(panel.GetFace3D()), frame.Convert(other.GetFace3D()) }, Tolerance.Distance); + Assert.Single(union); + + // Act + Panel merged = AnalyticalCreate.Panel(panel.Guid, panel, frame.Convert(union[0]), null, false); + + // Assert + Assert.Equal(18.0, merged.GetArea(), 6); + Assert.Null(merged.ValidSourceEvidence()); + } + + [Fact] + public void ValidSourceEvidence_MovedByAMillimetre_IsNoEvidence() + { + // Arrange + Panel panel = AnalyticalCreate.Panel(Host(Box(0, 0, 0, 4, 0.25, 3))); + + // Act + panel.Move(new Vector3D(0.001, 0, 0)); + + // Assert + Assert.Null(panel.ValidSourceEvidence(out string reason)); + Assert.Contains("frame differs", reason); + } + + [Fact] + public void ValidSourceEvidence_Rotated_IsNoEvidence() + { + // Arrange + Panel panel = AnalyticalCreate.Panel(Host(Box(0, 0, 0, 4, 0.25, 3))); + + // Act + panel.Transform(Transform3D.GetRotationZ(0.1)); + + // Assert + Assert.Null(panel.ValidSourceEvidence(out string reason)); + Assert.Contains("frame differs", reason); + } + + [Fact] + public void ValidSourceEvidence_Scaled_IsNoEvidence() + { + // Arrange + Panel panel = AnalyticalCreate.Panel(Host(Box(0, 0, 0, 4, 0.25, 3))); + + // Act + panel.Transform(Transform3D.GetScale(1.01)); + + // Assert: a scaling moves the frame, or at least changes the footprint. + Assert.Null(panel.ValidSourceEvidence()); + } + + [Fact] + public void ValidSourceEvidence_Flipped_IsNoEvidence() + { + // Arrange + Panel panel = AnalyticalCreate.Panel(Host(Box(0, 0, 0, 4, 0.25, 3))); + + // Act + panel.FlipNormal(false); + + // Assert + Assert.Null(panel.ValidSourceEvidence()); + } + + [Fact] + public void ValidSourceEvidence_SameFootprintRebuiltOnAnotherFrame_IsNoEvidence() + { + // Arrange: the same face in 3D on the same plane, expressed in another frame (origin moved in the plane, axes turned 90 degrees). + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + Plane frame = panel.Plane; + Point3D origin = new Point3D(frame.Origin.X + 0.37 * frame.AxisX.X, frame.Origin.Y + 0.37 * frame.AxisX.Y, frame.Origin.Z + 0.37 * frame.AxisX.Z); + Plane other = new Plane(origin, frame.AxisY, frame.AxisX.GetNegated()); + Assert.True(1 - other.Normal.Unit.DotProduct(frame.Normal.Unit) <= 1e-12, "the same plane, facing the same way"); + Face3D rebuilt = new Face3D(other, other.Convert(panel.GetFace3D())); + Panel edited = AnalyticalCreate.Panel(panel.Guid, panel, rebuilt, null, false); + Assert.Equal(panel.GetArea(), edited.GetArea(), 6); + Assert.True(edited.Plane.Origin.Distance(panel.SourceEvidence.Plane.Origin) > 1e-6, "the test needs a different frame"); + + // Act + SourceEvidence evidence = edited.ValidSourceEvidence(out string reason); + + // Assert + Assert.Null(evidence); + Assert.Contains("frame differs", reason); + } + + // --------------------------------------------------------------------------------------------------------------- + // SnapToJunctions rebinding + + // A facade (mid-plane y 0, from x 0.2) and a return wall (mid-plane x 0, from y 0.2), 0.4 m thick: each ends at the other's + // face, 0.2 m short of its mid-plane. + private static List Corner() + { + return new List + { + Host(Box(0.2, -0.2, 0, 6, 0.2, 3), "F"), + Host(Box(-0.2, 0.2, 0, 0.2, 4, 3), "C"), + }; + } + + private static Panel Named(IEnumerable panels, string name) + { + return panels.Single(x => x.GetValue(PanelSourceParameter.SourceName) == name); + } + + [Fact] + public void SnapToJunctions_PanelsWithEvidence_RebindsItToTheSnappedFootprint() + { + // Arrange + List panels = Corner(); + Assert.All(panels, x => Assert.NotNull(x.ValidSourceEvidence())); + Assert.Equal(17.4, Named(panels, "F").GetArea(), 5); + + // Act + List snapped = panels.SnapToJunctions(out _); + + // Assert: the facade now runs to the return wall's mid-plane, and its record is bound to that footprint. + Panel facade = Named(snapped, "F"); + Assert.Equal(18.0, facade.GetArea(), 5); + SourceEvidence evidence = facade.ValidSourceEvidence(out string reason); + Assert.NotNull(evidence); + Assert.Null(reason); + Assert.Equal(18.0, Area(evidence.BoundFootprint), 5); + Assert.Equal(17.4, Area(evidence.Origin), 5); + Assert.Equal(17.4, Area(evidence.Support), 5); + Assert.NotNull(Named(snapped, "C").ValidSourceEvidence()); + } + + [Fact] + public void SnapToJunctions_SnappedPanels_StayValidAfterSaveReloadAndASecondPass() + { + // Arrange + List snapped = Corner().SnapToJunctions(out _); + + // Act + List reloaded = snapped.ConvertAll(x => Core.Create.IJSAMObject(Core.Convert.ToString(x))); + List again = reloaded.SnapToJunctions(out _); + + // Assert + Assert.All(reloaded, x => Assert.NotNull(x.ValidSourceEvidence())); + Assert.All(again, x => Assert.NotNull(x.ValidSourceEvidence())); + Assert.Equal(18.0, Named(again, "F").GetArea(), 5); + } + + [Fact] + public void SnapToJunctions_SamePlaneEditThenSnapBackToTheBoundFootprint_StaleEvidenceStaysDropped() + { + // Arrange: snapped panels carry records bound to their snapped footprints. + List snapped = Corner().SnapToJunctions(out _); + Panel facade = Named(snapped, "F"); + Assert.NotNull(facade.ValidSourceEvidence()); + + // A same-plane edit trims 0.12 m off the facade's west end: the frame survives, the record is stale. + Panel edited = TrimmedWestOf(facade, facade.GetFace3D().GetBoundingBox().Min.X + 0.12); + Assert.Null(edited.ValidSourceEvidence()); + Assert.NotNull(edited.SourceEvidence); + List editedSet = new List { edited, Named(snapped, "C") }; + + // Act: snapping extends the trimmed edge back to the return wall's mid-plane, to exactly the bound footprint. + List result = editedSet.SnapToJunctions(out _); + + // Assert + Panel resnapped = Named(result, "F"); + Assert.Equal(facade.GetArea(), resnapped.GetArea(), 6); + Assert.Equal(0, AnalyticalQuery.RegionArea(new[] { resnapped.Plane.Convert(resnapped.GetFace3D()) }) - Area(facade.SourceEvidence.BoundFootprint), 6); + Assert.Null(resnapped.SourceEvidence); + Assert.Null(resnapped.ValidSourceEvidence()); + + // The panel that was not edited keeps valid evidence. + Assert.NotNull(Named(result, "C").ValidSourceEvidence()); + } + + [Fact] + public void SnapToJunctions_PanelWithoutEvidence_StaysWithout() + { + // Arrange: a wall that is not layered prismatic (a chamfered end) has no record to rebind. + IList<(double U, double V)> outline = new List<(double U, double V)> { (0.2, -0.2), (6, -0.2), (6, 0.1), (5.9, 0.2), (0.2, 0.2) }; + Panel chamfered = Host(Prism(XY, outline, null, 0, 3), "F"); + Panel returnWall = Host(Box(-0.2, 0.2, 0, 0.2, 4, 3), "C"); + Assert.Null(chamfered.SourceEvidence); + + // Act + List snapped = new List { chamfered, returnWall }.SnapToJunctions(out _); + + // Assert + Assert.Null(Named(snapped, "F").SourceEvidence); + Assert.NotNull(Named(snapped, "C").ValidSourceEvidence()); + } + + [Fact] + public void SnapToJunctions_ZeroSnapDistance_ReturnsCopiesThatKeepTheirRecords() + { + // Arrange + List panels = Corner(); + + // Act + List copies = panels.SnapToJunctions(out _, 0); + + // Assert + Assert.All(copies, x => Assert.NotNull(x.ValidSourceEvidence())); + } + } +} From aeafa38f098cb44e5c2316af06712b03d013f520 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Tue, 3 Nov 2026 21:46:15 +0100 Subject: [PATCH 2/4] docs: PanelsBySolid source evidence record (tested content e2bfbf2d) Co-Authored-By: Claude Sonnet 5.5 --- .../PanelsBySolid-SourceEvidence-Q4.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 documentation/PanelsBySolid-SourceEvidence-Q4.md diff --git a/documentation/PanelsBySolid-SourceEvidence-Q4.md b/documentation/PanelsBySolid-SourceEvidence-Q4.md new file mode 100644 index 000000000..f887fbb14 --- /dev/null +++ b/documentation/PanelsBySolid-SourceEvidence-Q4.md @@ -0,0 +1,133 @@ +# PanelsBySolid source evidence (SAM producer) - Q4 (2026-11-03) + +Status: draft PR from `feat/panelsbysolid-source-evidence-q4` into `sow/2026-Q4` (base `7c4591cda22f0f4ad0ec07abad08350b28ec884f`). +Draft, for owner review: not ready for review, not merged, not approved. The PR number and the final head are in the PR +description. Tested content SHA: `e2bfbf2db9f7fb0b9427e4c7f10da496b1a08b96`. This record-only commit follows it and changes only +this file. + +This is the SAM producer half of design rev 5 (`DESIGN-contact-aware-void-guard.md`, sections 1 and 2, owner sign-off +2026-11-03; machine-local evidence under `SAM_daily/2026-10-25-Softthouse/handover/evidence/2026-11-03-d203-trace/`). +The SAM_OCCT consumer (section 3: the Clean3D decision) is **not** in this PR; it is its own follow-up session and branch. +Until both land nothing changes for users: no existing code reads the record. + +## Work completed + +- `SourceEvidence` (`SAM/SAM.Analytical/Classes/SourceEvidence.cs`): the optional, all-or-nothing record. `Plane` (frame at + extraction), `GuaranteedThickness` g, `Support`, `Origin`, `BoundFootprint` (`Face2D` lists in that frame, inner loops kept). + Immutable from outside (getters return copies), JSON save/reload, `IsComplete` (frame, g > 0 and non-empty regions). +- `Create.PanelsBySolid` certifies material (`Create/PanelsBySolidSourceEvidence.cs`) once per element plane and stamps each + host panel (never Air panels). Only for closed, valid, layered prismatic solids; otherwise no record and nothing changes: + - closure by edge pairing (every face valid, at least 4 faces, every edge straight, every non-degenerate edge matched by + exactly one other edge within `Tolerance.Distance`; `Shell.IsClosed` is not used); + - positional classification about the host normal with delta = `Tolerance.Distance` metres over the actual extent (parallel: + vertex offsets within delta; perpendicular: every edge within delta of along/across the normal, with one of each); no angles; + - one section per layer between the parallel-face offsets inside the slab, `Support` = intersection of the material regions + (internal point `Inside`, inner loops kept); a layer thinner than the snap tolerance fails; no `Shell.Difference`; an empty + or null section is a failure; intersections use `Query.DefiniteIntersection`; + - g = 2 x the least half-extent about the host plane over the element plane's pairs and nested pairs. +- `Panel` carries, copies and saves the record (`Panel.SourceEvidence`, JSON key `SourceEvidence`). All internal copy + constructors copy it (it is never modified in place, so copies share it). `SetSourceEvidence` is internal. +- `Query.ValidSourceEvidence(panel[, out reason], tolerance)`: the record is evidence only while (a) its frame equals the + panel's (origin 1e-9, normal and Y axis same direction to 1e-12 of 1 - cos), (b) the panel's footprint equals + `BoundFootprint` (symmetric difference at most `tolerance * perimeter(BoundFootprint)`, definite-result intersection), and + (e) the record is complete with g > 0. A kernel failure is no evidence. +- `Query.SnapToJunctions` rebinds at its one `Create.Panel(panel.Guid, panel, face3D, null, false)` site: a record that is valid + on the input panel is bound to the snapped footprint and re-checked on the result; any other record is **dropped** from the + snapped panel. Copy-only paths (zero snap distance, unsettled set, no target) return the panel unchanged, record included. +- `Query.DefiniteIntersection` is public so the consumer applies the same null/empty semantics. + +## Important decisions and assumptions (owner to confirm) + +1. **Storage.** Rev 4 said "one parameter value on the panel"; this implementation uses a dedicated `Panel` field and JSON + property instead, so the record follows every `Create.Panel` copy without touching `ParameterSet`s and cannot be edited + through the parameter API. Behaviour is as the design describes. +2. **Conditions (c) and (d) are consumer-side.** Stage A "plane unmoved within `Tolerance.Distance`" and `SourceIndices.Count == 1` + need solver state; `ValidSourceEvidence` implements (a), (b), (e) only, and its documentation says so. +3. **Stale evidence never revives through snapping.** A same-plane edit keeps the frame, so a stale record could match the + footprint again if snapping extends the edge back to the bound footprint. Snapping therefore drops any record that is not + valid on its input rather than carrying it. Tested (below); the fail-first mutation was run. +4. **Pieces.** Rev 3/4 text and acceptance item 2 mention "pieces by minimum/union". `PanelsBySolids` never merges pieces into + one SAM panel: every panel, including those of the continuation/replacement pass, is read from its own solid through the same + method, so each carries its own record. Panels merged from several sources are made in SAM_OCCT, where rev 5 excludes them + by `SourceIndices.Count == 1`. No union or minimum is computed here. Owner to confirm this reading. +5. **Per-panel regions.** `Support` is certified once per element plane (all regions) and each panel keeps the regions whose + bounding box overlaps the panel's by the snap tolerance. `Origin` is the panel's face at extraction (aperture-claimed openings + and notches filled, unclaimed holes left open), as the prover used it. +6. **No new diagnostics.** A solid that is not certified gets no record and no message, so existing diagnostics (including the + Detailed text) are unchanged. Owner may want a Detailed Info code; it would change goldens that pin Detailed output. +7. **Public surface added:** `SourceEvidence`, `Panel.SourceEvidence`, `Query.ValidSourceEvidence` (two overloads), + `Query.DefiniteIntersection`. Nothing existing changed. No Grasshopper component, parameter, version or description touched. + +## Files changed + +- `SAM/SAM.Analytical/Classes/SourceEvidence.cs` (new) +- `SAM/SAM.Analytical/Create/PanelsBySolidSourceEvidence.cs` (new): closure, positional classification, layered certification +- `SAM/SAM.Analytical/Query/ValidSourceEvidence.cs` (new): validity, definite intersection, region area/perimeter helpers +- `SAM/SAM.Analytical/Classes/Panel.cs`: field, copies, property, JSON +- `SAM/SAM.Analytical/Create/PanelsBySolid.cs`: certify and stamp in `PanelsByElementPlane` +- `SAM/SAM.Analytical/Query/SnapToJunctions.cs`: rebind +- `SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs` (new, 36 tests) +- `documentation/PanelsBySolid-SourceEvidence-Q4.md` (this record) + +## Tests (`SAM.Tests/PanelsBySolidSourceEvidenceTests.cs`, xUnit, `Method_State_Expected`) + +- Certification through `PanelsBySolid`/`PanelsBySolids`: prismatic wall; stepped wall (D203 #250 profile, g 0.548122); slab with + stair void (host recorded, Air panel not); rectangular tunnel past the mid-plane (support 8.25 of 8.55 m2, pocket excluded); + diamond tunnel, 45 degree chamfer, 30 m wall 2 mm off at the far end -> no record; 30 m wall 0.5 micrometre off -> record. +- Direct (`Create.TryCertifyMaterial`, internal seams for the section and intersection): positional bound (5e-7 accepted, + 2e-6 rejected); end face missing; T-junction split face; layer thinner than the snap tolerance; null and empty section; null + intersection with overlapping bounds; `DefiniteIntersection` disjoint/overlapping/real. +- Save/reload: record equal after `Convert.ToString` -> `Create.IJSAMObject` (frame, g, region areas and perimeters) and + still valid; JSON round trip; panel JSON without the key; incomplete record dropped on load. +- Validity: copies stay valid; same-plane trim, extension and split (frame kept, record invalid, "footprint differs"); merged + pieces; `Move` 1 mm; rotation; scaling; `FlipNormal`; same footprint on a rebuilt frame. +- `SnapToJunctions`: rebind to the snapped footprint (facade 17.4 -> 18.0 m2, `BoundFootprint` 18.0, `Origin` 17.4); valid after + save/reload and a second pass; **same-plane edit then snapping back to the bound footprint leaves no record** (the trimmed + edge returns to exactly 18.0 m2, the unedited panel keeps its record); panel without a record stays without; zero snap + distance keeps records. + +Not in this PR (consumer side, SAM_OCCT): Stage A plane move of 2e-6, `Absorb`, the Clean3D decision, controls A-D, T1, T2, W. + +## Validation + +Local, Windows, Debug, `dotnet test SAM/SAM.Tests/SAM.Tests.csproj --no-build`, with `APPDATA` pointed at a scratch folder so the +build deployed nothing to `%APPDATA%\SAM`. Reproduce from `SAM/` of the worktree: + +``` +APPDATA= dotnet build SAM.Tests/SAM.Tests.csproj -c Debug +APPDATA= dotnet test SAM.Tests/SAM.Tests.csproj --no-build +APPDATA= dotnet test SAM.Tests/SAM.Tests.csproj --no-build --filter "FullyQualifiedName~PanelsBySolidSourceEvidenceTests" +``` + +| Check | Result | +| --- | --- | +| Baseline build of `sow/2026-Q4` `7c4591cd` | 0 errors | +| Full `SAM.Tests` suite at the working tree that became `e2bfbf2d` | **4017 passed, 0 failed, 0 skipped** (36 new) | +| Full `SAM.Tests` suite at `e2bfbf2d` | **4017 passed, 0 failed, 0 skipped**, 3 m 44 s | +| New tests alone at `e2bfbf2d` | 36 passed, 0 failed | +| Fail-first mutation: `RebindSourceEvidence` returns early for an invalid record (stale record carried by snapping) | `SnapToJunctions_SamePlaneEditThenSnapBackToTheBoundFootprint_StaleEvidenceStaysDropped` failed, 35 others passed; reverted | +| Extraction cost: `PanelsBySolidComplexHouseTests`, `PanelsBySolidsTests`, `PanelsBySolidMultiElementTests` (59 tests), two runs each | with change 10 s and 11 s; base 12 s and 11 s: no measurable overhead | + +Existing tests are unchanged (no golden or parameter-set edit). + +## Unresolved issues, risks, blockers + +- **Not run:** hosted CI (no PR head to check yet); Release configuration; `SAM.Core.Grasshopper.Tests` (needs Rhino; no + Grasshopper surface touched); Chain N0/N1/N2 on the saved Softthouse R/F sets and the 392-solid census (need the machine-local + data and the SAM_OCCT consumer). +- **Real-data confirmations still open** (design section 7): footprint-equality tolerance against the real `SnapToJunctions` + rounding (367 of 367 frames matched in the prover; here only the synthetic corner and a JSON round trip are covered); record + size (three region lists per panel; not measured); coverage 80.9 % of solids about a horizontal normal. Slab/roof host normals: + one slab with a void is tested, no census. +- Residuals the proof does not check, as designed: self-intersection and outward orientation of the solid. +- `Face2D.Intersection` null semantics: a null with overlapping bounds is treated as failure (fail closed). +- Decisions 1, 4 and 6 above are the owner's call. + +## Next step + +Owner: review this draft and decide items 1, 4 and 6. Then the consumer, in its own session: SAM_OCCT branch +`fix/clean3d-contact-void-guard-q4` from `sow/2026-Q4`, **new session, Plan then Build, Sonnet 5.5, High effort** (a large solver +change that needs a plan against `SnappedPanel`/Clean3D first), controls A-D, T1, T2, W first. It reads `Panel.SourceEvidence` +through `Query.ValidSourceEvidence`, adds conditions (c) and (d), and must build against this SAM branch (check how its CI +resolves the SAM dependency by branch name before relying on it). Merge order: SAM first. After merge, add the +`PROJECT_PROGRESS.md` closeout with the merge SHA. Commit and push before switching computers. From 6eddd56d98ac9f19f613481b923f0b90096f470b Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Wed, 4 Nov 2026 10:45:30 +0100 Subject: [PATCH 3/4] fix(analytical): SourceEvidence is immutable once constructed (Q4) Panels and their copies share one SourceEvidence instance, and its public FromJsonObject could overwrite the certified thickness or support of all of them without going through the rebinding check. FromJsonObject is now an explicit interface implementation that always refuses; the JSON constructor is the only reader. The regions and frame the record returns were already copies. Regression tests: overwriting a panel's record (and the one the validity query returns, and a copy's) through the JSON reader changes neither the panel nor its copy; regions and frame handed to a caller are copies; a record is still read by Core.Create.IJSAMObject. Co-Authored-By: Claude Sonnet 5.5 --- SAM/SAM.Analytical/Classes/SourceEvidence.cs | 30 +++++--- .../PanelsBySolidSourceEvidenceTests.cs | 77 +++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/SAM/SAM.Analytical/Classes/SourceEvidence.cs b/SAM/SAM.Analytical/Classes/SourceEvidence.cs index 234a5ce73..452632d72 100644 --- a/SAM/SAM.Analytical/Classes/SourceEvidence.cs +++ b/SAM/SAM.Analytical/Classes/SourceEvidence.cs @@ -28,7 +28,9 @@ namespace SAM.Analytical /// /// /// All regions are s in the frame , inner loops kept. The record is stored on the - /// panel and saved with it. + /// panel and saved with it. The record is immutable once constructed: panels and their copies share one instance, so it + /// has no public way to change a field (the regions and frame it returns are copies, and + /// is implemented explicitly and always refuses); a changed record is a new one, made by . /// /// public class SourceEvidence : IAnalyticalObject @@ -63,7 +65,7 @@ public SourceEvidence(SourceEvidence sourceEvidence) public SourceEvidence(JsonObject jsonObject) { - FromJsonObject(jsonObject); + Read(jsonObject); } /// @@ -138,20 +140,28 @@ public SourceEvidence WithBoundFootprint(IEnumerable boundFootprint) return new SourceEvidence(plane, GuaranteedThickness, support, origin, boundFootprint); } - public bool FromJsonObject(JsonObject jsonObject) + /// + /// Always false, and nothing changes. A record is immutable once constructed: panels and their copies share one instance, + /// so overwriting it would change the certified thickness or support of every one of them without going through the + /// rebinding check. Read a record with . + /// + bool IJSAMObject.FromJsonObject(JsonObject jsonObject) + { + return false; + } + + private void Read(JsonObject jsonObject) { if (jsonObject == null) { - return false; + return; } plane = jsonObject["Plane"] is JsonObject planeJson ? new Plane(planeJson) : null; GuaranteedThickness = jsonObject["GuaranteedThickness"] is JsonValue jsonValue && jsonValue.TryGetValue(out double value) ? value : double.NaN; - support = Read(jsonObject["Support"]); - origin = Read(jsonObject["Origin"]); - boundFootprint = Read(jsonObject["BoundFootprint"]); - - return true; + support = ReadRegions(jsonObject["Support"]); + origin = ReadRegions(jsonObject["Origin"]); + boundFootprint = ReadRegions(jsonObject["BoundFootprint"]); } public JsonObject ToJsonObject() @@ -183,7 +193,7 @@ private static List Clone(IEnumerable face2Ds) return face2Ds?.Where(x => x != null).Select(x => new Face2D(x.ToJsonObject())).ToList(); } - private static List Read(JsonNode jsonNode) + private static List ReadRegions(JsonNode jsonNode) { if (jsonNode is not JsonArray jsonArray) { diff --git a/SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs b/SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs index cfc97b98c..5c993c1a1 100644 --- a/SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs +++ b/SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs @@ -737,5 +737,82 @@ public void SnapToJunctions_ZeroSnapDistance_ReturnsCopiesThatKeepTheirRecords() // Assert Assert.All(copies, x => Assert.NotNull(x.ValidSourceEvidence())); } + + // --------------------------------------------------------------------------------------------------------------- + // Panels and their copies share one record, so nothing a caller can reach may change it. + + private static void AssertIntact(Panel panel) + { + SourceEvidence evidence = panel.SourceEvidence; + Assert.NotNull(evidence); + Assert.Equal(0.25, evidence.GuaranteedThickness, 6); + Assert.Equal(12.0, Area(evidence.Support), 6); + Assert.Equal(12.0, Area(evidence.Origin), 6); + Assert.Equal(12.0, Area(evidence.BoundFootprint), 6); + Assert.NotNull(panel.ValidSourceEvidence()); + } + + [Fact] + public void SourceEvidence_OverwrittenThroughTheJsonReader_ChangesNeitherThePanelNorItsCopy() + { + // Arrange: a forged record with a larger thickness and no support. + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + Panel copy = AnalyticalCreate.Panel(panel); + JsonObject forged = panel.SourceEvidence.ToJsonObject(); + forged["GuaranteedThickness"] = 5.0; + forged["Support"] = new JsonArray(); + + // Act: through the panel's record and through the one the validity query returns. + bool viaPanel = ((IJSAMObject)panel.SourceEvidence).FromJsonObject(forged); + bool viaValidity = ((IJSAMObject)panel.ValidSourceEvidence()).FromJsonObject(forged); + bool viaCopy = ((IJSAMObject)copy.SourceEvidence).FromJsonObject(forged); + + // Assert + Assert.False(viaPanel); + Assert.False(viaValidity); + Assert.False(viaCopy); + AssertIntact(panel); + AssertIntact(copy); + } + + [Fact] + public void SourceEvidence_RegionsAndFrameReturnedToTheCaller_AreCopies() + { + // Arrange + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + Panel copy = AnalyticalCreate.Panel(panel); + SourceEvidence evidence = panel.ValidSourceEvidence(); + + // Act: move and empty everything the record hands out. + foreach (List regions in new[] { evidence.Support, evidence.Origin, evidence.BoundFootprint }) + { + regions.ForEach(x => x.Move(new Vector2D(10, 10))); + regions.Clear(); + } + + Plane plane = evidence.Plane; + plane.Move(new Vector3D(10, 10, 10)); + + // Assert + AssertIntact(panel); + AssertIntact(copy); + Assert.True(evidence.Plane.Origin.Distance(panel.Plane.Origin) <= 1e-9); + } + + [Fact] + public void SourceEvidence_ReadFromJson_IsBuiltByItsConstructor() + { + // Arrange + Panel panel = Host(Box(0, 0, 0, 4, 0.25, 3)); + + // Act + SourceEvidence read = Core.Create.IJSAMObject(Core.Convert.ToString(panel.SourceEvidence)); + + // Assert + Assert.NotNull(read); + Assert.Equal(0.25, read.GuaranteedThickness, 6); + Assert.Equal(12.0, Area(read.Support), 6); + Assert.True(read.IsComplete); + } } } From 1aa1c3a227c49513ef3d85cb2eb203ba303dc7b9 Mon Sep 17 00:00:00 2001 From: Michal Dengusiak Date: Wed, 4 Nov 2026 10:46:14 +0100 Subject: [PATCH 4/4] docs: PanelsBySolid source evidence record (tested content 6eddd56d) Co-Authored-By: Claude Sonnet 5.5 --- .../PanelsBySolid-SourceEvidence-Q4.md | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/documentation/PanelsBySolid-SourceEvidence-Q4.md b/documentation/PanelsBySolid-SourceEvidence-Q4.md index f887fbb14..04febb4f2 100644 --- a/documentation/PanelsBySolid-SourceEvidence-Q4.md +++ b/documentation/PanelsBySolid-SourceEvidence-Q4.md @@ -2,9 +2,14 @@ Status: draft PR from `feat/panelsbysolid-source-evidence-q4` into `sow/2026-Q4` (base `7c4591cda22f0f4ad0ec07abad08350b28ec884f`). Draft, for owner review: not ready for review, not merged, not approved. The PR number and the final head are in the PR -description. Tested content SHA: `e2bfbf2db9f7fb0b9427e4c7f10da496b1a08b96`. This record-only commit follows it and changes only +description. Tested content SHA: `6eddd56d98ac9f19f613481b923f0b90096f470b`. This record-only commit follows it and changes only this file. +History: first delivery tested at `e2bfbf2d` (record head `aeafa38f`). Owner review (2026-11-04) accepted the dedicated `Panel` +field and JSON key, certifying each piece from its own solid, and leaving diagnostics unchanged (decisions 1, 4, 6), and asked +for one fix: `Panel.SourceEvidence` returned an instance shared by panel copies while `SourceEvidence.FromJsonObject` was public +and could overwrite it. Fixed in `6eddd56d` (below); the PR stays a draft. + This is the SAM producer half of design rev 5 (`DESIGN-contact-aware-void-guard.md`, sections 1 and 2, owner sign-off 2026-11-03; machine-local evidence under `SAM_daily/2026-10-25-Softthouse/handover/evidence/2026-11-03-d203-trace/`). The SAM_OCCT consumer (section 3: the Clean3D decision) is **not** in this PR; it is its own follow-up session and branch. @@ -14,7 +19,10 @@ Until both land nothing changes for users: no existing code reads the record. - `SourceEvidence` (`SAM/SAM.Analytical/Classes/SourceEvidence.cs`): the optional, all-or-nothing record. `Plane` (frame at extraction), `GuaranteedThickness` g, `Support`, `Origin`, `BoundFootprint` (`Face2D` lists in that frame, inner loops kept). - Immutable from outside (getters return copies), JSON save/reload, `IsComplete` (frame, g > 0 and non-empty regions). + **Immutable once constructed**: the regions and frame it returns are copies, `WithBoundFootprint` makes a new record, and + `IJSAMObject.FromJsonObject` is an explicit interface implementation that always returns false and changes nothing (panels + and their copies share one instance, so overwriting it would change all of them without the rebinding check). The JSON + constructor is the only reader. JSON save/reload, `IsComplete` (frame, g > 0 and non-empty regions). - `Create.PanelsBySolid` certifies material (`Create/PanelsBySolidSourceEvidence.cs`) once per element plane and stamps each host panel (never Air panels). Only for closed, valid, layered prismatic solids; otherwise no record and nothing changes: - closure by edge pairing (every face valid, at least 4 faces, every edge straight, every non-degenerate edge matched by @@ -36,7 +44,7 @@ Until both land nothing changes for users: no existing code reads the record. snapped panel. Copy-only paths (zero snap distance, unsettled set, no target) return the panel unchanged, record included. - `Query.DefiniteIntersection` is public so the consumer applies the same null/empty semantics. -## Important decisions and assumptions (owner to confirm) +## Important decisions and assumptions (1, 4 and 6 accepted by the owner on 2026-11-04) 1. **Storage.** Rev 4 said "one parameter value on the panel"; this implementation uses a dedicated `Panel` field and JSON property instead, so the record follows every `Create.Panel` copy without touching `ParameterSet`s and cannot be edited @@ -60,13 +68,13 @@ Until both land nothing changes for users: no existing code reads the record. ## Files changed -- `SAM/SAM.Analytical/Classes/SourceEvidence.cs` (new) +- `SAM/SAM.Analytical/Classes/SourceEvidence.cs` (new; `6eddd56d` makes it immutable) - `SAM/SAM.Analytical/Create/PanelsBySolidSourceEvidence.cs` (new): closure, positional classification, layered certification - `SAM/SAM.Analytical/Query/ValidSourceEvidence.cs` (new): validity, definite intersection, region area/perimeter helpers - `SAM/SAM.Analytical/Classes/Panel.cs`: field, copies, property, JSON - `SAM/SAM.Analytical/Create/PanelsBySolid.cs`: certify and stamp in `PanelsByElementPlane` - `SAM/SAM.Analytical/Query/SnapToJunctions.cs`: rebind -- `SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs` (new, 36 tests) +- `SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs` (new, 39 tests) - `documentation/PanelsBySolid-SourceEvidence-Q4.md` (this record) ## Tests (`SAM.Tests/PanelsBySolidSourceEvidenceTests.cs`, xUnit, `Method_State_Expected`) @@ -86,6 +94,12 @@ Until both land nothing changes for users: no existing code reads the record. edge returns to exactly 18.0 m2, the unedited panel keeps its record); panel without a record stays without; zero snap distance keeps records. +- Shared-record immutability (added in `6eddd56d`): overwriting a panel's record, the one `ValidSourceEvidence` returns, and a + copy's record through `IJSAMObject.FromJsonObject` with a forged thickness and no support is refused and leaves the original + panel and its copy intact and valid; the `Support`, `Origin`, `BoundFootprint` and `Plane` handed to a caller are copies + (moved, emptied and offset without effect on the panel or its copy); a record is still read by + `Core.Create.IJSAMObject`. + Not in this PR (consumer side, SAM_OCCT): Stage A plane move of 2e-6, `Absorb`, the Clean3D decision, controls A-D, T1, T2, W. ## Validation @@ -106,13 +120,18 @@ APPDATA= dotnet test SAM.Tests/SAM.Tests.csproj --no-build --filter "Fu | Full `SAM.Tests` suite at `e2bfbf2d` | **4017 passed, 0 failed, 0 skipped**, 3 m 44 s | | New tests alone at `e2bfbf2d` | 36 passed, 0 failed | | Fail-first mutation: `RebindSourceEvidence` returns early for an invalid record (stale record carried by snapping) | `SnapToJunctions_SamePlaneEditThenSnapBackToTheBoundFootprint_StaleEvidenceStaysDropped` failed, 35 others passed; reverted | +| Full `SAM.Tests` suite at the working tree that became `6eddd56d` (after the immutability fix) | **4020 passed, 0 failed, 0 skipped**, 3 m 28 s (39 new) | +| New tests alone at that tree | 39 passed, 0 failed | +| Fail-first for the fix: the three new immutability tests against `SourceEvidence.cs` from `aeafa38f` | `SourceEvidence_OverwrittenThroughTheJsonReader_ChangesNeitherThePanelNorItsCopy` failed; the two copy tests passed (the getters already returned copies); fix restored | +| Hosted checks at `aeafa38f`, as reported by the owner from the logs (not re-read here) | Release solution build and SPDX passed; Release `SAM.Tests` 4017/4017; no reviews or comments on the PR | | Extraction cost: `PanelsBySolidComplexHouseTests`, `PanelsBySolidsTests`, `PanelsBySolidMultiElementTests` (59 tests), two runs each | with change 10 s and 11 s; base 12 s and 11 s: no measurable overhead | Existing tests are unchanged (no golden or parameter-set edit). ## Unresolved issues, risks, blockers -- **Not run:** hosted CI (no PR head to check yet); Release configuration; `SAM.Core.Grasshopper.Tests` (needs Rhino; no +- **Not run:** hosted CI at the head that carries the immutability fix (it is in the PR description once it reports); local + Release configuration (Release ran in hosted CI at `aeafa38f`); `SAM.Core.Grasshopper.Tests` (needs Rhino; no Grasshopper surface touched); Chain N0/N1/N2 on the saved Softthouse R/F sets and the 392-solid census (need the machine-local data and the SAM_OCCT consumer). - **Real-data confirmations still open** (design section 7): footprint-equality tolerance against the real `SnapToJunctions` @@ -121,11 +140,10 @@ Existing tests are unchanged (no golden or parameter-set edit). one slab with a void is tested, no census. - Residuals the proof does not check, as designed: self-intersection and outward orientation of the solid. - `Face2D.Intersection` null semantics: a null with overlapping bounds is treated as failure (fail closed). -- Decisions 1, 4 and 6 above are the owner's call. ## Next step -Owner: review this draft and decide items 1, 4 and 6. Then the consumer, in its own session: SAM_OCCT branch +Owner: review the new head and its hosted checks, then mark the PR ready. The consumer is in its own session: SAM_OCCT branch `fix/clean3d-contact-void-guard-q4` from `sow/2026-Q4`, **new session, Plan then Build, Sonnet 5.5, High effort** (a large solver change that needs a plan against `SnappedPanel`/Clean3D first), controls A-D, T1, T2, W first. It reads `Panel.SourceEvidence` through `Query.ValidSourceEvidence`, adds conditions (c) and (d), and must build against this SAM branch (check how its CI