diff --git a/OpenNest.Core/Geometry/KeepOutOverlap.cs b/OpenNest.Core/Geometry/KeepOutOverlap.cs new file mode 100644 index 0000000..54ecce8 --- /dev/null +++ b/OpenNest.Core/Geometry/KeepOutOverlap.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; + +namespace OpenNest.Geometry; + +/// +/// Tolerance-free test of material against an axis-aligned keep-out rectangle, for +/// rectangles too small for the tolerance-based overlap machinery (validation offsets, +/// Clipper grids, triangulation and vector equality work at 1e-3 to 1e-5 scale and collapse +/// such a rectangle, PM 6859db53). Positive-area overlap only: flush contact is clear, as +/// everywhere else. +/// +public static class KeepOutOverlap +{ + /// The scale below which tolerance-based overlap tests are not trusted for a + /// keep-out rectangle (the validation outline chord tolerance). + public const double ToleranceFreeBelow = 0.001; + + /// A keep-out narrower than on either axis. + public static bool NeedsToleranceFreeTest(Box keepOut) => + keepOut.Length < ToleranceFreeBelow || keepOut.Width < ToleranceFreeBelow; + + /// + /// 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 + /// inside the material (tested at its centre). + /// + public static bool MaterialEnters(Polygon perimeter, IReadOnlyList holes, Box box) + { + if (RingEnters(perimeter, box)) + return true; + if (holes != null) + foreach (var hole in holes) + if (RingEnters(hole, box)) + return true; + + var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2); + if (!Contains(perimeter, centre)) + return false; + if (holes != null) + foreach (var hole in holes) + if (Contains(hole, centre)) + return false; + return true; + } + + private static bool RingEnters(Polygon ring, Box box) + { + var vertices = ring.Vertices; + var count = vertices.Count; + for (var i = 0; i < count; i++) + { + var next = vertices[(i + 1) % count]; + if (SegmentEntersOpenBox(vertices[i], next, box)) + return true; + } + return false; + } + + /// Liang-Barsky clip against the open rectangle; a positive-length part of the + /// segment must lie strictly inside it. + private static bool SegmentEntersOpenBox(Vector p, Vector q, Box box) + { + var dx = q.X - p.X; + var dy = q.Y - p.Y; + var t0 = 0.0; + var t1 = 1.0; + return Clip(-dx, p.X - box.Left, ref t0, ref t1) + && Clip(dx, box.Right - p.X, ref t0, ref t1) + && Clip(-dy, p.Y - box.Bottom, ref t0, ref t1) + && Clip(dy, box.Top - p.Y, ref t0, ref t1) + && t0 < t1; + } + + /// Restricts [t0, t1] to the parameters where denominator * t < numerator. + private static bool Clip(double denominator, double numerator, ref double t0, ref double t1) + { + if (denominator == 0) + return numerator > 0; + var r = numerator / denominator; + if (denominator < 0) + { + if (r >= t1) + return false; + if (r > t0) + t0 = r; + } + else + { + if (r <= t0) + return false; + if (r < t1) + t1 = r; + } + return true; + } + + /// Even-odd ray cast; exact for a point that is not on the ring (callers only + /// ask after ruling out any edge through the rectangle containing the point). + private static bool Contains(Polygon ring, Vector point) + { + var inside = false; + var vertices = ring.Vertices; + var count = vertices.Count; + for (int i = 0, j = count - 1; i < count; j = i++) + { + var a = vertices[i]; + var b = vertices[j]; + if ((a.Y > point.Y) != (b.Y > point.Y) + && point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X) + inside = !inside; + } + return inside; + } +} diff --git a/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs b/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs new file mode 100644 index 0000000..681ead2 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/TinyClampZoneTests.cs @@ -0,0 +1,134 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.NestingEngines.Default; +using OpenNest.Engine.NestingEngines.Rectangles; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// C16 (PM 6859db53): a clamp zone the job validator accepts may be far smaller than the +/// validation outline tolerance (here 1e-5 square). Every keep-out consumer must check it +/// without throwing, flag a placement whose material overlaps it (plus the D6 clearance), +/// and keep a placement that only touches or clears it clear. Tolerances are unchanged (C5): +/// the expected verdicts below are exact rectangle arithmetic, not the checker's output. +/// +public class TinyClampZoneTests +{ + private const double Tiny = 1e-5; + + private static readonly ClampZone TinyZone = new("tiny", new Box(8, 0, Tiny, Tiny)); + + private static Program SquareProgram() + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(2, 0)); + program.Codes.Add(new LinearMove(2, 2)); + program.Codes.Add(new LinearMove(0, 2)); + program.Codes.Add(new LinearMove(0, 0)); + return program; + } + + private static NestJobPart Square() => + new("square", PartGeometrySnapshot.FromProgram(SquareProgram()), 1, 0, RotationPolicy.Fixed(0)); + + private static NestPlateStock Stock(double bottom) => + new("sheet", new Size(10, 12), null, 0, new Spacing(0, bottom, 0, 0), 1, + new[] { TinyZone }); + + private static NestJob Job(double bottom) => new(new[] { Square() }, new[] { Stock(bottom) }); + + private static IReadOnlyList ClampViolations(NestJob job, NestJobResult result) => + NestLayoutCheck.Violations(job, result).Where(v => v.Contains("clamp zone")).ToList(); + + [Fact] + public void RectanglesResultOnATinyClampIsCheckedWithoutThrowing() + { + // The PM probe: Rectangles places the square flush against the tiny clamp. + var job = Job(0); + NestJobValidator.Validate(job); + var result = new RectanglesNestingEngine().Solve(job); + Assert.Equal(NestJobStatus.Complete, result.Status); + + var violations = ClampViolations(job, result); + + // Independent oracle: the square's footprint shares no positive area with the zone. + var pose = Assert.Single(result.Plates.SelectMany(p => p.Placements)); + var overlapX = System.Math.Min(pose.X + 2, 8 + Tiny) - System.Math.Max(pose.X, 8); + var overlapY = System.Math.Min(pose.Y + 2, Tiny) - System.Math.Max(pose.Y, 0); + var expectOverlap = overlapX > 0 && overlapY > 0; + Assert.Equal(expectOverlap, violations.Count > 0); + } + + [Fact] + public void DefaultSolvesATinyClampJobToAValidResult() + { + var job = Job(0); + var result = new DefaultNestingEngine().Solve(job); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Empty(NestLayoutCheck.Violations(job, result)); + } + + // (bottom edge spacing, x, y, material overlaps the zone inflated by the clearance). + // The keep-out is [8 - c, 8 + 1e-5 + c] x [-c, 1e-5 + c] with c = bottom - 0.0005 + // (the shared spacing slack); the 2x2 square spans [x, x+2] x [y, y+2]. + [Theory] + [InlineData(0.0, 7.5, 0.0, true)] // covers the zone + [InlineData(0.0, 6.0, 0.0, false)] // right edge flush on the zone's left side + [InlineData(0.0, 8.00001, 0.0, false)] // left edge flush on the zone's right side + [InlineData(0.0, 8.0, Tiny, false)] // bottom edge flush on the zone's top + [InlineData(0.0, 3.0, 0.0, false)] // well clear + [InlineData(0.25, 7.5, 0.1, true)] // over the zone, inside the clearance band + [InlineData(0.25, 8.0, 0.24, true)] // above the zone but inside the clearance + [InlineData(0.25, 8.25001, 0.1, false)] // exactly at the clearance to the right + [InlineData(0.25, 7.0, 0.25001, false)] // exactly at the clearance above + [InlineData(0.25, 3.0, 0.25, false)] // well clear + public void LayoutCheckJudgesPlacementsAgainstATinyClamp(double bottom, double x, double y, + bool overlaps) + { + var job = Job(bottom); + NestJobValidator.Validate(job); + var stock = job.Plates[0]; + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("square", 0, x, y, 0) }) }, + Array.Empty(), Array.Empty()); + + var violations = ClampViolations(job, result); + + Assert.Equal(overlaps, violations.Count > 0); + } + + private static Program FrameProgram() + { + // 4x4 square with a 2x2 hole in its middle. + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(4, 0)); + program.Codes.Add(new LinearMove(4, 4)); + program.Codes.Add(new LinearMove(0, 4)); + program.Codes.Add(new LinearMove(0, 0)); + program.Codes.Add(new RapidMove(1, 1)); + program.Codes.Add(new LinearMove(1, 3)); + program.Codes.Add(new LinearMove(3, 3)); + program.Codes.Add(new LinearMove(3, 1)); + program.Codes.Add(new LinearMove(1, 1)); + return program; + } + + // The tiny zone inside the frame's hole is clear; inside its material it is not. + [Theory] + [InlineData(6.0, -2.0, false)] // hole spans [7, 9] x [-1, 1] around the zone + [InlineData(6.0, 0.0, true)] // the frame's bottom bar covers the zone + public void LayoutCheckSeesHolesAroundATinyClamp(double x, double y, bool overlaps) + { + var frame = new NestJobPart("frame", PartGeometrySnapshot.FromProgram(FrameProgram()), 1, 0, + RotationPolicy.Fixed(0)); + var job = new NestJob(new[] { frame }, new[] { Stock(0) }); + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, job.Plates[0], new[] { new NestJobPlacement("frame", 0, x, y, 0) }) }, + Array.Empty(), Array.Empty()); + + Assert.Equal(overlaps, ClampViolations(job, result).Count > 0); + } +} diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index dbad50a..5bd6ddd 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -187,7 +187,7 @@ public static class NestLayoutCheck continue; var material = Outline(part, 0); - if (material == null || !Overlaps(material, keepOutOutline)) + if (material == null || !OverlapsKeepOut(material, keepOutOutline, keepOut)) continue; result.Add( @@ -198,17 +198,33 @@ public static class NestLayoutCheck } } + /// + /// The keep-out as an exact four-vertex ring. Routing a synthetic rectangle through the + /// tolerance-based validation offset collapses a zone smaller than that tolerance to + /// nothing (PM 6859db53), so the rectangle is never offset or flattened here. + /// private static PartOutline RectangleOutline(Box box) { - var ring = new List - { - new Line(box.Left, box.Bottom, box.Right, box.Bottom), - new Line(box.Right, box.Bottom, box.Right, box.Top), - new Line(box.Right, box.Top, box.Left, box.Top), - new Line(box.Left, box.Top, box.Left, box.Bottom), - }; - var profile = new ShapeProfile(ring); - return Outline(profile, new Vector(0, 0), 0); + var perimeter = new Polygon(); + perimeter.Vertices.Add(new Vector(box.Left, box.Bottom)); + perimeter.Vertices.Add(new Vector(box.Right, box.Bottom)); + perimeter.Vertices.Add(new Vector(box.Right, box.Top)); + perimeter.Vertices.Add(new Vector(box.Left, box.Top)); + perimeter.Vertices.Add(new Vector(box.Left, box.Bottom)); + perimeter.UpdateBounds(); + return new PartOutline { Perimeter = perimeter, Holes = new List() }; + } + + /// + /// 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. + /// + private static bool OverlapsKeepOut(PartOutline material, PartOutline keepOutOutline, Box keepOut) + { + if (KeepOutOverlap.NeedsToleranceFreeTest(keepOut) || keepOutOutline.Triangles == null) + return KeepOutOverlap.MaterialEnters(material.Perimeter, material.Holes, keepOut); + return Overlaps(material, keepOutOutline); } ///