mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-07 01:05:44 -05:00
Slice 3 of common-line cutting. On costly plate (N26-295: 3/16 RA310S at about $2,500 a sheet) pairing parts one kerf apart fits the part the spacing cannot: the last plate's 38 parts fit on one 48x120 with Auto Nest only with pairs. - Drawing Properties gains "Allow common line" (Drawing.AllowCommonLine, off by default because customer edge-quality needs vary). The nest's CommonLineUsage (Off / When it adds parts / Whenever available) is set in Auto Nest and seeded from Tools > Nest Defaults; both persist (nest.json, defaults.json). - NestPipeline runs the ordinary solve, then, with a level on, trials where eligible parts are offered as kerf-gap pair compounds. Placed pairs expand into parts plus explicit NestJobCommonLine groups, are validated again, and CommonLineRanking keeps a trial only by the level: count first, then sheets; "when it adds parts" keeps the plain layout on a tie, "whenever" also takes a shorter cut or less material, never at the cost of a part. Engines, FillScore and their comparers are unchanged; Off asks the engine exactly what it did. - NestLayoutCheck exempts members only through a sheet's explicit group list and only when the job allows common lines; materializing and committing create Plate.CommonLines. - Server nesting writes common-line jobs as version 3 and replays the recorded trial answers, so the desktop binds the same groups. - Fill Area honors the level through a PlateFillService overload. - The benchmark gains --common-lines <level> --kerf <k>. Benchmark (synthetic nests + N26-295, --parallel 1): with no flag and with --common-lines off every engine matches the base commit; with a level on no layout becomes invalid and three runs save a sheet at equal nested area. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
770 lines
34 KiB
C#
770 lines
34 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Clipper2Lib;
|
|
using OpenNest.Converters;
|
|
using OpenNest.Engine.Jobs.Adapters;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Math;
|
|
|
|
namespace OpenNest.Engine.Jobs;
|
|
|
|
/// <summary>
|
|
/// Validates a (possibly multi-plate) placed layout against the benchmark
|
|
/// rules: on every plate, every part must lie within that plate's work
|
|
/// area and every pair of parts must be at least PartSpacing apart; across
|
|
/// all plates combined, no drawing may have more parts placed than
|
|
/// requested (the quantity limit is a property of the whole order, not of
|
|
/// any one plate). Geometry checks work on arbitrary (concave, holed)
|
|
/// polygons by reusing the same world-space extraction Part.Intersects
|
|
/// uses internally, so no engine gets an advantage or penalty from shape
|
|
/// complexity.
|
|
/// </summary>
|
|
public static class NestLayoutCheck
|
|
{
|
|
/// <summary>Checks placement structure, bounds, spacing, quantities, offered stock,
|
|
/// MaxPlates and rotation policies. Unrepresentable placements skip geometry checks.
|
|
/// Requirement IDs are used in messages. Instance indices and fulfillment metadata are
|
|
/// not checked, matching the benchmark contract.</summary>
|
|
public static IReadOnlyList<string> Violations(NestJob job, NestJobResult result)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(job);
|
|
return Violations(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id));
|
|
}
|
|
|
|
/// <summary>Same checks as <see cref="Violations(NestJob, NestJobResult)"/>, with messages
|
|
/// naming each requirement by <paramref name="displayNames"/> (requirement ID -> name).</summary>
|
|
public static IReadOnlyList<string> Violations(
|
|
NestJob job,
|
|
NestJobResult result,
|
|
IReadOnlyDictionary<string, string> displayNames
|
|
) => Violations(job, result, displayNames, out _);
|
|
|
|
internal static IReadOnlyList<string> Violations(
|
|
NestJob job,
|
|
NestJobResult result,
|
|
IReadOnlyDictionary<string, string> displayNames,
|
|
out bool canKeep
|
|
)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(job);
|
|
ArgumentNullException.ThrowIfNull(result);
|
|
ArgumentNullException.ThrowIfNull(displayNames);
|
|
var violations = new List<string>();
|
|
ValidateStructure(job, result, displayNames, violations);
|
|
canKeep = violations.Count == 0;
|
|
if (canKeep)
|
|
{
|
|
var materialized = NestResultMaterializer.Materialize(job, result);
|
|
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
|
|
p => (Name: displayNames.GetValueOrDefault(p.Id, p.Id), p.Quantity));
|
|
var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList();
|
|
violations.AddRange(Validate(runs, requirements));
|
|
}
|
|
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id,
|
|
p => displayNames.GetValueOrDefault(p.Id, p.Id)), violations);
|
|
return violations;
|
|
}
|
|
|
|
private static void ValidateStructure(
|
|
NestJob job,
|
|
NestJobResult result,
|
|
IReadOnlyDictionary<string, string> displayNames,
|
|
List<string> violations
|
|
)
|
|
{
|
|
var partIds = job.Parts.Select(p => p.Id).ToHashSet(StringComparer.Ordinal);
|
|
foreach (var sheet in result.Plates)
|
|
{
|
|
for (var i = 0; i < sheet.Placements.Count; i++)
|
|
{
|
|
var pose = sheet.Placements[i];
|
|
var name = pose.PartId == null ? "<null>" : displayNames.GetValueOrDefault(pose.PartId, pose.PartId);
|
|
var description = $"Plate {sheet.PlateIndex} placement {i} for '{name}'";
|
|
if (pose.PartId == null || !partIds.Contains(pose.PartId))
|
|
violations.Add($"{description}, which is not part of this job");
|
|
if (!double.IsFinite(pose.X))
|
|
violations.Add($"{description} has nonfinite X ({pose.X})");
|
|
if (!double.IsFinite(pose.Y))
|
|
violations.Add($"{description} has nonfinite Y ({pose.Y})");
|
|
if (!double.IsFinite(pose.Rotation))
|
|
violations.Add($"{description} has nonfinite Rotation ({pose.Rotation})");
|
|
}
|
|
var grouped = new HashSet<int>();
|
|
foreach (var group in sheet.CommonLines)
|
|
if (group.Placements.Count < 2 || !double.IsFinite(group.Kerf) || group.Kerf <= 0
|
|
|| group.Placements.Any(i => i < 0 || i >= sheet.Placements.Count || !grouped.Add(i)))
|
|
violations.Add($"Plate {sheet.PlateIndex} has a malformed common-line group");
|
|
}
|
|
}
|
|
|
|
/// <summary>Tests material clearance using the benchmark's conservative outlines.
|
|
/// The leftmost raw outline is inflated, matching the full-layout sweep; ties retain
|
|
/// argument order. Geometry is cloned before transformation.</summary>
|
|
public static bool Clears(JobPartGeometry a, NestJobPlacement pa,
|
|
JobPartGeometry b, NestJobPlacement pb, double spacing)
|
|
{
|
|
var ap = Transform(a, pa.Rotation);
|
|
var bp = Transform(b, pb.Rotation);
|
|
var al = new Vector(pa.X, pa.Y);
|
|
var bl = new Vector(pb.X, pb.Y);
|
|
var ar = Outline(ap, al, 0);
|
|
var br = Outline(bp, bl, 0);
|
|
if (ar.Perimeter.BoundingBox.Left > br.Perimeter.BoundingBox.Left)
|
|
{
|
|
(ap, bp) = (bp, ap);
|
|
(al, bl) = (bl, al);
|
|
(ar, br) = (br, ar);
|
|
}
|
|
var inflateBy = InflationFor(spacing);
|
|
var inflated = inflateBy > Tolerance.Epsilon ? Outline(ap, al, inflateBy) : ar;
|
|
return !BoxesTouch(inflated.Perimeter.BoundingBox, br.Perimeter.BoundingBox)
|
|
|| !Overlaps(inflated, br);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prepares one part at one pose for repeated clearance tests against other parts: its
|
|
/// validation outline and, on first need, its spacing-inflated outline, each triangulated
|
|
/// once. <see cref="Clears(PreparedPlacement, JobPartGeometry, NestJobPlacement)"/> gives
|
|
/// the pairwise overload's answers, including its leftmost-is-inflated tie rule, without
|
|
/// outlining and triangulating this part again per test. Not thread-safe.
|
|
/// </summary>
|
|
public static PreparedPlacement Prepare(JobPartGeometry a, NestJobPlacement pa, double spacing)
|
|
{
|
|
var ap = Transform(a, pa.Rotation);
|
|
var al = new Vector(pa.X, pa.Y);
|
|
return new PreparedPlacement(ap, al, Outline(ap, al, 0), spacing);
|
|
}
|
|
|
|
/// <summary>Same answer as <c>Clears(a, pa, b, pb, spacing)</c> for the part and spacing
|
|
/// <paramref name="a"/> was prepared with.</summary>
|
|
public static bool Clears(PreparedPlacement a, JobPartGeometry b, NestJobPlacement pb)
|
|
{
|
|
var bp = Transform(b, pb.Rotation);
|
|
var bl = new Vector(pb.X, pb.Y);
|
|
var br = Outline(bp, bl, 0);
|
|
if (a.Raw.Perimeter.BoundingBox.Left > br.Perimeter.BoundingBox.Left)
|
|
{
|
|
// The other part is leftmost, so it is the one inflated, against this raw outline.
|
|
var inflated = a.InflateBy > Tolerance.Epsilon ? Outline(bp, bl, a.InflateBy) : br;
|
|
return !BoxesTouch(inflated.Perimeter.BoundingBox, a.Raw.Perimeter.BoundingBox)
|
|
|| !Overlaps(inflated, a.Raw);
|
|
}
|
|
return !BoxesTouch(a.Inflated.Perimeter.BoundingBox, br.Perimeter.BoundingBox)
|
|
|| !Overlaps(a.Inflated, br);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The layout check's spacing verdict for two prepared parts, prepared with the same positive
|
|
/// spacing: true unless the leftmost part's inflated outline overlaps the other's raw outline
|
|
/// (ties inflate <paramref name="a"/>) and their <see cref="NativeContours"/> confirm the gap
|
|
/// is short of the spacing by more than the slack. The whole-job runner validates candidates
|
|
/// with it, so it accepts exactly the pairs <see cref="Violations(NestJob, NestJobResult)"/>
|
|
/// accepts. Unlike <see cref="Clears(PreparedPlacement, JobPartGeometry, NestJobPlacement)"/>,
|
|
/// it does not reject arcs the outline alone puts too close.
|
|
/// </summary>
|
|
internal static bool KeepsSpacing(PreparedPlacement a, PreparedPlacement b)
|
|
{
|
|
var (left, right) = a.Raw.Perimeter.BoundingBox.Left > b.Raw.Perimeter.BoundingBox.Left
|
|
? (b, a)
|
|
: (a, b);
|
|
return !BoxesTouch(left.Inflated.Perimeter.BoundingBox, right.Raw.Perimeter.BoundingBox)
|
|
|| !Overlaps(left.Inflated, right.Raw)
|
|
|| !left.Native.TooClose(right.Native, left.Spacing);
|
|
}
|
|
|
|
/// <summary>A part at a pose with its outlines prepared once; see
|
|
/// <see cref="Prepare(JobPartGeometry, NestJobPlacement, double)"/>.</summary>
|
|
public sealed class PreparedPlacement
|
|
{
|
|
private readonly ShapeProfile profile;
|
|
private readonly Vector location;
|
|
private SpacingOutline inflated;
|
|
private NativeContours native;
|
|
|
|
internal PreparedPlacement(ShapeProfile profile, Vector location, SpacingOutline raw, double spacing)
|
|
{
|
|
this.profile = profile;
|
|
this.location = location;
|
|
Raw = raw;
|
|
Spacing = spacing;
|
|
InflateBy = InflationFor(spacing);
|
|
}
|
|
|
|
internal SpacingOutline Raw { get; }
|
|
internal double Spacing { get; }
|
|
internal double InflateBy { get; }
|
|
|
|
internal SpacingOutline Inflated =>
|
|
inflated ??= InflateBy > Tolerance.Epsilon ? Outline(profile, location, InflateBy) : Raw;
|
|
|
|
internal NativeContours Native => native ??= NativeContours.Of(profile, location);
|
|
}
|
|
|
|
private static ShapeProfile Transform(JobPartGeometry geometry, double rotation)
|
|
{
|
|
var shapes = new[] { geometry.Perimeter }.Concat(geometry.Cutouts);
|
|
var entities = shapes.SelectMany(s => s.Entities).Select(e => e.Clone()).ToList();
|
|
foreach (var entity in entities)
|
|
entity.Rotate(rotation);
|
|
return new ShapeProfile(entities);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// requirements maps each materialized part's BaseDrawing (by reference - materialized
|
|
/// Drawing instances are freshly reconstructed per NestResultMaterializer.Materialize, so
|
|
/// identity must never be inferred from Name, which is only incidentally seeded from the
|
|
/// originating NestJobPart id) to its original quantity limit and display name.
|
|
/// </summary>
|
|
internal static List<string> Validate(
|
|
List<(Plate Plate, List<Part> Parts)> plateRuns,
|
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements
|
|
)
|
|
{
|
|
var result = new List<string>();
|
|
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
|
|
|
if (allParts.Count == 0)
|
|
return result;
|
|
|
|
ValidateQuantities(allParts, requirements, result);
|
|
|
|
foreach (var (plate, parts) in plateRuns)
|
|
{
|
|
if (parts.Count == 0)
|
|
continue;
|
|
|
|
ValidateBounds(parts, plate, requirements, result);
|
|
ValidateAreaBudget(parts, plate, result);
|
|
ValidateSpacing(parts, plate.PartSpacing, requirements, result, plate.CommonLines);
|
|
ValidateCommonLines(plate, parts, requirements, result);
|
|
if (plate.ClampZones.Count > 0)
|
|
ValidateClamps(parts, plate, requirements, result);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// C1/C5: no part material may enter a clamp zone inflated by the plate's clamp
|
|
/// clearance (the rail's own, else the bottom-edge spacing, D6), enforced with the
|
|
/// shared spacing slack so exact engine placements are not reddened by rounding.
|
|
/// </summary>
|
|
private static void ValidateClamps(
|
|
List<Part> parts,
|
|
Plate plate,
|
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
|
List<string> result
|
|
)
|
|
{
|
|
var inflateBy = InflationFor(plate.ClampClearance);
|
|
|
|
// Plate zones are stored in sheet coordinates; parts are in the placement frame.
|
|
foreach (var zone in ClampZoneFrame.ToPlacement(plate))
|
|
{
|
|
// Box.Offset(d) grows the rectangle on all four sides.
|
|
var keepOut = zone.Bounds.Offset(inflateBy);
|
|
var keepOutOutline = RectangleOutline(keepOut);
|
|
|
|
foreach (var part in parts)
|
|
{
|
|
if (!BoxesTouch(keepOut, MaterialBounds(part)))
|
|
continue;
|
|
|
|
if (!OverlapsKeepOut(part, keepOutOutline, keepOut))
|
|
continue;
|
|
|
|
result.Add(
|
|
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) overlaps "
|
|
+ $"clamp zone '{zone.Id}' including its edge clearance"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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 SpacingOutline RectangleOutline(Box box)
|
|
{
|
|
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 SpacingOutline(perimeter, 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
|
|
/// on the part's native contours (not the grid-rounded validation outline): the
|
|
/// tolerant path would call real overlap clear. A part without material is skipped.
|
|
/// </summary>
|
|
private static bool OverlapsKeepOut(Part part, SpacingOutline keepOutOutline, Box keepOut)
|
|
{
|
|
if (KeepOutOverlap.NeedsToleranceFreeTest(keepOut) || keepOutOutline.Triangles == null)
|
|
{
|
|
var entities = ConvertProgram.ToGeometry(part.Program)
|
|
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
|
.ToList();
|
|
var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
|
|
return profile?.Perimeter != null && KeepOutOverlap.ProfileEnters(profile, part.Location, keepOut);
|
|
}
|
|
var material = Outline(part, 0);
|
|
return material != null && Overlaps(material, keepOutOutline);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks what the materialized layout cannot show: every sheet must be
|
|
/// one of the job's own stock entries (an engine may not invent a sheet
|
|
/// size or loosen its spacing/edge settings, which the layout checks
|
|
/// would otherwise trust), finite stock and MaxPlates may not be overdrawn, and every
|
|
/// placement's rotation must satisfy its part's RotationPolicy.
|
|
/// </summary>
|
|
internal static void ValidateAgainstJob(
|
|
NestJob job,
|
|
NestJobResult jobResult,
|
|
IReadOnlyDictionary<string, string> displayNames,
|
|
List<string> result
|
|
)
|
|
{
|
|
var stockById = job.Plates.ToDictionary(s => s.Id);
|
|
var partsById = job.Parts.ToDictionary(p => p.Id);
|
|
var sheetsUsed = new Dictionary<string, int>();
|
|
|
|
if (job.Options.MaxPlates is int maxPlates && jobResult.Plates.Count > maxPlates)
|
|
result.Add($"Used {jobResult.Plates.Count} sheet(s) but the job MaxPlates limit is {maxPlates}");
|
|
|
|
foreach (var sheet in jobResult.Plates)
|
|
{
|
|
if (
|
|
!stockById.TryGetValue(sheet.Stock.Id, out var stock)
|
|
|| !SameSettings(stock, sheet.Stock)
|
|
)
|
|
{
|
|
result.Add(
|
|
$"Plate {sheet.PlateIndex} uses stock '{sheet.Stock.Id}' ({sheet.Stock.Size}) that does not match any stock offered by the job"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
sheetsUsed[stock.Id] = sheetsUsed.GetValueOrDefault(stock.Id) + 1;
|
|
}
|
|
|
|
foreach (var (stockId, used) in sheetsUsed)
|
|
{
|
|
var available = stockById[stockId].Quantity;
|
|
|
|
if (available.HasValue && used > available.Value)
|
|
{
|
|
result.Add(
|
|
$"Used {used} sheet(s) of stock '{stockId}' but only {available.Value} are available"
|
|
);
|
|
}
|
|
}
|
|
|
|
foreach (var sheet in jobResult.Plates)
|
|
ValidateCommonLinesAgainstJob(job, sheet, partsById, displayNames, result);
|
|
|
|
foreach (var sheet in jobResult.Plates)
|
|
{
|
|
foreach (var placement in sheet.Placements)
|
|
{
|
|
if (placement.PartId == null
|
|
|| !partsById.TryGetValue(placement.PartId, out var part)
|
|
|| !double.IsFinite(placement.Rotation))
|
|
continue; // reported by ValidateStructure
|
|
|
|
if (!part.Rotation.Allows(placement.Rotation))
|
|
{
|
|
var name = displayNames.TryGetValue(part.Id, out var n) ? n : part.Id;
|
|
result.Add(
|
|
$"'{name}' placed at {Angle.ToDegrees(placement.Rotation):F3}° on plate {sheet.PlateIndex}, "
|
|
+ $"outside its rotation constraint ({Describe(part.Rotation)})"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A sheet's explicit common-line groups must be allowed by the job: a common-line level on,
|
|
/// the job's kerf, members that allow common lines, and positions on the sheet that no other
|
|
/// group takes. Geometry (that the members really share edges) is checked on the plate.
|
|
/// </summary>
|
|
private static void ValidateCommonLinesAgainstJob(NestJob job, NestJobPlateResult sheet,
|
|
IReadOnlyDictionary<string, NestJobPart> partsById, IReadOnlyDictionary<string, string> displayNames,
|
|
List<string> result)
|
|
{
|
|
if (sheet.CommonLines.Count == 0)
|
|
return;
|
|
if (!job.Options.UsesCommonLines)
|
|
{
|
|
result.Add($"Plate {sheet.PlateIndex} has common-line groups, which this job does not allow");
|
|
return;
|
|
}
|
|
var taken = new HashSet<int>();
|
|
foreach (var group in sheet.CommonLines)
|
|
{
|
|
if (BitConverter.DoubleToInt64Bits(group.Kerf) != BitConverter.DoubleToInt64Bits(job.Options.CommonLineKerf))
|
|
result.Add($"Plate {sheet.PlateIndex} has a common-line group at kerf {group.Kerf:F4}; the job's kerf is {job.Options.CommonLineKerf:F4}");
|
|
if (group.Placements.Count < 2)
|
|
result.Add($"Plate {sheet.PlateIndex} has a common-line group of fewer than two parts");
|
|
foreach (var index in group.Placements)
|
|
{
|
|
if (index < 0 || index >= sheet.Placements.Count || !taken.Add(index))
|
|
{
|
|
result.Add($"Plate {sheet.PlateIndex} has a common-line group naming placement {index}, which is missing or already in a group");
|
|
continue;
|
|
}
|
|
var id = sheet.Placements[index].PartId;
|
|
if (id != null && partsById.TryGetValue(id, out var part) && !part.AllowCommonLine)
|
|
result.Add($"'{displayNames.GetValueOrDefault(id, id)}' is in a common-line group on plate {sheet.PlateIndex} but does not allow common lines");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) =>
|
|
ReferenceEquals(expected, actual)
|
|
|| (
|
|
expected.Cost == actual.Cost
|
|
&& expected.Size.Equals(actual.Size)
|
|
&& expected.PartSpacing.IsEqualTo(actual.PartSpacing)
|
|
&& expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left)
|
|
&& expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right)
|
|
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
|
|
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
|
|
&& expected.ClampClearance.IsEqualTo(actual.ClampClearance)
|
|
&& expected.Quadrant == actual.Quadrant
|
|
&& Equals(expected.Outline, actual.Outline)
|
|
&& SameZones(expected.ClampZones, actual.ClampZones)
|
|
);
|
|
|
|
// Clamp sets are part of stock settings; a different set is not the offered stock.
|
|
private static bool SameZones(
|
|
IReadOnlyList<ClampZone> x,
|
|
IReadOnlyList<ClampZone> y
|
|
)
|
|
{
|
|
if (x.Count != y.Count)
|
|
return false;
|
|
for (var i = 0; i < x.Count; i++)
|
|
{
|
|
if (
|
|
!string.Equals(x[i].Id, y[i].Id, StringComparison.Ordinal)
|
|
|| !x[i].Bounds.X.IsEqualTo(y[i].Bounds.X)
|
|
|| !x[i].Bounds.Y.IsEqualTo(y[i].Bounds.Y)
|
|
|| !x[i].Bounds.Length.IsEqualTo(y[i].Bounds.Length)
|
|
|| !x[i].Bounds.Width.IsEqualTo(y[i].Bounds.Width)
|
|
)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static string Describe(RotationPolicy policy) =>
|
|
policy.Kind switch
|
|
{
|
|
RotationPolicyKind.Fixed => $"fixed at {Angle.ToDegrees(policy.Start):F3}°",
|
|
RotationPolicyKind.BoundedSweep =>
|
|
$"{Angle.ToDegrees(policy.Start):F3}° to {Angle.ToDegrees(policy.End):F3}° in {Angle.ToDegrees(policy.Step):F3}° steps",
|
|
_ => "any",
|
|
};
|
|
|
|
private static void ValidateQuantities(
|
|
List<Part> parts,
|
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
|
List<string> result
|
|
)
|
|
{
|
|
var placedCounts = parts
|
|
.GroupBy<Part, Drawing>(p => p.BaseDrawing, ReferenceEqualityComparer.Instance)
|
|
.ToDictionary(g => g.Key, g => g.Count());
|
|
|
|
foreach (var (drawing, placed) in placedCounts)
|
|
{
|
|
if (!requirements.TryGetValue(drawing, out var requirement))
|
|
{
|
|
result.Add(
|
|
$"Placed drawing '{drawing.Name}' which was not requested for this job"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (placed > requirement.Quantity)
|
|
{
|
|
result.Add(
|
|
$"'{requirement.Name}': placed {placed} across all plates but only {requirement.Quantity} were requested"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidateBounds(
|
|
List<Part> parts,
|
|
Plate plate,
|
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
|
List<string> result
|
|
)
|
|
{
|
|
var workArea = plate.WorkArea();
|
|
var stock = plate.Outline == null ? null : DrawingJobMapper.FromPlate("plate", plate, null);
|
|
|
|
foreach (var part in parts)
|
|
{
|
|
var bb = MaterialBounds(part);
|
|
|
|
var outLeft = bb.Left < workArea.X - NestTolerances.WorkAreaSlack;
|
|
var outBottom = bb.Bottom < workArea.Y - NestTolerances.WorkAreaSlack;
|
|
var outRight = bb.Right > workArea.Right + NestTolerances.WorkAreaSlack;
|
|
var outTop = bb.Top > workArea.Top + NestTolerances.WorkAreaSlack;
|
|
|
|
if (outLeft || outBottom || outRight || outTop)
|
|
{
|
|
result.Add(
|
|
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area "
|
|
+ $"of a {plate.Size} plate"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// An outlined sheet: the circumscribed material must lie in the usable area, which
|
|
// keeps the largest edge spacing from every edge and obstacle, within the same slack.
|
|
if (stock != null && Outline(part, 0) is { } material
|
|
&& !stock.OutlineHolds(new PathsD { ClipperBridge.ToPath(material.Perimeter, positive: true) },
|
|
NestTolerances.WorkAreaSlack))
|
|
{
|
|
result.Add(
|
|
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the outline "
|
|
+ $"of a {plate.Size} plate or over one of its obstacles"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hard mathematical backstop: non-overlapping parts confined to the
|
|
/// work area can never have a combined area greater than the work
|
|
/// area itself. This catches overlap that the polygon-based
|
|
/// ValidateSpacing check can miss - Collision.HasOverlap (and
|
|
/// Part.Intersects, which uses the same algorithm) has been observed
|
|
/// to return false negatives on real, complex production geometry, so
|
|
/// this check does not depend on it.
|
|
/// </summary>
|
|
private static void ValidateAreaBudget(
|
|
List<Part> parts,
|
|
Plate plate,
|
|
List<string> result
|
|
)
|
|
{
|
|
var workArea = plate.WorkArea();
|
|
var budget = plate.Outline == null
|
|
? workArea.Width * workArea.Length
|
|
: DrawingJobMapper.FromPlate("plate", plate, null).UsableOutline().Sum(Clipper.Area);
|
|
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
|
|
|
|
if (placedArea > budget + Tolerance.Epsilon)
|
|
{
|
|
result.Add(
|
|
$"Combined placed area ({placedArea:F2}) on a {plate.Size} plate exceeds its work area ({budget:F2}) - "
|
|
+ "parts must overlap even though the polygon overlap check did not flag a pair"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every pair of parts must be at least <paramref name="spacing"/> apart.
|
|
/// Each part's material is inflated by the spacing (perimeter offset
|
|
/// outward, holes shrunk inward) and tested against the other part's raw
|
|
/// material, with holes subtracted on both sides - so a small part
|
|
/// nested inside another part's cutout (part-in-part) is legal as long as
|
|
/// it clears the cutout's edge by the spacing. Pairs are pruned with an
|
|
/// X-sorted sweep over bounding boxes so only neighbors reach the
|
|
/// polygon clipper. A pair the outlines flag at a positive spacing is
|
|
/// reported only when <see cref="NativeContours"/> confirm it.
|
|
/// </summary>
|
|
private static void ValidateSpacing(
|
|
List<Part> parts,
|
|
double spacing,
|
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
|
List<string> result,
|
|
IEnumerable<CommonLineGroup> commonLines = null
|
|
)
|
|
{
|
|
// Members of one explicit common-line group are held to the group's kerf instead.
|
|
var groupOf = new Dictionary<Part, CommonLineGroup>(ReferenceEqualityComparer.Instance);
|
|
if (commonLines != null)
|
|
foreach (var group in commonLines)
|
|
foreach (var member in group.Members)
|
|
groupOf.TryAdd(member, group);
|
|
var profiles = new ShapeProfile[parts.Count];
|
|
var raw = new SpacingOutline[parts.Count];
|
|
var inflated = new SpacingOutline[parts.Count];
|
|
var native = new NativeContours[parts.Count];
|
|
var inflateBy = InflationFor(spacing);
|
|
|
|
for (var i = 0; i < parts.Count; i++)
|
|
{
|
|
profiles[i] = Profile(parts[i]);
|
|
if (profiles[i] == null)
|
|
continue;
|
|
raw[i] = Outline(profiles[i], parts[i].Location, 0);
|
|
inflated[i] = inflateBy > Tolerance.Epsilon
|
|
? Outline(profiles[i], parts[i].Location, inflateBy)
|
|
: raw[i];
|
|
}
|
|
|
|
var order = Enumerable
|
|
.Range(0, parts.Count)
|
|
.Where(i => raw[i] != null && inflated[i] != null)
|
|
.OrderBy(i => raw[i].Perimeter.BoundingBox.Left)
|
|
.ToList();
|
|
|
|
for (var a = 0; a < order.Count; a++)
|
|
{
|
|
var i = order[a];
|
|
var reach = inflated[i].Perimeter.BoundingBox;
|
|
|
|
for (var b = a + 1; b < order.Count; b++)
|
|
{
|
|
var j = order[b];
|
|
var other = raw[j].Perimeter.BoundingBox;
|
|
|
|
// Sorted by Left, so nothing further along can reach part i either.
|
|
if (other.Left > reach.Right + Tolerance.Epsilon)
|
|
break;
|
|
|
|
if (!BoxesTouch(reach, other))
|
|
continue;
|
|
|
|
// Inflating one side by the full spacing covers both cases: part j
|
|
// inside part i's (shrunk) cutout, or part i's inflated outline
|
|
// inside part j's raw cutout.
|
|
if (!Overlaps(inflated[i], raw[j]))
|
|
continue;
|
|
|
|
if (groupOf.TryGetValue(parts[i], out var shared)
|
|
&& groupOf.TryGetValue(parts[j], out var partnerGroup) && ReferenceEquals(shared, partnerGroup))
|
|
{
|
|
native[i] ??= NativeContours.Of(profiles[i], parts[i].Location);
|
|
native[j] ??= NativeContours.Of(profiles[j], parts[j].Location);
|
|
if (native[i].TooClose(native[j], shared.Kerf))
|
|
result.Add(
|
|
$"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' share a common line but are closer than its kerf ({shared.Kerf:F4})"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// The outline circumscribes arcs by more than the slack, so confirm a spacing
|
|
// candidate on the native contours. Zero spacing has no gap to confirm.
|
|
if (inflateBy > Tolerance.Epsilon)
|
|
{
|
|
native[i] ??= NativeContours.Of(profiles[i], parts[i].Location);
|
|
native[j] ??= NativeContours.Of(profiles[j], parts[j].Location);
|
|
if (!native[i].TooClose(native[j], spacing))
|
|
continue;
|
|
}
|
|
|
|
result.Add(
|
|
$"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every explicit common-line group must be intact: each member shares a straight edge one
|
|
/// kerf apart with another member and the members form one connected group
|
|
/// (<see cref="CommonLineGeometry.Check"/>). A group that does not share is not a common line,
|
|
/// so its spacing exemption would hide a real violation.
|
|
/// </summary>
|
|
private static void ValidateCommonLines(Plate plate, List<Part> parts,
|
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, List<string> result)
|
|
{
|
|
foreach (var group in plate.CommonLines)
|
|
{
|
|
if (group.Members.Any(member => !parts.Contains(member)))
|
|
{
|
|
result.Add("A common-line group names a part that is not on its plate");
|
|
continue;
|
|
}
|
|
if (!(group.Kerf < plate.PartSpacing))
|
|
result.Add($"A common-line group's kerf ({group.Kerf:F4}) is not smaller than the part spacing ({plate.PartSpacing:F3})");
|
|
foreach (var problem in CommonLineGeometry.Check(group).Problems)
|
|
result.Add($"'{DisplayName(problem.Part, requirements)}' {problem.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>Analytic world-space material bounds; surface marks never bound material.</summary>
|
|
internal static Box MaterialBounds(Part part) => PlateRemnant.MaterialBounds(part);
|
|
|
|
/// <summary>Inflation that enforces <paramref name="spacing"/> less the shared slack.</summary>
|
|
private static double InflationFor(double spacing) => SpacingOutline.InflationFor(spacing);
|
|
|
|
private static bool BoxesTouch(Box a, Box b) =>
|
|
a.Left <= b.Right + Tolerance.Epsilon
|
|
&& b.Left <= a.Right + Tolerance.Epsilon
|
|
&& a.Bottom <= b.Top + Tolerance.Epsilon
|
|
&& b.Bottom <= a.Top + Tolerance.Epsilon;
|
|
|
|
/// <summary>Friendly name for a violation message, falling back to the materialized
|
|
/// Drawing's own Name (the raw partId string) if this part wasn't in requirements at all -
|
|
/// that mismatch is already reported by ValidateQuantities, so this is display-only.</summary>
|
|
private static string DisplayName(
|
|
Part part,
|
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements
|
|
) =>
|
|
requirements.TryGetValue(part.BaseDrawing, out var requirement)
|
|
? requirement.Name
|
|
: part.BaseDrawing.Name;
|
|
|
|
private static bool Overlaps(SpacingOutline a, SpacingOutline b) => a.Overlaps(b);
|
|
|
|
/// <summary>
|
|
/// Extracts a part's material as world-space polygons - the perimeter and
|
|
/// its cutouts - grown by <paramref name="inflateBy"/> (perimeter offset
|
|
/// outward, cutouts offset inward, in one Clipper region offset). A cutout
|
|
/// that closes up under the offset is dropped, which treats it as solid:
|
|
/// conservative, since it has no room for another part at the required
|
|
/// spacing anyway. Arcs are flattened conservatively (perimeter arcs
|
|
/// circumscribed, cutout arcs inscribed) but nothing is padded. Callers inflate
|
|
/// by the spacing less NestTolerances.SpacingSlack, so straight edges exactly at
|
|
/// the spacing pass despite rounding; the only other leniency is the round-join
|
|
/// chord error at convex corners (SpacingOutline.ChordTolerance / 10). Arcs at
|
|
/// the spacing can overlap by up to twice the chord tolerance, so the spacing
|
|
/// check confirms what this outline flags on NativeContours.
|
|
/// part.Program is already rotated; only a Location offset is needed.
|
|
/// </summary>
|
|
private static SpacingOutline Outline(Part part, double inflateBy)
|
|
{
|
|
var profile = Profile(part);
|
|
return profile == null ? null : Outline(profile, part.Location, inflateBy);
|
|
}
|
|
|
|
/// <summary>A part's clean nesting material, already rotated; null when it has no closed
|
|
/// perimeter.</summary>
|
|
private static ShapeProfile Profile(Part part)
|
|
{
|
|
var entities = ConvertProgram
|
|
.ToGeometry(part.Program)
|
|
.ForNesting()
|
|
.ToList();
|
|
|
|
if (entities.Count == 0)
|
|
return null;
|
|
|
|
var profile = new ShapeProfile(entities);
|
|
|
|
return profile.Perimeter == null ? null : profile;
|
|
}
|
|
|
|
private static SpacingOutline Outline(ShapeProfile profile, Vector location, double inflateBy) =>
|
|
SpacingOutline.Build(profile, location, inflateBy);
|
|
}
|