Files
OpenNest/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
T
ajandClaude Opus 5.5 dc9e83ef17 perf(jobs): flatten validator contours once, at a 0.001 chord tolerance
The candidate validator flattened every arc into 1000 segments and rebuilt
both parts' polygons and edge lists for every pair it compared, so spacing
checks on filleted parts cost millions of edge pairs each. Validation, not
the fill pipeline, was nearly all of a solve's wall time.

Placed contours are now flattened once with ToPolygonWithTolerance(0.001),
the tolerance the benchmark NestValidator and Part.Intersects already use,
and each part's shape is built once per candidate. Arcs stay inscribed, so
a layout placed exactly at the spacing still passes.

12-nest PEP corpus, Default + StockLadder, --parallel 1: 2820 s -> 227 s.
Every run that finished before gives the same validity, count, plates and
cost. Three StockLadder runs that used to hit the 5-minute timeout now
finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:29:43 -04:00

470 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
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 placement overlap/spacing checks: the same 0.001 the benchmark's
// NestValidator and Part.Intersects use. Arcs are inscribed, so a layout placed exactly at
// the spacing passes; outward arcs may come up to this much closer than the spacing.
private const double PlacementChordTolerance = 0.001;
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>();
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))
sources[placement.PartId] = source = CreateShape(part.Geometry);
var shape = Transform(source, placement);
if (!FitsWorkArea(shape, stock))
throw new InvalidOperationException(
"Candidate placement falls outside the usable stock area."
);
foreach (var other in placed)
{
// 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.");
if (stock.PartSpacing > 0 && Distance(shape, other) < stock.PartSpacing - Epsilon)
throw new InvalidOperationException(
"Candidate placements violate required part spacing."
);
}
placed.Add(shape);
counts[placement.PartId] = count + 1;
}
}
internal static void ValidateGeometry(PartGeometrySnapshot geometry)
{
_ = CreateShape(geometry);
}
private static ShapeTopology CreateShape(PartGeometrySnapshot geometry)
{
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(geometry));
var cutEntities = new List<Entity>();
foreach (var entity in entities)
if (!ReferenceEquals(entity.Layer, SpecialLayers.Rapid))
cutEntities.Add(entity);
var contours = ShapeBuilder.GetShapes(cutEntities);
if (contours.Count == 0)
throw new ArgumentException("Geometry must contain a closed contour.");
var closedEntities = new List<Entity>();
var marks = new List<Shape>();
foreach (var contour in contours)
{
if (contour.IsClosed())
{
ValidateContour(contour);
closedEntities.AddRange(contour.Entities);
}
else
marks.Add(contour);
}
if (closedEntities.Count == 0)
throw new ArgumentException("Geometry must contain a closed outer contour.");
// ShapeProfile selects the outer profile, but does not validate containment and
// treats open chains as cutouts. Only validated closed contours may define material.
var profile = new ShapeProfile(closedEntities);
foreach (var cutout in profile.Cutouts)
ValidateInternalChain(cutout, profile.Perimeter, new List<Shape>());
foreach (var mark in marks)
ValidateMark(mark, profile.Perimeter, profile.Cutouts);
profile.NormalizeWinding();
return new ShapeTopology(profile.Perimeter, profile.Cutouts);
}
private static void ValidateMark(Shape mark, Shape perimeter, List<Shape> holes)
{
const double chordTolerance = 0.00001;
var boundaries = new List<Shape> { perimeter };
boundaries.AddRange(holes);
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
foreach (var entity in mark.Entities)
{
if (entity.Length <= Epsilon || entity is not (Line or Arc))
throw new ArgumentException("Unsupported or degenerate internal mark.");
var parameters = new List<double> { 0, 1 };
foreach (var boundary in boundaries)
{
entity.Intersects(boundary, out var intersections);
foreach (var point in intersections)
AddParameter(point);
// Include endpoints of coincident edges (parallel intersections may be empty).
foreach (var point in boundary.Entities.CollectPoints())
if (entity.ClosestPointTo(point).DistanceTo(point) <= Epsilon)
AddParameter(point);
}
parameters.Sort();
for (var index = 0; index < parameters.Count; index++)
{
Check(PointAt(parameters[index]));
if (index > 0)
Check(PointAt((parameters[index - 1] + parameters[index]) / 2));
}
void AddParameter(Vector point)
{
if (!point.IsValid())
throw new ArgumentException("Indeterminate mark intersection.");
var value = entity is Line line
? line.StartPoint.DistanceTo(point) / line.Length
: Angle.NormalizeRad(
((Arc)entity).IsReversed
? ((Arc)entity).StartAngle - ((Arc)entity).Center.AngleTo(point)
: ((Arc)entity).Center.AngleTo(point) - ((Arc)entity).StartAngle
) / ((Arc)entity).SweepAngle();
if (value >= 0 && value <= 1)
parameters.Add(value);
}
Vector PointAt(double value)
{
if (entity is Line line)
return line.StartPoint + (line.EndPoint - line.StartPoint) * value;
var arc = (Arc)entity;
var angle = arc.StartAngle + (arc.IsReversed ? -1 : 1) * arc.SweepAngle() * value;
return arc.Center
+ new Vector(System.Math.Cos(angle), System.Math.Sin(angle)) * arc.Radius;
}
void Check(Vector point)
{
for (var index = 0; index < boundaries.Count; index++)
{
// Exact analytic boundary contact is allowed; near-boundary uncertainty is not.
var onBoundary = false;
foreach (var edge in boundaries[index].Entities)
if (edge.ClosestPointTo(point).DistanceTo(point) <= Epsilon)
onBoundary = true;
if (onBoundary)
continue;
foreach (var edge in polygons[index].ToLines())
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
throw new ArgumentException(
"Internal mark is too close to a material boundary."
);
var inside = StrictlyInside(polygons[index], point);
if (index == 0 ? !inside : inside)
throw new ArgumentException(
"Open geometry leaves the closed material region."
);
}
}
}
}
private static void ValidateInternalChain(Shape chain, Shape perimeter, List<Shape> holes)
{
// A connected analytic entity cannot leave material without crossing its boundary.
// Reject contact too: conservative, rather than guessing at tangent/collinear cuts.
// The witness point is farther than the polygonization error from every boundary.
const double chordTolerance = 0.00001;
var boundaries = new List<Shape> { perimeter };
boundaries.AddRange(holes);
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
foreach (var entity in chain.Entities)
{
if (entity.Length <= Epsilon)
throw new ArgumentException("Geometry contains a zero-length internal edge.");
var point = entity switch
{
Line line => line.StartPoint,
Arc arc => arc.StartPoint(),
Circle circle => circle.Center.Offset(circle.Radius, 0),
_ => throw new ArgumentException("Unsupported internal geometry."),
};
if (!StrictlyInside(polygons[0], point))
throw new ArgumentException(
"Open or disconnected geometry lies outside the closed perimeter."
);
for (var index = 0; index < boundaries.Count; index++)
{
if (index > 0 && polygons[index].ContainsPoint(point))
throw new ArgumentException("Internal geometry lies in a cutout.");
foreach (var edge in polygons[index].ToLines())
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
throw new ArgumentException(
"Internal geometry is too close to a material boundary."
);
if (entity.Intersects(boundaries[index]))
throw new ArgumentException(
"Internal geometry crosses or touches a material boundary."
);
}
}
}
private static void ValidateContour(Shape contour)
{
if (!contour.IsClosed())
throw new ArgumentException("Geometry must contain closed contours with usable edges.");
foreach (var entity in contour.Entities)
if (entity.Length <= Epsilon)
throw new ArgumentException("Geometry contains a zero-length edge.");
if (contour.Area() <= Epsilon)
throw new ArgumentException("Geometry must contain non-degenerate contours.");
}
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 = WorkArea(stock);
if (!FitsWorkArea(shape.Perimeter, workArea))
return false;
foreach (var cutout in shape.Cutouts)
if (!FitsWorkArea(cutout, workArea))
return false;
return true;
}
private static Box WorkArea(NestPlateStock stock)
{
var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
return new Box(
left + stock.EdgeSpacing.Left,
bottom + stock.EdgeSpacing.Bottom,
stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right,
stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top
);
}
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
);
}
/// <summary>
/// Winding-number point-in-polygon. Returns false for points on an edge or vertex.
/// </summary>
private static bool StrictlyInside(Polygon polygon, Vector point)
{
var n = polygon.IsClosed() ? polygon.Vertices.Count - 1 : polygon.Vertices.Count;
if (n < 3)
return false;
var winding = 0;
for (var i = 0; i < n; i++)
{
var p1 = polygon.Vertices[i];
var p2 = polygon.Vertices[(i + 1) % n];
if (OnSegment(p1, p2, point))
return false;
if (p1.Y <= point.Y)
{
if (p2.Y > point.Y && IsLeft(p1, p2, point) > 0)
winding++;
}
else if (p2.Y <= point.Y && IsLeft(p1, p2, point) < 0)
{
winding--;
}
}
return winding != 0;
}
private static bool OnSegment(Vector a, Vector b, Vector p)
{
var cross = (b.X - a.X) * (p.Y - a.Y) - (b.Y - a.Y) * (p.X - a.X);
if (!cross.IsEqualTo(0.0))
return false;
return System.Math.Min(a.X, b.X) - Epsilon <= p.X
&& p.X <= System.Math.Max(a.X, b.X) + Epsilon
&& System.Math.Min(a.Y, b.Y) - Epsilon <= p.Y
&& p.Y <= System.Math.Max(a.Y, b.Y) + Epsilon;
}
private static double IsLeft(Vector p1, Vector p2, Vector p) =>
(p2.X - p1.X) * (p.Y - p1.Y) - (p2.Y - p1.Y) * (p.X - p1.X);
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 static double Distance(ShapeTopology left, ShapeTopology right)
{
var result = double.PositiveInfinity;
foreach (var leftContour in left.Contours)
foreach (var rightContour in right.Contours)
if (BoundsDistance(leftContour.Bounds, rightContour.Bounds) < result)
result = System.Math.Min(
result,
BoundaryDistance(leftContour.Lines, rightContour.Lines)
);
return result;
}
private static double BoundaryDistance(List<Line> left, List<Line> right)
{
var result = double.PositiveInfinity;
foreach (var leftLine in left)
{
foreach (var rightLine in right)
{
if (leftLine.Intersects(rightLine))
return 0;
result = System.Math.Min(
result,
leftLine.ClosestPointTo(rightLine.StartPoint).DistanceTo(rightLine.StartPoint)
);
result = System.Math.Min(
result,
leftLine.ClosestPointTo(rightLine.EndPoint).DistanceTo(rightLine.EndPoint)
);
result = System.Math.Min(
result,
rightLine.ClosestPointTo(leftLine.StartPoint).DistanceTo(leftLine.StartPoint)
);
result = System.Math.Min(
result,
rightLine.ClosestPointTo(leftLine.EndPoint).DistanceTo(leftLine.EndPoint)
);
}
}
return result;
}
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 and
/// spacing checks against every other placement.
/// </summary>
private sealed class Contour
{
internal Contour(Shape shape)
{
Polygon = shape.ToPolygonWithTolerance(PlacementChordTolerance);
Bounds = Polygon.BoundingBox;
Lines = Polygon.ToLines();
}
internal Box Bounds { get; }
internal Polygon Polygon { get; }
internal List<Line> Lines { get; }
}
}