mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-04 09:11:34 -05:00
The repo mixed variants such as center/centre and color/colour, so a name could be spelled either way and searches missed half the hits. Standardize on the US spelling, matching .NET's own APIs (Color, Center). Wire values owned by other systems keep their spelling: the material ledger's "Cancelled" hold status and GitHub's "cancelled" job status. No behavior change beyond the spelling of a few status messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
9.1 KiB
C#
218 lines
9.1 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Clipper2Lib;
|
|
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 neighbor 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;
|
|
if (stock.Outline == null)
|
|
return true;
|
|
// The circumscribed perimeter must lie in the outline's usable area; holes are inside it.
|
|
var perimeter = ClipperBridge.ToPath(
|
|
ClipperBridge.Flatten(shape.Perimeter, NestTolerances.ValidationOutline, circumscribe: true), positive: true);
|
|
return stock.OutlineHolds(new PathsD { perimeter }, Epsilon);
|
|
}
|
|
|
|
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; }
|
|
}
|
|
}
|