fix(validation): check tiny clamp zones without outline collapse

A clamp zone smaller than the validation outline tolerance went through
ClipperBridge.OffsetForValidation, collapsed to a null outline and made
NestLayoutCheck (and Default scoring) throw. The keep-out is now an exact
four-vertex ring, and a keep-out below 0.001 on either axis is judged by a
tolerance-free rectangle test (KeepOutOverlap): positive-area overlap only,
hole-aware, never a silent clear. Tolerances are unchanged.

PM 6859db53-e39b-4135-85c6-4967037cf880
This commit is contained in:
aj committed 2026-10-13 18:15:49 -04:00
1 parent 3f0a2703fd
commit 8f0c0692fd
3 files changed
+274 -10

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
using System.Collections.Generic;
namespace OpenNest.Geometry;
/// <summary>
/// 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.
/// </summary>
public static class KeepOutOverlap
{
/// <summary>The scale below which tolerance-based overlap tests are not trusted for a
/// keep-out rectangle (the validation outline chord tolerance).</summary>
public const double ToleranceFreeBelow = 0.001;
/// <summary>A keep-out narrower than <see cref="ToleranceFreeBelow"/> on either axis.</summary>
public static bool NeedsToleranceFreeTest(Box keepOut) =>
keepOut.Length < ToleranceFreeBelow || keepOut.Width < ToleranceFreeBelow;
/// <summary>
/// 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).
/// </summary>
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> 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;
}
/// <summary>Liang-Barsky clip against the open rectangle; a positive-length part of the
/// segment must lie strictly inside it.</summary>
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;
}
/// <summary>Restricts [t0, t1] to the parameters where <c>denominator * t &lt; numerator</c>.</summary>
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;
}
/// <summary>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).</summary>
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;
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<string> 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<PartFulfillment>(), Array.Empty<StockUsage>());
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<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.Equal(overlaps, ClampViolations(job, result).Count > 0);
}
}
+26 -10
View File
@@ -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
}
}
/// <summary>
/// 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.
/// </summary>
private static PartOutline RectangleOutline(Box box)
{
var ring = new List<Geometry.Entity>
{
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<Polygon>() };
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>