mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 22:01:06 -04:00
feat(core): pre-post checks for parts nested in cutouts
Posting review now proves containment exactly as the cutting planner does (shared NestedPartContainment) and warns when a nested part is cut after its host (NestedCutOrder) or when a host's hole lead or the nested part's lead meets the other's material (NestedLead), including subprogram hole poses. Ambiguous containment is incomplete, never clear; fixed-lead motions are not reported twice. A committed cutout preview warns until Plan Cutting cuts the insert first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b324baa69a
commit
6ac56c4a3f
13 files changed
+555
-61
No files matched your search
@@ -17,8 +17,11 @@ namespace OpenNest.CNC.CuttingPlanning;
|
||||
/// </summary>
|
||||
internal static class FixedLeadReview
|
||||
{
|
||||
/// <param name="fixedMotions">Receives, per reviewed part index, which of its placed program's
|
||||
/// motions this review owns, so other lead checks do not report them twice.</param>
|
||||
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||
IReadOnlyList<ClampZone> clamps, CancellationToken token)
|
||||
IReadOnlyList<ClampZone> clamps, CancellationToken token,
|
||||
IDictionary<int, Func<int, bool>> fixedMotions = null)
|
||||
{
|
||||
var parts = plate.Parts;
|
||||
var reviewed = Enumerable.Range(0, parts.Count)
|
||||
@@ -84,6 +87,8 @@ internal static class FixedLeadReview
|
||||
continue;
|
||||
}
|
||||
bool IsFixed(int motion) => pins == null || pins[motion] != null;
|
||||
if (fixedMotions != null)
|
||||
fixedMotions[index] = IsFixed;
|
||||
if (materials[index] is { IsComplete: true } own)
|
||||
{
|
||||
var failures = new List<FixedLeadFailure>();
|
||||
@@ -204,7 +209,7 @@ internal static class FixedLeadReview
|
||||
}
|
||||
|
||||
// The same clean, rotation-baked, seam-trimmed program that planning captures.
|
||||
private static Program Clean(Part part)
|
||||
internal static Program Clean(Part part)
|
||||
{
|
||||
var clean = (Program)part.BaseDrawing.Program.Clone();
|
||||
clean.Rotate(part.Rotation - part.BaseDrawing.Program.Rotation);
|
||||
|
||||
@@ -128,23 +128,8 @@ public static class LeadPathValidator
|
||||
}
|
||||
return (null, null);
|
||||
|
||||
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint)
|
||||
{
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
var contacts = move.Curve.Contacts(boundary, out var overlap);
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||
}
|
||||
// With all other boundary contacts excluded, the connected open path
|
||||
// has constant material membership. Use the native arc midpoint, not
|
||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
|
||||
}
|
||||
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint) =>
|
||||
MaterialContact(move.Curve, material, permittedJoint, ref budget, token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
@@ -152,4 +137,27 @@ public static class LeadPathValidator
|
||||
return new(false, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How a native lead curve contacts or enters complete material, or null when it stays clear.
|
||||
/// A contact at <paramref name="permittedJoint"/> is allowed. Exceeding the budget throws.
|
||||
/// </summary>
|
||||
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
|
||||
Vector? permittedJoint, ref int budget, CancellationToken token)
|
||||
{
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Lead validation exceeds the native query limit.");
|
||||
var contacts = curve.Contacts(boundary, out var overlap);
|
||||
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|
||||
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
|
||||
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
|
||||
}
|
||||
// With all other boundary contacts excluded, the connected open path
|
||||
// has constant material membership. Use the native arc midpoint, not
|
||||
// the chord midpoint or endpoints (which can both lie in scrap).
|
||||
return material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Whether one part's clean material lies strictly inside a cutout of another's. The cutting
|
||||
/// planner's prerequisites and the pre-post review share this proof, so a pair the planner orders
|
||||
/// inner-before-host is the pair posting checks. Bounds only select candidates; containment is
|
||||
/// proven on native material, and any boundary contact or incomplete material is ambiguous.
|
||||
/// </summary>
|
||||
internal static class NestedPartContainment
|
||||
{
|
||||
private const int ContactBudget = 1000000;
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="inner"/>'s perimeter lies strictly inside one cutout of
|
||||
/// <paramref name="host"/>, false when it lies in none; otherwise null, with
|
||||
/// <paramref name="ambiguity"/> saying why containment cannot be established.
|
||||
/// </summary>
|
||||
internal static bool? InsideCutout(LeadMaterialSnapshot inner, LeadMaterialSnapshot host,
|
||||
CancellationToken token, out string ambiguity)
|
||||
{
|
||||
ambiguity = null;
|
||||
if (!inner.IsComplete || !host.IsComplete)
|
||||
{
|
||||
ambiguity = inner.Reason ?? host.Reason;
|
||||
return null;
|
||||
}
|
||||
var budget = ContactBudget;
|
||||
foreach (var curve in inner.Rings[0])
|
||||
foreach (var ring in host.Rings)
|
||||
foreach (var boundary in ring)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
{
|
||||
ambiguity = "the containment check exceeds the native query limit";
|
||||
return null;
|
||||
}
|
||||
if (curve.Contacts(boundary, out var overlap).Count != 0 || overlap)
|
||||
{
|
||||
ambiguity = "their boundaries touch or cross";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var probe = inner.Rings[0][0].Start;
|
||||
return host.Rings.Skip(1).Any(hole => LeadMaterialSnapshot.Inside(probe, hole, token));
|
||||
}
|
||||
|
||||
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
|
||||
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
|
||||
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
|
||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
|
||||
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
|
||||
|
||||
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
|
||||
internal static bool Within(Box inner, Box outer) =>
|
||||
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
|
||||
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
|
||||
|
||||
/// <summary>The union of two boxes.</summary>
|
||||
internal static Box Union(Box a, Box b)
|
||||
{
|
||||
var left = System.Math.Min(a.Left, b.Left);
|
||||
var bottom = System.Math.Min(a.Bottom, b.Bottom);
|
||||
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-post review of parts nested in other parts' cutouts. Cutting a host's enclosing hole
|
||||
/// releases the slug the nested part is cut from, so the nested part must be cut first; and the
|
||||
/// host's hole leads must not cross the nested part, nor the nested part's leads enter the host.
|
||||
/// Containment is proven exactly as the cutting planner proves it; an ambiguous pair is incomplete,
|
||||
/// never clear. Motions a fixed-lead review already owns are not reported twice. Reports only.
|
||||
/// </summary>
|
||||
internal static class NestedPartReview
|
||||
{
|
||||
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
|
||||
IReadOnlyDictionary<int, Func<int, bool>> fixedMotions, CancellationToken token)
|
||||
{
|
||||
var parts = plate.Parts;
|
||||
var clean = new Program[parts.Count];
|
||||
var cleanBounds = new Box[parts.Count];
|
||||
var hostBounds = new Box[parts.Count];
|
||||
for (var index = 0; index < parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var part = parts[index];
|
||||
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
try
|
||||
{
|
||||
// Scribe-only drawings are marks, not material.
|
||||
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
|
||||
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null))
|
||||
continue;
|
||||
var program = FixedLeadReview.Clean(part);
|
||||
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
|
||||
hostBounds[index] = NestedPartContainment.Union(part.BoundingBox, cleanBounds[index]);
|
||||
clean[index] = program;
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
// The overlap and rapid checks already report unreadable programs.
|
||||
}
|
||||
}
|
||||
var materials = new LeadMaterialSnapshot[parts.Count];
|
||||
var executions = new OwnedExecution[parts.Count];
|
||||
for (var inner = 0; inner < parts.Count; inner++)
|
||||
for (var host = 0; host < parts.Count; host++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (inner == host || clean[inner] == null || clean[host] == null
|
||||
|| !NestedPartContainment.Within(cleanBounds[inner], hostBounds[host]))
|
||||
continue;
|
||||
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
|
||||
out var ambiguity);
|
||||
if (inside == null)
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, inner + 1, host + 1,
|
||||
$"Containment of part {inner + 1} relative to part {host + 1} cannot be established: "
|
||||
+ $"{ambiguity}. Their cut order and leads are not checked."));
|
||||
continue;
|
||||
}
|
||||
if (inside == false)
|
||||
continue;
|
||||
if (inner > host)
|
||||
{
|
||||
var bounds = cleanBounds[inner];
|
||||
findings.Add(new(PostVerificationKind.NestedCutOrder, plateNumber, inner + 1, host + 1,
|
||||
$"Part {inner + 1} lies inside a cutout of part {host + 1} but is cut after it, so cutting "
|
||||
+ "the cutout releases its slug first. Plan Cutting cuts nested parts first; replan or "
|
||||
+ "reorder before posting.")
|
||||
{ Location = new Vector((bounds.Left + bounds.Right) / 2, (bounds.Bottom + bounds.Top) / 2) });
|
||||
}
|
||||
Leads(host, inner, $"part {inner + 1}, which is nested in this part's cutout");
|
||||
Leads(inner, host, $"part {host + 1}, whose cutout this part is nested in");
|
||||
}
|
||||
|
||||
LeadMaterialSnapshot Material(int index) =>
|
||||
materials[index] ??= LeadMaterialSnapshot.Capture(clean[index], parts[index].Location, token);
|
||||
|
||||
void Leads(int from, int against, string what)
|
||||
{
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
execution = executions[from] ??= ExecutionMotionReader.Read(parts[from].Program,
|
||||
parts[from].Location, null, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
return; // The rapid check reports the unreadable program.
|
||||
}
|
||||
var owned = fixedMotions.TryGetValue(from, out var isFixed) ? isFixed : null;
|
||||
var budget = 1000000;
|
||||
for (var motion = 0; motion < execution.Motions.Count; motion++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var move = execution.Motions[motion];
|
||||
if (move.Rapid || move.Layer is not (LayerType.Leadin or LayerType.Leadout)
|
||||
|| owned?.Invoke(motion) == true
|
||||
|| move.Curve == null || !(move.Length > PostVerificationGeometry.Epsilon))
|
||||
continue;
|
||||
string contact;
|
||||
try
|
||||
{
|
||||
contact = LeadPathValidator.MaterialContact(move.Curve, materials[against], null, ref budget, token);
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
{
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, from + 1, against + 1,
|
||||
"Nested part lead check: " + exception.Message));
|
||||
return;
|
||||
}
|
||||
if (contact != null)
|
||||
findings.Add(new(PostVerificationKind.NestedLead, plateNumber, from + 1, against + 1,
|
||||
$"Lead on cutting contour {FixedLeadReview.ContourAt(execution, motion)} contacts or enters "
|
||||
+ $"the material of {what}. Replan cutting or edit the lead before posting.")
|
||||
{ Location = move.Curve.Midpoint });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
}
|
||||
@@ -195,7 +195,10 @@ public static class PostVerificationAnalyzer
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// Leads Plan Cutting accepted as given (locked and drawing-pinned) are checked again here.
|
||||
FixedLeadReview.Add(plate, plateNumber, findings, clamps, cancellationToken);
|
||||
var fixedMotions = new Dictionary<int, Func<int, bool>>();
|
||||
FixedLeadReview.Add(plate, plateNumber, findings, clamps, cancellationToken, fixedMotions);
|
||||
// Parts nested in cutouts: inner-before-host order and leads across the pair.
|
||||
NestedPartReview.Add(plate, plateNumber, findings, fixedMotions, cancellationToken);
|
||||
var overlap = PlateOverlapAnalyzer.Analyze(
|
||||
PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty<ClampZone>()).ToArray(),
|
||||
cancellationToken),
|
||||
|
||||
@@ -17,7 +17,16 @@ public enum PostVerificationKind
|
||||
/// A violation inside a fixed emission (a locked program, or a contour cut exactly as its
|
||||
/// drawing pins it): accepted as given, never repaired, always reported for review.
|
||||
/// </summary>
|
||||
FixedLead
|
||||
FixedLead,
|
||||
|
||||
/// <summary>A part inside another part's cutout is cut after that host, whose cutout releases it.</summary>
|
||||
NestedCutOrder,
|
||||
|
||||
/// <summary>
|
||||
/// A lead of a nested part or of its host contacts or enters the other's material; for a host
|
||||
/// this is typically an enclosing-hole lead crossing the insert.
|
||||
/// </summary>
|
||||
NestedLead
|
||||
}
|
||||
|
||||
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||
@@ -59,6 +68,10 @@ public sealed class PostVerificationReport
|
||||
// Only nests with fixed-lead findings gain the line, so other reports read as before.
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.FixedLead))
|
||||
Summary(PostVerificationKind.FixedLead, "Fixed leads");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedCutOrder))
|
||||
Summary(PostVerificationKind.NestedCutOrder, "Nested part order");
|
||||
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedLead))
|
||||
Summary(PostVerificationKind.NestedLead, "Nested part leads");
|
||||
foreach (var finding in Findings)
|
||||
{
|
||||
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
|
||||
|
||||
@@ -16,7 +16,6 @@ namespace OpenNest.Engine.CuttingPlanning;
|
||||
/// </summary>
|
||||
internal sealed class CuttingDependencyGraph
|
||||
{
|
||||
private const int ContactBudget = 1000000;
|
||||
private readonly int[][] prerequisites;
|
||||
|
||||
private CuttingDependencyGraph(int[][] prerequisites) => this.prerequisites = prerequisites;
|
||||
@@ -108,7 +107,7 @@ internal sealed class CuttingDependencyGraph
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Bounds only select candidates; containment itself is proven on native material.
|
||||
if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
|
||||
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds))
|
||||
&& NestedPartContainment.Within(nodes[inner].CleanBounds, nodes[host].HostBounds))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -160,34 +159,13 @@ internal sealed class CuttingDependencyGraph
|
||||
// True when the inner part's perimeter lies strictly inside one cutout of the host. Any
|
||||
// boundary contact or crossing between them, or incomplete material, is ambiguous.
|
||||
private static bool InsideCutout(IReadOnlyList<DependencyNode> nodes, int inner, int host,
|
||||
CancellationToken token)
|
||||
{
|
||||
var part = nodes[inner].Material;
|
||||
var enclosing = nodes[host].Material;
|
||||
if (!part.IsComplete || !enclosing.IsComplete)
|
||||
throw Ambiguous(inner, host, part.Reason ?? enclosing.Reason);
|
||||
var budget = ContactBudget;
|
||||
foreach (var curve in part.Rings[0])
|
||||
foreach (var ring in enclosing.Rings)
|
||||
foreach (var boundary in ring)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw Ambiguous(inner, host, "the containment check exceeds the native query limit");
|
||||
if (curve.Contacts(boundary, out var overlap).Count != 0 || overlap)
|
||||
throw Ambiguous(inner, host, "their boundaries touch or cross");
|
||||
}
|
||||
var probe = part.Rings[0][0].Start;
|
||||
return enclosing.Rings.Skip(1).Any(hole => LeadMaterialSnapshot.Inside(probe, hole, token));
|
||||
}
|
||||
CancellationToken token) =>
|
||||
NestedPartContainment.InsideCutout(nodes[inner].Material, nodes[host].Material, token, out var ambiguity)
|
||||
?? throw Ambiguous(inner, host, ambiguity);
|
||||
|
||||
private static CuttingDependencyException Ambiguous(int inner, int host, string reason) =>
|
||||
new(CuttingPlanStatus.UnsupportedGeometry, inner, host,
|
||||
$"Containment of part {inner + 1} relative to part {host + 1} cannot be established: {reason}.");
|
||||
|
||||
private static bool Within(Box inner, Box outer) =>
|
||||
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
|
||||
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
/// <summary>Owned copy of a cutoff definition's nominal line.</summary>
|
||||
@@ -207,7 +185,7 @@ internal sealed class DependencyNode
|
||||
CutOff = cutOff;
|
||||
PlacedBounds = Copy(placedBounds);
|
||||
CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
|
||||
HostBounds = cleanBounds == null ? PlacedBounds : Union(PlacedBounds, CleanBounds);
|
||||
HostBounds = cleanBounds == null ? PlacedBounds : NestedPartContainment.Union(PlacedBounds, CleanBounds);
|
||||
this.materialFactory = materialFactory;
|
||||
}
|
||||
|
||||
@@ -221,13 +199,6 @@ internal sealed class DependencyNode
|
||||
internal LeadMaterialSnapshot Material => material ??= materialFactory();
|
||||
|
||||
private static Box Copy(Box box) => new(box.X, box.Y, box.Length, box.Width);
|
||||
|
||||
private static Box Union(Box a, Box b)
|
||||
{
|
||||
var left = System.Math.Min(a.Left, b.Left);
|
||||
var bottom = System.Math.Min(a.Bottom, b.Bottom);
|
||||
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A dependency refusal naming the part and, when relevant, the other part.</summary>
|
||||
|
||||
@@ -108,9 +108,7 @@ public static class CuttingPlanService
|
||||
SeamOvercut.Trim(ownedClean);
|
||||
// Material extent only: remote rapids and scribe marks must not widen it, or a
|
||||
// genuine insert could fail the containment prefilter.
|
||||
var cleanBounds = ExecutionMotionReader.ReadSupported(ownedClean, source.Location, null, token).Motions
|
||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
|
||||
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
|
||||
var cleanBounds = NestedPartContainment.CleanBounds(ownedClean, source.Location, token);
|
||||
var location = source.Location;
|
||||
if (request.ConfirmedParameters != null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Diagnostics;
|
||||
|
||||
public class NestedPartReviewTests
|
||||
{
|
||||
[Fact]
|
||||
public void InnerCutBeforeItsHost_WithAClearHoleLead_HasNoNestedFinding()
|
||||
{
|
||||
var report = Analyze(Inner(), Host(new Vector(12, 10)));
|
||||
|
||||
Assert.Empty(Nested(report));
|
||||
Assert.Empty(Find(report, PostVerificationKind.Incomplete));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InnerCutAfterItsHost_WarnsAndRequiresFreshConsent()
|
||||
{
|
||||
var report = Analyze(Host(new Vector(12, 10)), Inner());
|
||||
|
||||
var finding = Assert.Single(Find(report, PostVerificationKind.NestedCutOrder));
|
||||
Assert.Equal(2, finding.PartNumber);
|
||||
Assert.Equal(1, finding.OtherPartNumber);
|
||||
Assert.NotNull(finding.Location);
|
||||
Assert.False(report.CanPost(false));
|
||||
Assert.True(report.CanPost(true));
|
||||
Assert.Contains("Nested part order: 1 warning(s)", report.ToDisplayText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostingEntryPoint_EvenForAnOrderPreservingPost_RequiresFreshConsent()
|
||||
{
|
||||
var nest = NestWith(Host(new Vector(12, 10)), Inner());
|
||||
|
||||
var report = PostVerificationAnalyzer.AnalyzeForPost(nest, new OrderPreservingPost());
|
||||
|
||||
Assert.Single(Find(report, PostVerificationKind.NestedCutOrder));
|
||||
Assert.Empty(Find(report, PostVerificationKind.Incomplete));
|
||||
Assert.False(report.CanPost(false));
|
||||
Assert.True(PostVerificationAnalyzer.AnalyzeForPost(NestWith(Inner(), Host(new Vector(12, 10))),
|
||||
new OrderPreservingPost()).CanPost(false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Mode.Absolute)]
|
||||
[InlineData(Mode.Incremental)]
|
||||
public void HoleLeadCrossingTheInsert_WarnsAtItsSubprogramPose(Mode mode)
|
||||
{
|
||||
// The hole is a subprogram call; its lead-in starts at the hole centre, inside the insert.
|
||||
var report = Analyze(Inner(), Host(new Vector(10, 10), mode));
|
||||
|
||||
var finding = Assert.Single(Find(report, PostVerificationKind.NestedLead));
|
||||
Assert.Equal(2, finding.PartNumber);
|
||||
Assert.Equal(1, finding.OtherPartNumber);
|
||||
Assert.Contains("cutting contour 1", finding.Message);
|
||||
Assert.False(report.CanPost(false));
|
||||
Assert.Contains("Nested part leads: 1 warning(s)", report.ToDisplayText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertLeadEnteringTheHostMaterial_Warns()
|
||||
{
|
||||
// The insert's lead-in starts beyond the cutout wall, in the host's material.
|
||||
var report = Analyze(Inner(leadStart: new Vector(16, 10)), Host(new Vector(12, 10)));
|
||||
|
||||
var finding = Assert.Single(Find(report, PostVerificationKind.NestedLead));
|
||||
Assert.Equal(1, finding.PartNumber);
|
||||
Assert.Equal(2, finding.OtherPartNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LockedHostLead_IsReportedOnceAsAFixedLead()
|
||||
{
|
||||
var host = Host(new Vector(10, 10));
|
||||
host.LeadInsLocked = true;
|
||||
|
||||
var report = Analyze(Inner(), host);
|
||||
|
||||
Assert.Contains(Find(report, PostVerificationKind.FixedLead), f => f.PartNumber == 2 && f.OtherPartNumber == 1);
|
||||
Assert.Empty(Find(report, PostVerificationKind.NestedLead));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertStraddlingTheCutoutWall_IsIncompleteNotClear()
|
||||
{
|
||||
var report = Analyze(Square(14, 9, 2), Host(new Vector(12, 10)));
|
||||
|
||||
Assert.Contains(Find(report, PostVerificationKind.Incomplete), f =>
|
||||
f.PartNumber == 1 && f.OtherPartNumber == 2 && f.Message.Contains("Containment"));
|
||||
Assert.Empty(Find(report, PostVerificationKind.NestedCutOrder));
|
||||
Assert.False(report.CanPost(false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartInAConcavePocketOutsideTheHost_HasNoNestedFinding()
|
||||
{
|
||||
var outline = new[] { (10.0, 0.0), (0.0, 0.0), (0.0, 10.0), (3.0, 10.0), (3.0, 3.0), (7.0, 3.0),
|
||||
(7.0, 10.0), (10.0, 10.0), (10.0, 5.0) };
|
||||
var program = new Program();
|
||||
program.MoveTo(10, 5);
|
||||
foreach (var (x, y) in outline)
|
||||
program.LineTo(x, y);
|
||||
var pocketed = new Part(new Drawing("pocketed", program));
|
||||
|
||||
var report = Analyze(pocketed, Square(4.5, 5, 1));
|
||||
|
||||
Assert.Empty(Nested(report));
|
||||
Assert.DoesNotContain(Find(report, PostVerificationKind.Incomplete), f => f.Message.Contains("Containment"));
|
||||
}
|
||||
|
||||
private static PostVerificationFinding[] Nested(PostVerificationReport report) => report.Findings
|
||||
.Where(f => f.Kind is PostVerificationKind.NestedCutOrder or PostVerificationKind.NestedLead).ToArray();
|
||||
|
||||
private static PostVerificationFinding[] Find(PostVerificationReport report, PostVerificationKind kind) =>
|
||||
report.Findings.Where(f => f.Kind == kind).ToArray();
|
||||
|
||||
private static PostVerificationReport Analyze(params Part[] parts) =>
|
||||
PostVerificationAnalyzer.Analyze(NestWith(parts));
|
||||
|
||||
private static Nest NestWith(params Part[] parts)
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
foreach (var part in parts)
|
||||
plate.Parts.Add(part);
|
||||
return nest;
|
||||
}
|
||||
|
||||
// A 20x20 square host with a radius-5 cutout centred at (10, 10). Its placed program cuts the
|
||||
// hole first through a subprogram call (lead-in from holeLead to (15, 10)), then the perimeter.
|
||||
private static Part Host(Vector holeLead, Mode mode = Mode.Absolute)
|
||||
{
|
||||
var clean = new Program();
|
||||
clean.MoveTo(15, 10);
|
||||
clean.ArcTo(15, 10, 10, 10, RotationType.CCW);
|
||||
clean.MoveTo(20, 10);
|
||||
Outline(clean);
|
||||
var hole = new Program();
|
||||
hole.MoveTo(holeLead.X - 10, holeLead.Y - 10);
|
||||
hole.Codes.Add(new LinearMove(5, 0) { Layer = LayerType.Leadin });
|
||||
hole.Codes.Add(new ArcMove(5, 0, 0, 0) { Layer = LayerType.Display });
|
||||
hole.Mode = mode;
|
||||
var placed = new Program();
|
||||
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(10, 10), Id = 1 });
|
||||
placed.MoveTo(20.5, 10);
|
||||
placed.Codes.Add(new LinearMove(20, 10) { Layer = LayerType.Leadin });
|
||||
Outline(placed);
|
||||
placed.Mode = mode;
|
||||
var host = new Part(new Drawing("host", clean));
|
||||
Assert.True(host.RestoreLeadInProgram(placed, false));
|
||||
return host;
|
||||
|
||||
static void Outline(Program program)
|
||||
{
|
||||
program.LineTo(20, 20);
|
||||
program.LineTo(0, 20);
|
||||
program.LineTo(0, 0);
|
||||
program.LineTo(20, 0);
|
||||
program.LineTo(20, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// A 2x2 insert at (9, 9) inside the host's cutout, cut from its right-edge midpoint.
|
||||
private static Part Inner(Vector? leadStart = null) =>
|
||||
Square(9, 9, 2, leadStart ?? new Vector(11.5, 10));
|
||||
|
||||
private static Part Square(double x, double y, double size, Vector? leadStart = null)
|
||||
{
|
||||
var clean = new Program();
|
||||
clean.MoveTo(size, size / 2);
|
||||
Contour(clean, size);
|
||||
var part = new Part(new Drawing("square", clean), new Vector(x, y));
|
||||
var lead = leadStart ?? new Vector(x + size + 0.25, y + size / 2);
|
||||
var placed = new Program();
|
||||
placed.MoveTo(lead.X - x, lead.Y - y);
|
||||
placed.Codes.Add(new LinearMove(size, size / 2) { Layer = LayerType.Leadin });
|
||||
Contour(placed, size);
|
||||
Assert.True(part.RestoreLeadInProgram(placed, false));
|
||||
return part;
|
||||
|
||||
static void Contour(Program program, double size)
|
||||
{
|
||||
program.LineTo(size, 0);
|
||||
program.LineTo(0, 0);
|
||||
program.LineTo(0, size);
|
||||
program.LineTo(size, size);
|
||||
program.LineTo(size, size / 2);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OrderPreservingPost : IPostProcessor, IPostVerificationSupport
|
||||
{
|
||||
public string Name => "order-preserving test post";
|
||||
public string Author => "tests";
|
||||
public string Description => "Writes nothing.";
|
||||
public bool PreservesPlacedProgramOrder => true;
|
||||
public void Post(Nest nest, Stream outputStream) => throw new NotSupportedException();
|
||||
public void Post(Nest nest, string outputFile) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
namespace OpenNest.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Cutouts-first placements are only cut-safe once the insert is cut before its host and the
|
||||
/// host's hole leads clear it. Posting review must say so on the committed parts, and a fresh
|
||||
/// Plan Cutting run on those actual parts must produce that order.
|
||||
/// </summary>
|
||||
public class CutoutCuttingSafetyTests
|
||||
{
|
||||
[Fact]
|
||||
public void CommittedCutoutPreview_WarnsUntilPlanCuttingCutsTheInsertFirst()
|
||||
{
|
||||
var frame = new OpenNest.Shapes.RingShape { OuterDiameter = 20, InnerDiameter = 10 }.GetDrawing();
|
||||
frame.Name = "neutral-frame";
|
||||
var program = new Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(6, 0);
|
||||
program.LineTo(6, 6);
|
||||
program.LineTo(0, 6);
|
||||
program.LineTo(0, 0);
|
||||
var insert = new Drawing("neutral-insert", program);
|
||||
var request = new NestPipelineRequest("Irregular", new[] {
|
||||
new NestItem { Drawing = frame, Quantity = 1 },
|
||||
new NestItem { Drawing = insert, Quantity = 1 } },
|
||||
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) },
|
||||
new NestJobOptions(maxPlates: 1));
|
||||
var preview = NestPipeline.RunCutoutPreview(request);
|
||||
Assert.True(preview.IsValid, string.Join("; ", preview.Violations));
|
||||
var nest = new Nest();
|
||||
nest.Drawings.Add(frame);
|
||||
nest.Drawings.Add(insert);
|
||||
using (var manager = new PlateManager(nest))
|
||||
NestPipelineCommit.ApplyToEmptyPlates(preview, manager);
|
||||
var plate = Assert.Single(nest.Plates, p => p.Parts.Count != 0);
|
||||
Assert.Equal(new[] { frame, insert }, plate.Parts.Select(p => p.BaseDrawing));
|
||||
var host = plate.Parts[0];
|
||||
var inner = plate.Parts[1];
|
||||
|
||||
// As committed, the frame (and so its cutout) is cut before the insert inside it.
|
||||
var before = PostVerificationAnalyzer.Analyze(nest);
|
||||
var order = Assert.Single(before.Findings, f => f.Kind == PostVerificationKind.NestedCutOrder);
|
||||
Assert.Equal((2, 1), (order.PartNumber, order.OtherPartNumber));
|
||||
Assert.False(before.CanPost(false));
|
||||
|
||||
// Keeping that order is refused, so Apply cannot install it.
|
||||
var kept = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||
confirmedParameters: ExplicitContourTests.Parameters(), preservePartOrder: true));
|
||||
Assert.Equal(CuttingPlanStatus.ConstraintConflict, kept.Status);
|
||||
Assert.NotEqual(CuttingCommitStatus.Applied, CuttingPlanService.Apply([kept]).Status);
|
||||
Assert.Equal(new[] { host, inner }, plate.Parts);
|
||||
|
||||
var planned = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||
Assert.True(planned.Status == CuttingPlanStatus.Ready,
|
||||
planned.Status + ": " + string.Join("; ", planned.Findings.Select(f => f.Message)));
|
||||
Assert.Equal(new[] { inner, host }, planned.ProposedOrder.Select(o => o.SourcePart));
|
||||
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([planned]).Status);
|
||||
|
||||
var after = PostVerificationAnalyzer.Analyze(nest);
|
||||
Assert.Empty(after.Findings);
|
||||
Assert.True(after.CanPost(false));
|
||||
}
|
||||
}
|
||||
@@ -476,6 +476,8 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
PostVerificationKind.RapidCrossing => "Rapid crossing",
|
||||
PostVerificationKind.Incomplete => "Not checked",
|
||||
PostVerificationKind.FixedLead => "Fixed lead",
|
||||
PostVerificationKind.NestedCutOrder => "Nested part order",
|
||||
PostVerificationKind.NestedLead => "Nested part lead",
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
|
||||
@@ -537,6 +539,8 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
? "Orange dashes show the rapid; crosshairs mark where it crosses a cut contour."
|
||||
: "Orange dashes show the rapid running inside a cut contour.",
|
||||
PostVerificationKind.MissingLeadIn => "The ring marks the contour start that has no lead-in.",
|
||||
PostVerificationKind.NestedCutOrder => "The ring marks the nested part that is cut after its host.",
|
||||
PostVerificationKind.NestedLead => "The ring marks the middle of the lead that meets the other part.",
|
||||
_ => "The ring marks the affected part; the exact spot is not known."
|
||||
};
|
||||
|
||||
|
||||
@@ -137,8 +137,8 @@ representation; repeating does not bypass whole-job validation or change cutting
|
||||
|
||||
Placing parts inside another part's enclosed cutout is being built as a step that runs before any
|
||||
engine, so every engine benefits. Only an internal test-only pipeline preview calls it: a part
|
||||
inside a cutout must be cut before the cutout's contour, and the cutting/post safety gates are
|
||||
not complete. Ordinary whole-job calls do not enable this path.
|
||||
inside a cutout must be cut before the cutout's contour. Ordinary whole-job calls do not enable
|
||||
this path.
|
||||
|
||||
`CutoutLatticeFill` (`OpenNest.Engine/Jobs/Cutouts/`) fills one closed cutout with copies of one
|
||||
part. It runs Fill over the cutout's bounds plus one part step on every side, then shifts
|
||||
@@ -182,7 +182,13 @@ and 16 base sweep-angle samples per frame (automatic quarter-turns are all retai
|
||||
remaining copies stay ordinary requirements. Fixed legal proxy orientations are a
|
||||
conservative search choice, not full engine choice among all legal orientations; unsupported
|
||||
bundles remain ordinary independent requirements. This is an internal pipeline path, not a
|
||||
production setting or cut-ready nest. Cutting/post safety remains pending before enablement.
|
||||
production setting or cut-ready nest.
|
||||
|
||||
Committed preview parts keep the frame before its inserts, so they are not cut-ready as placed.
|
||||
Pre-post verification warns on a nested part cut after its host and on host/insert leads that
|
||||
meet the other part (see [pre-post verification](post-verification.md)); Plan Cutting on those
|
||||
parts cuts each insert before its frame and refuses a kept order that does not. Enabling the
|
||||
pre-pass for ordinary whole-job calls is still pending (Windows acceptance and front-end wiring).
|
||||
|
||||
## Renamed engines
|
||||
|
||||
|
||||
@@ -92,6 +92,18 @@ shows the first three check categories, even when there are no findings:
|
||||
are not reported. A part whose clean material cannot be captured makes the check
|
||||
incomplete for leads near it. This line appears in the summary only when there is such
|
||||
a finding.
|
||||
- **Nested parts:** a part whose perimeter lies strictly inside a cutout of another part (its
|
||||
host), proven on clean material exactly as Plan Cutting proves its inner-before-host
|
||||
prerequisite. **Nested part order** warns when the nested part comes after its host in the
|
||||
placed order, because cutting the host's cutout releases the slug it is cut from.
|
||||
**Nested part leads** warns when an actual lead-in or lead-out of the host (typically its
|
||||
enclosing hole's lead, including a hole cut through a subprogram call at its placed pose)
|
||||
contacts or enters the nested part's material, or a lead of the nested part enters the host's
|
||||
material. Leads already reported as fixed leads are not reported again. A pair whose
|
||||
containment cannot be established (touching or crossing boundaries, uncapturable material)
|
||||
is incomplete, never clear. Each line appears in the summary only when there is such a
|
||||
finding. Plan Cutting's Ready result for the plate cuts nested parts first with leads that
|
||||
clear them; this review checks the live programs again before posting.
|
||||
|
||||
Findings identify the plate and part's position in the cutting sequence. Read the
|
||||
scrollable report, cancel to fix the nest, then post again to rerun verification.
|
||||
|
||||
Reference in new issue
Block a user