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..452632d72 --- /dev/null +++ b/SAM/SAM.Analytical/Classes/SourceEvidence.cs @@ -0,0 +1,232 @@ +// 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. 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 + { + 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) + { + Read(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); + } + + /// + /// 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; + } + + 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 = ReadRegions(jsonObject["Support"]); + origin = ReadRegions(jsonObject["Origin"]); + boundFootprint = ReadRegions(jsonObject["BoundFootprint"]); + } + + 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 ReadRegions(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..5c993c1a1 --- /dev/null +++ b/SAM/SAM.Tests/PanelsBySolidSourceEvidenceTests.cs @@ -0,0 +1,818 @@ +// 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())); + } + + // --------------------------------------------------------------------------------------------------------------- + // 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); + } + } +} diff --git a/documentation/PanelsBySolid-SourceEvidence-Q4.md b/documentation/PanelsBySolid-SourceEvidence-Q4.md new file mode 100644 index 000000000..04febb4f2 --- /dev/null +++ b/documentation/PanelsBySolid-SourceEvidence-Q4.md @@ -0,0 +1,151 @@ +# 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: `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. +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 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 + 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 (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 + 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; `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, 39 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. + +- 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 + +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 | +| 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 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` + 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). + +## Next step + +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 +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.