mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 18:21:55 -04:00
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:
1 parent
3f0a2703fd
commit
8f0c0692fd
3 files changed
+274
-10
No files matched your search
@@ -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 < 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user