From 6908e009694f72ca1f325ff67a903d5b7f194a60 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 17:42:24 -0400 Subject: [PATCH] fix(clamps): judge tiny clamps against the part's real material The tolerance-free rectangle test was fed approximated material: the layout check passed it the 1e-4-grid validation outline (a strip of real material that wholly contains a 1e-5 clamp could vanish), and interactive fill and the overlap check passed 0.001 inscribed arc chords (a clamp inside a disk but outside its chords read as clear). All three now flatten the native contours at a quarter of the clamp's smaller side, lines kept exact, so the arc-chord sliver can never hold the clamp. Ordinary clamps are unchanged. PM 407f5eb7-329f-41e4-947e-64ad52aa4034 --- OpenNest.Core/Diagnostics/OverlapMaterial.cs | 10 ++- .../Diagnostics/PlateOverlapAnalyzer.cs | 7 +- OpenNest.Core/Geometry/KeepOutOverlap.cs | 29 ++++++++ .../Jobs/TinyClampZoneTests.cs | 70 +++++++++++++++++++ OpenNest.Engine/Jobs/InteractiveClamps.cs | 3 +- OpenNest.Engine/Jobs/NestLayoutCheck.cs | 19 +++-- .../PlateOverlapAnalyzerClampZoneTests.cs | 34 +++++++++ 7 files changed, 161 insertions(+), 11 deletions(-) diff --git a/OpenNest.Core/Diagnostics/OverlapMaterial.cs b/OpenNest.Core/Diagnostics/OverlapMaterial.cs index 37f6615..94651f2 100644 --- a/OpenNest.Core/Diagnostics/OverlapMaterial.cs +++ b/OpenNest.Core/Diagnostics/OverlapMaterial.cs @@ -10,7 +10,13 @@ namespace OpenNest.Diagnostics; /// Request-local, validated single-outer material. Never exposed to report consumers. internal sealed record OverlapMaterial(Polygon Outer, List Holes) { - internal static OverlapMaterial Read(List entities, CancellationToken cancellationToken) + internal static OverlapMaterial Read(List entities, CancellationToken cancellationToken) => + Read(entities, cancellationToken, PlateOverlapAnalyzer.ChordTolerance); + + /// As with a caller-chosen + /// chord tolerance (a finer one for keep-outs smaller than the default). + internal static OverlapMaterial Read(List entities, CancellationToken cancellationToken, + double chordTolerance) { // ShapeBuilder can reverse entities while chaining. Own a fresh copy for each analysis. var shapes = ShapeBuilder.GetShapes(entities.Select(entity => entity.Clone())); @@ -21,7 +27,7 @@ internal sealed record OverlapMaterial(Polygon Outer, List Holes) { cancellationToken.ThrowIfCancellationRequested(); ValidateChain(shape); - var polygon = shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance); + var polygon = shape.ToPolygonWithTolerance(chordTolerance); // The analytic chain was validated above. Normalize its sampled seam (e.g. // sin(2*pi) is not exactly zero), rather than adding a spurious microscopic edge. if (polygon.IsClosed()) diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs index 4f65e86..0ecbf76 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs @@ -280,7 +280,12 @@ public static class PlateOverlapAnalyzer // (negligibly small) clamp rectangle as the overlap fragment. if (KeepOutOverlap.NeedsToleranceFreeTest(zone)) { - if (KeepOutOverlap.MaterialEnters(part.Material.Outer, part.Material.Holes, zone)) + // Re-read the native contours flattened finely enough for this clamp: + // the shared 0.001 chords can hide material between an arc and its chord. + var fine = OverlapMaterial + .Read(part.Input.Source.Entities, cancellationToken, KeepOutOverlap.FlatteningTolerance(zone)) + .Transform(part.Input.Rotation, part.Input.Location); + if (KeepOutOverlap.MaterialEnters(fine.Outer, fine.Holes, zone)) findings.Add(TinyClampFinding(part, clamp)); continue; } diff --git a/OpenNest.Core/Geometry/KeepOutOverlap.cs b/OpenNest.Core/Geometry/KeepOutOverlap.cs index 54ecce8..d6e0c65 100644 --- a/OpenNest.Core/Geometry/KeepOutOverlap.cs +++ b/OpenNest.Core/Geometry/KeepOutOverlap.cs @@ -19,6 +19,35 @@ public static class KeepOutOverlap public static bool NeedsToleranceFreeTest(Box keepOut) => keepOut.Length < ToleranceFreeBelow || keepOut.Width < ToleranceFreeBelow; + /// + /// Chord tolerance for flattening real part material against : + /// a quarter of its smaller side (at most ), so the + /// sliver between an arc and its chord can never hold the whole rectangle - any real + /// overlap then shows up as a polygon edge through it or its centre inside. + /// + public static double FlatteningTolerance(Box keepOut) => + System.Math.Min(ToleranceFreeBelow, System.Math.Min(keepOut.Length, keepOut.Width) / 4); + + /// + /// for a part's native contours (lines kept exactly, arcs + /// flattened at ), translated by . + /// Never fed through a grid-rounding offset or a coarse flattening. + /// + public static bool ProfileEnters(ShapeProfile profile, Vector offset, Box keepOut) + { + var tolerance = FlatteningTolerance(keepOut); + var perimeter = profile.Perimeter.ToPolygonWithTolerance(tolerance); + perimeter.Offset(offset); + var holes = new List(profile.Cutouts.Count); + foreach (var cutout in profile.Cutouts) + { + var hole = cutout.ToPolygonWithTolerance(tolerance); + hole.Offset(offset); + holes.Add(hole); + } + return MaterialEnters(perimeter, holes, keepOut); + } + /// /// True when the material (perimeter minus holes) shares positive area with the open /// rectangle: some boundary edge runs through its interior, or its interior lies wholly diff --git a/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs b/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs index b003d98..e444758 100644 --- a/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs +++ b/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs @@ -186,4 +186,74 @@ public class TinyClampZoneTests Assert.False(EntersTinyZone(mover), $"pushed to ({mover.Location.X:R},{mover.Location.Y:R})"); } + // PM 407f5eb7: the tolerance-free test must read the part's real material, not the + // 1e-4-grid validation outline or a 0.001 arc flattening. + private static Program RectProgram(double w, double h) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(w, 0); + program.LineTo(w, h); + program.LineTo(0, h); + program.LineTo(0, 0); + return program; + } + + private static Program DiskProgram() + { + // Radius 2 centred at (10, 10), two absolute CCW half circles. + var program = new Program(); + program.MoveTo(12, 10); + program.ArcTo(8, 10, 10, 10, RotationType.CCW); + program.ArcTo(12, 10, 10, 10, RotationType.CCW); + return program; + } + + private static Box DiskBox(double radius) + { + var cx = 10 + radius * System.Math.Cos(0.02); + var cy = 10 + radius * System.Math.Sin(0.02); + return new Box(cx - 0.000005, cy - 0.000005, Tiny, Tiny); + } + + public static TheoryData RealMaterialRows => new() + { + { "rect-8.00004", new Box(8.00002, 0.5, Tiny, Tiny), true }, // wholly inside the part + { "rect-8.00004", new Box(8.00005, 0.5, Tiny, Tiny), false }, // just beyond its edge + { "rect-2.00004", new Box(2.000032, 1, 5e-6, Tiny), true }, + { "disk", DiskBox(1.9998), true }, // inside the true arc, outside its 0.001 chords + { "disk", DiskBox(2.0002), false }, // just outside the true arc + }; + + private static Program MaterialProgram(string name) => name switch + { + "rect-8.00004" => RectProgram(8.00004, 2), + "rect-2.00004" => RectProgram(2.00004, 2), + _ => DiskProgram(), + }; + + [Theory] + [MemberData(nameof(RealMaterialRows))] + public void LayoutCheckReadsRealMaterialAroundATinyClamp(string material, Box zone, bool overlaps) + { + var program = MaterialProgram(material); + var stock = new NestPlateStock("sheet", new Size(20, 20), null, 0, default, 1, + new[] { new ClampZone("tiny", zone) }); + var job = new NestJob(new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1, 0, + RotationPolicy.Fixed(0)) }, new[] { stock }); + NestJobValidator.Validate(job); + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) }, + Array.Empty(), Array.Empty()); + + Assert.Equal(overlaps, ClampViolations(job, result).Count > 0); + } + + [Theory] + [MemberData(nameof(RealMaterialRows))] + public void InteractiveKeepOutTestReadsRealMaterial(string material, Box zone, bool overlaps) + { + var part = new Part(new Drawing(material, MaterialProgram(material))); + Assert.Equal(overlaps, InteractiveClamps.MaterialInKeepOut(part, zone)); + } } diff --git a/OpenNest.Engine/Jobs/InteractiveClamps.cs b/OpenNest.Engine/Jobs/InteractiveClamps.cs index 233e74b..525de3c 100644 --- a/OpenNest.Engine/Jobs/InteractiveClamps.cs +++ b/OpenNest.Engine/Jobs/InteractiveClamps.cs @@ -100,8 +100,7 @@ internal static class InteractiveClamps // A keep-out below the polygon tolerance collapses in the tolerant test and would // read as clear (PM b0062c73); judge it tolerance-free instead. if (KeepOutOverlap.NeedsToleranceFreeTest(keepOut)) - return KeepOutOverlap.MaterialEnters(partProfile.Perimeter.ToPolygonWithTolerance(0.001), - HolePolygons(partProfile), keepOut); + return KeepOutOverlap.ProfileEnters(partProfile, new Vector(0, 0), keepOut); var zoneProfile = new ShapeProfile(RectangleEntities(keepOut)); if (zoneProfile.Perimeter == null) return true; diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index 5bd6ddd..aa08d62 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -186,8 +186,7 @@ public static class NestLayoutCheck if (!BoxesTouch(keepOut, MaterialBounds(part))) continue; - var material = Outline(part, 0); - if (material == null || !OverlapsKeepOut(material, keepOutOutline, keepOut)) + if (!OverlapsKeepOut(part, keepOutOutline, keepOut)) continue; result.Add( @@ -218,13 +217,21 @@ public static class NestLayoutCheck /// /// Material against a keep-out rectangle. A rectangle below the outline tolerance, or one /// the triangulated test cannot prepare, is judged by the tolerance-free rectangle test - /// instead: the tolerant path would call real overlap clear. + /// on the part's native contours (not the grid-rounded validation outline): the + /// tolerant path would call real overlap clear. A part without material is skipped. /// - private static bool OverlapsKeepOut(PartOutline material, PartOutline keepOutOutline, Box keepOut) + private static bool OverlapsKeepOut(Part part, PartOutline keepOutOutline, Box keepOut) { if (KeepOutOverlap.NeedsToleranceFreeTest(keepOut) || keepOutOutline.Triangles == null) - return KeepOutOverlap.MaterialEnters(material.Perimeter, material.Holes, keepOut); - return Overlaps(material, keepOutOutline); + { + var entities = ConvertProgram.ToGeometry(part.Program) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + var profile = entities.Count == 0 ? null : new ShapeProfile(entities); + return profile?.Perimeter != null && KeepOutOverlap.ProfileEnters(profile, part.Location, keepOut); + } + var material = Outline(part, 0); + return material != null && Overlaps(material, keepOutOutline); } /// diff --git a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs index 461d44e..e49e35b 100644 --- a/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs +++ b/OpenNest.Tests/Diagnostics/PlateOverlapAnalyzerClampZoneTests.cs @@ -214,6 +214,40 @@ public class PlateOverlapAnalyzerClampZoneTests Assert.Equal(overlaps, report.ClampFindings.Any(f => f.PartBName.Contains("tiny"))); } + // PM 407f5eb7: real material, not the 0.001 arc flattening, decides a tiny clamp. + [Theory] + [InlineData("rect", 8.00002, 0.5, true)] + [InlineData("rect", 8.00005, 0.5, false)] + [InlineData("disk", 1.9998, 0, true)] + [InlineData("disk", 2.0002, 0, false)] + public void TinyClampIsJudgedAgainstRealMaterial(string material, double a, double b, bool overlaps) + { + Program program; + Box zone; + if (material == "rect") + { + program = Rect(8.00004, 2); + zone = new Box(a, b, 1e-5, 1e-5); + } + else + { + program = new Program(); + program.MoveTo(12, 10); + program.ArcTo(8, 10, 10, 10, RotationType.CCW); + program.ArcTo(12, 10, 10, 10, RotationType.CCW); + var cx = 10 + a * System.Math.Cos(0.02); + var cy = 10 + a * System.Math.Sin(0.02); + zone = new Box(cx - 0.000005, cy - 0.000005, 1e-5, 1e-5); + } + var parts = new List { new(new Drawing(material, program), new Vector(0, 0)) }; + + var report = PlateOverlapAnalyzer.Analyze( + PlateOverlapAnalyzer.Capture(parts, new[] { new ClampZone("tiny", zone) })); + + Assert.True(report.IsComplete, string.Join("; ", report.Issues.Select(issue => issue.Message))); + Assert.Equal(overlaps, report.ClampFindings.Count > 0); + } + private static string Describe(PlateOverlapReport report) => string.Join(";", report.Pairs.Select(p => $"{p.PartAId}/{p.PartBId}:{p.Area:R}")) + "|"