mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-29 04:51:05 -04:00
NestLayoutCheck rejected Fill layouts whose interlocked arcs sat exactly at the part spacing: its SpacingOutline circumscribes arcs by up to 0.001 each, more than the 0.0005 slack, so facing arcs overlapped. The runner's placement validator measured inscribed 0.001 polygons instead and so accepted gaps up to ~0.002 under the slack. Both now find candidates with SpacingOutline and confirm them on NativeContours, the native contours flattened at a twentieth of the slack (moved out of PartSpacingReview, which keeps using them). The runner calls NestLayoutCheck.KeepsSpacing, the same outline, tie rule and confirmation, so the two validators accept exactly the same pairs. Clears stays unconfirmed and stricter for engine certification. No tolerance changed. FixedStrategyEngine_IrregularJob pins now assert LayoutAssert.Valid with unchanged digests; the NativeU 70-part grid's 28 reports were the same cause and its test now requires none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
8.8 KiB
C#
212 lines
8.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using OpenNest.Engine.Jobs.Placement;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Engine.Jobs;
|
|
|
|
/// <summary>Validates a trial against immutable job geometry before the runner commits accounting.</summary>
|
|
internal static class NestJobPlacementValidator
|
|
{
|
|
private const double Epsilon = 0.0000001;
|
|
// Flattening for the placement overlap check: the same 0.001 Part.Intersects uses. Part
|
|
// spacing is judged by NestLayoutCheck.KeepsSpacing instead, so this validator and the
|
|
// layout check accept exactly the same pairs.
|
|
private const double PlacementChordTolerance = NestTolerances.ValidationOutline;
|
|
|
|
internal static void ValidateCandidate(
|
|
PlateCandidate candidate,
|
|
NestPlateStock stock,
|
|
IReadOnlyDictionary<string, int> remaining,
|
|
IReadOnlyDictionary<string, NestJobPart> parts
|
|
)
|
|
{
|
|
if (candidate == null)
|
|
throw new InvalidOperationException("The plate nester returned a null candidate.");
|
|
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
var placed = new List<ShapeTopology>();
|
|
// Spacing outlines per placement, prepared only once a neighbour comes near.
|
|
var poses = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
|
|
var spaced = new List<NestLayoutCheck.PreparedPlacement>();
|
|
var geometries = new Dictionary<string, JobPartGeometry>(StringComparer.Ordinal);
|
|
var sources = new Dictionary<string, ShapeTopology>(StringComparer.Ordinal);
|
|
foreach (var placement in candidate.Placements)
|
|
{
|
|
if (
|
|
placement.PartId == null
|
|
|| !remaining.TryGetValue(placement.PartId, out var available)
|
|
|| !parts.TryGetValue(placement.PartId, out var part)
|
|
)
|
|
throw new InvalidOperationException(
|
|
"Candidate references an unknown requirement ID."
|
|
);
|
|
if (
|
|
!double.IsFinite(placement.X)
|
|
|| !double.IsFinite(placement.Y)
|
|
|| !double.IsFinite(placement.Rotation)
|
|
)
|
|
throw new InvalidOperationException("Candidate poses must be finite.");
|
|
counts.TryGetValue(placement.PartId, out var count);
|
|
if (count >= available)
|
|
throw new InvalidOperationException("Candidate overproduces a requirement.");
|
|
if (!part.Rotation.Allows(placement.Rotation))
|
|
throw new InvalidOperationException(
|
|
"Candidate rotation is not allowed for the requirement."
|
|
);
|
|
|
|
if (!sources.TryGetValue(placement.PartId, out var source))
|
|
{
|
|
var geometry = JobPartGeometry.Read(part.Geometry);
|
|
geometries[placement.PartId] = geometry;
|
|
sources[placement.PartId] = source = new ShapeTopology(geometry.Perimeter,
|
|
new List<Shape>(geometry.Cutouts));
|
|
}
|
|
var shape = Transform(source, placement);
|
|
if (!FitsWorkArea(shape, stock))
|
|
throw new InvalidOperationException(
|
|
"Candidate placement falls outside the usable stock area."
|
|
);
|
|
poses.Add((geometries[placement.PartId], placement));
|
|
spaced.Add(null);
|
|
for (var k = 0; k < placed.Count; k++)
|
|
{
|
|
var other = placed[k];
|
|
// Analytic contour bounds give a conservative lower bound on clearance.
|
|
// Do not polygonize or compare every hole edge for distant placements.
|
|
if (BoundsDistance(shape.Perimeter.BoundingBox, other.Perimeter.BoundingBox)
|
|
>= stock.PartSpacing && !shape.Perimeter.BoundingBox.Intersects(other.Perimeter.BoundingBox))
|
|
continue;
|
|
if (Overlaps(shape, other))
|
|
throw new InvalidOperationException("Candidate placements overlap.");
|
|
// Earlier placement first, so ties inflate it as the layout check's sweep does.
|
|
if (stock.PartSpacing > 0
|
|
&& !NestLayoutCheck.KeepsSpacing(Spaced(k), Spaced(placed.Count)))
|
|
throw new InvalidOperationException(
|
|
"Candidate placements violate required part spacing."
|
|
);
|
|
}
|
|
|
|
placed.Add(shape);
|
|
counts[placement.PartId] = count + 1;
|
|
}
|
|
|
|
NestLayoutCheck.PreparedPlacement Spaced(int index) =>
|
|
spaced[index] ??= NestLayoutCheck.Prepare(poses[index].Geometry, poses[index].Pose,
|
|
stock.PartSpacing);
|
|
}
|
|
|
|
private static ShapeTopology Transform(ShapeTopology source, NestJobPlacement placement)
|
|
{
|
|
var perimeter = TransformContour(source.Perimeter, placement);
|
|
var cutouts = new List<Shape>(source.Cutouts.Count);
|
|
foreach (var cutout in source.Cutouts)
|
|
cutouts.Add(TransformContour(cutout, placement));
|
|
return new ShapeTopology(perimeter, cutouts);
|
|
}
|
|
|
|
private static Shape TransformContour(Shape source, NestJobPlacement placement)
|
|
{
|
|
var contour = (Shape)source.Clone();
|
|
contour.Rotate(placement.Rotation);
|
|
contour.Offset(placement.X, placement.Y);
|
|
return contour;
|
|
}
|
|
|
|
private static bool FitsWorkArea(ShapeTopology shape, NestPlateStock stock)
|
|
{
|
|
var workArea = stock.WorkArea;
|
|
if (!FitsWorkArea(shape.Perimeter, workArea))
|
|
return false;
|
|
foreach (var cutout in shape.Cutouts)
|
|
if (!FitsWorkArea(cutout, workArea))
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
private static bool FitsWorkArea(Shape contour, Box workArea)
|
|
{
|
|
var bounds = contour.BoundingBox;
|
|
return bounds.Left >= workArea.Left - Epsilon
|
|
&& bounds.Right <= workArea.Right + Epsilon
|
|
&& bounds.Bottom >= workArea.Bottom - Epsilon
|
|
&& bounds.Top <= workArea.Top + Epsilon;
|
|
}
|
|
|
|
private static bool Overlaps(ShapeTopology left, ShapeTopology right)
|
|
{
|
|
var leftPoly = left.Contours[0].Polygon;
|
|
var rightPoly = right.Contours[0].Polygon;
|
|
if (!leftPoly.BoundingBox.Intersects(rightPoly.BoundingBox))
|
|
return false;
|
|
// True material overlap requires shared interior area, not boundary touching.
|
|
// Edge/corner contact (zero clearance) is a valid placement when part spacing is zero.
|
|
// Collision checks this by clipping triangulated polygons and rejecting zero-area
|
|
// slivers, so it catches containment and small corner intersections that a witness
|
|
// probe can miss, while contact stays legal; cutouts are subtracted from both sides.
|
|
return Collision.HasOverlap(
|
|
leftPoly,
|
|
rightPoly,
|
|
left.CutoutPolygons,
|
|
right.CutoutPolygons
|
|
);
|
|
}
|
|
|
|
private static double BoundsDistance(Box left, Box right)
|
|
{
|
|
var x = System.Math.Max(0, System.Math.Max(left.Left - right.Right, right.Left - left.Right));
|
|
var y = System.Math.Max(0, System.Math.Max(left.Bottom - right.Top, right.Bottom - left.Top));
|
|
return System.Math.Sqrt(x * x + y * y);
|
|
}
|
|
|
|
private sealed class ShapeTopology(Shape perimeter, List<Shape> cutouts)
|
|
{
|
|
private Contour[] contours;
|
|
private List<Polygon> cutoutPolygons;
|
|
|
|
internal Shape Perimeter { get; } = perimeter;
|
|
internal List<Shape> Cutouts { get; } = cutouts;
|
|
|
|
/// <summary>The perimeter first, then the cutouts, each flattened once on first use.</summary>
|
|
internal Contour[] Contours
|
|
{
|
|
get
|
|
{
|
|
if (contours != null)
|
|
return contours;
|
|
var result = new Contour[Cutouts.Count + 1];
|
|
result[0] = new Contour(Perimeter);
|
|
for (var i = 0; i < Cutouts.Count; i++)
|
|
result[i + 1] = new Contour(Cutouts[i]);
|
|
return contours = result;
|
|
}
|
|
}
|
|
|
|
internal List<Polygon> CutoutPolygons
|
|
{
|
|
get
|
|
{
|
|
if (cutoutPolygons != null)
|
|
return cutoutPolygons;
|
|
var result = new List<Polygon>(Cutouts.Count);
|
|
for (var i = 1; i < Contours.Length; i++)
|
|
result.Add(Contours[i].Polygon);
|
|
return cutoutPolygons = result;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A contour flattened once, at <see cref="PlacementChordTolerance"/>, for the overlap check
|
|
/// against every other placement.
|
|
/// </summary>
|
|
private sealed class Contour
|
|
{
|
|
internal Contour(Shape shape)
|
|
{
|
|
Polygon = shape.ToPolygonWithTolerance(PlacementChordTolerance);
|
|
}
|
|
|
|
internal Polygon Polygon { get; }
|
|
}
|
|
}
|