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:
ajandClaude Opus 5.5 committed 2026-10-17 12:09:55 -04:00
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));
}
}
+4
View File
@@ -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."
};
+9 -3
View File
@@ -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
+12
View File
@@ -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.