feat(planning): cut holes inside a display perimeter that is never cut

Some jobs cut only cutouts into a supplied blank, such as instrument holes
in a purchased door that is the whole sheet. Its outline has to be known
to the planner (the blank is material that leads, rapids and cut-offs must
respect) but must never be pierced, led or cut.

A new DisplayPerimeter layer marks that outline. It is material, so
nesting, containment, lead clearance and automatic cut-offs all see the
blank; a door that fills its sheet leaves cut-offs nothing to cut. Cutting
preparation keeps it out of the contours entirely: with no cut perimeter
every contour is a hole with internal leads, and the search, best-effort
and safest-route paths stop offering a perimeter. Replay refuses any run
along the display perimeter and completes once every other ring is cut.

A display perimeter must be one whole closed contour enclosing every other
contour; a mixed ring, one inside another contour, or a second one is
refused with that reason. Saving, drawing and posting it are still to do.
This commit is contained in:
aj committed 2026-10-22 19:32:22 -04:00
1 parent c325a1745a
commit 6e3705eb89
12 files changed
+274 -29

No files matched your search

@@ -72,6 +72,8 @@ internal static class ContourProgramVerifier
}
}
if (ringIndex < 0) return false;
// A display perimeter is never cut: any run along it is not this plan.
if (ringIndex == 0 && material.HasDisplayPerimeter) return false;
var tabs = material.Tabs[ringIndex];
if (tabs.Any(tab => tab))
{
@@ -108,7 +110,7 @@ internal static class ContourProgramVerifier
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
true, tolerance, token, ref budget)) return false;
}
return visited.Count == material.Rings.Count;
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
}
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
@@ -12,8 +12,9 @@ namespace OpenNest.CNC.CuttingPlanning;
public sealed class LeadMaterialSnapshot
{
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
string reason)
string reason, bool displayPerimeter = false)
{
HasDisplayPerimeter = displayPerimeter;
Rings = rings;
Tabs = tabs;
Reason = reason;
@@ -41,6 +42,12 @@ public sealed class LeadMaterialSnapshot
/// </summary>
internal IReadOnlyList<bool[]> Tabs { get; }
/// <summary>
/// True when the perimeter (<see cref="Rings"/>[0]) is a display perimeter: a supplied blank's
/// edge that bounds material but is never cut. Every other ring is a hole the program cuts.
/// </summary>
internal bool HasDisplayPerimeter { get; }
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
@@ -53,6 +60,8 @@ public sealed class LeadMaterialSnapshot
var ringTabs = new List<bool[]>();
var chain = new List<PostVerificationGeometry.Curve>();
var chainTabs = new List<bool>();
var ringDisplay = new List<bool>();
var chainDisplay = new List<bool>();
foreach (var motion in execution.Motions)
{
token.ThrowIfCancellationRequested();
@@ -63,7 +72,8 @@ public sealed class LeadMaterialSnapshot
continue;
}
// A tabbed cutout's tabs are never cut but still close its hole.
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
// A display perimeter is never cut but still bounds the blank's material.
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter))
throw new ArgumentException("Material capture contains an unsupported layer.");
var curve = motion.Curve;
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
@@ -72,6 +82,7 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve);
chainTabs.Add(motion.Layer == LayerType.Tab);
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish();
}
@@ -126,13 +137,17 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
if (ringTabs[outer].Any(tab => tab))
throw new ArgumentException("A perimeter cannot have tabs.");
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
throw new ArgumentException("A display perimeter must enclose every other contour.");
if (ringDisplay[outer] && rings.Count == 1)
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
var holes = rings.Where((_, i) => i != outer).ToArray();
for (var i = 0; i < holes.Length; i++)
for (var j = i + 1; j < holes.Length; j++)
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
throw new ArgumentException("Nested cutouts are not supported material.");
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null);
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
void Finish()
{
@@ -145,10 +160,14 @@ public sealed class LeadMaterialSnapshot
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
}
if (chainDisplay.Contains(true) && chainDisplay.Contains(false))
throw new ArgumentException("A display perimeter must be one whole closed contour.");
rings.Add(chain.ToArray());
ringTabs.Add(chainTabs.ToArray());
ringDisplay.Add(chainDisplay.Count > 0 && chainDisplay[0]);
chain.Clear();
chainTabs.Clear();
chainDisplay.Clear();
}
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
@@ -46,10 +46,20 @@ public sealed class PreparedContours
// automatic entries keep to points where the tab steadies the part. World = local + origin.
private readonly PartSupport support;
private readonly Vector supportOrigin;
// False when the outside is a display perimeter: shown and material, never cut. It is not one
// of the contours, so every contour is a hole and no emission can reach the outline.
private readonly bool hasCutPerimeter;
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters)
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, hasCutPerimeter: true)
{
}
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters, bool hasCutPerimeter)
{
this.hasCutPerimeter = hasCutPerimeter;
this.shapes = shapes;
this.scribes = scribes;
this.breakCuts = breakCuts;
@@ -60,7 +70,8 @@ public sealed class PreparedContours
}
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters)
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
unpinned.hasCutPerimeter)
{
contourParameters = new CuttingParameters[shapes.Length];
var rejected = new List<CutPinResolution>();
@@ -95,7 +106,8 @@ public sealed class PreparedContours
// A copy that tabs the perimeter for support; every pin keeps its entry, now owned here, and
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters)
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
source.hasCutPerimeter)
{
for (var contour = 0; contour < Count; contour++)
{
@@ -132,6 +144,8 @@ public sealed class PreparedContours
PostVerificationGeometry.Validate(location);
if (!support.IsComplete || support.BestTabPoint == null)
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
if (!hasCutPerimeter)
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
return new PreparedContours(this, support, location, tab);
}
@@ -145,19 +159,27 @@ public sealed class PreparedContours
public Vector SupportOrigin => supportOrigin;
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
internal bool PerimeterTabbed => ParametersFor(PerimeterOrdinal).TabsEnabled;
internal bool PerimeterTabbed => hasCutPerimeter && ParametersFor(PerimeterOrdinal).TabsEnabled;
public int Count => shapes.Length;
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
public int PerimeterOrdinal => Count - 1;
/// <summary>
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
/// is a display perimeter, so every contour below it is a hole.
/// </summary>
public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count;
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
public bool HasCutPerimeter => hasCutPerimeter;
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
public bool IsPinned(int contourOrdinal) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
@@ -215,6 +237,7 @@ public sealed class PreparedContours
var scribes = new List<Entity>();
var breakCuts = new List<Shape>();
var tabbedCutouts = new List<Shape>();
Shape displayPerimeter = null;
Shape breakCut = null;
var current = new Shape();
foreach (var entity in geometryProgram.ToGeometry())
@@ -245,7 +268,8 @@ public sealed class PreparedContours
scribes.Add(entity);
continue;
}
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab
&& entity.Layer != SpecialLayers.DisplayPerimeter)
throw new ArgumentException("Preparation requires clean cut/display contours.");
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
throw new ArgumentException("Degenerate contour entity.");
@@ -263,6 +287,26 @@ public sealed class PreparedContours
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour();
}
if (displayPerimeter != null)
{
if (current.Entities.Count != 0)
throw new ArgumentException("Preparation requires nonempty closed contours.");
if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0)
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
var outline = displayPerimeter.BoundingBox;
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
{
shape.UpdateBounds();
var box = shape.BoundingBox;
if (box.Left < outline.Left - PostVerificationGeometry.Epsilon || box.Right > outline.Right + PostVerificationGeometry.Epsilon
|| box.Bottom < outline.Bottom - PostVerificationGeometry.Epsilon || box.Top > outline.Top + PostVerificationGeometry.Epsilon)
throw new ArgumentException("A display perimeter must enclose every other contour.");
}
foreach (var cut in breakCuts)
cut.UpdateBounds();
// Never a contour: nothing can choose, emit or lead it. Every contour is a hole.
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, hasCutPerimeter: false);
}
if (current.Entities.Count != 0 || contours.Count == 0)
throw new ArgumentException("Preparation requires nonempty closed contours.");
var perimeter = 0;
@@ -283,7 +327,15 @@ public sealed class PreparedContours
void FinishContour()
{
current.UpdateBounds();
if (ContourCuttingStrategy.IsTabbedCutout(current))
if (current.Entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
{
if (!current.Entities.TrueForAll(e => e.Layer == SpecialLayers.DisplayPerimeter))
throw new ArgumentException("A display perimeter must be one whole closed contour.");
if (displayPerimeter != null)
throw new ArgumentException("A display perimeter must enclose every other contour.");
displayPerimeter = current;
}
else if (ContourCuttingStrategy.IsTabbedCutout(current))
{
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
throw new ArgumentException("A tabbed cutout has nothing to cut.");
@@ -728,7 +780,7 @@ public sealed class PreparedContours
// directly, so the probe cannot drift the preparation or mutate it.
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
contourParameters);
contourParameters, PerimeterOrdinal);
}
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
@@ -738,7 +790,7 @@ public sealed class PreparedContours
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList());
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal);
}
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
@@ -760,6 +812,8 @@ public sealed class PreparedContours
token.ThrowIfCancellationRequested();
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
// Only the outside is tabbed, with its own pin's tab when it is pinned.
if (!hasCutPerimeter)
return new(this, choices, expected, 0);
var perimeter = ParametersFor(PerimeterOrdinal);
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
}
@@ -251,14 +251,14 @@ namespace OpenNest.CNC.CuttingStrategy
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
IReadOnlyList<Shape> tabbedCutouts = null)
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
EmitBreakCuts(result, breakCuts, shapes);
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
EmitTabbedCutout(result, cutout);
EmitChosenContours(result, shapes, choices, contourParameters);
EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
result.Mode = Mode.Incremental;
return result;
}
@@ -269,20 +269,23 @@ namespace OpenNest.CNC.CuttingStrategy
// perimeter never degrades to a hole because it was isolated. Output must not be
// installed on a Part or accepted as a complete plan.
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null)
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null,
int? perimeterOrdinal = null)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
result.Mode = Mode.Incremental;
return result;
}
// One contour per choice in order; the perimeter (last shape) is always External.
// One contour per choice in order; the perimeter is always External. Without a cut perimeter
// (a display perimeter outside) perimeterOrdinal is past the last shape: every contour is a hole.
// Contour emission itself reads only the contour's own geometry and settings, never
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
private void EmitChosenContours(Program result, Shape[] shapes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters)
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
int perimeterOrdinal)
{
var shared = Parameters;
try
@@ -294,7 +297,7 @@ namespace OpenNest.CNC.CuttingStrategy
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
var shape = shapes[choice.ContourOrdinal];
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
exactCirclePrograms: true, scrapPierce: choice.Pierce);
}
}
+9
View File
@@ -21,5 +21,14 @@ namespace OpenNest.CNC
/// leaves it out, cutting the rest of the contour as open pieces.
/// </summary>
Tab,
/// <summary>
/// The outside edge of a supplied blank that is only shown, never cut (for example a
/// purchased door whose instrument cutouts are the only cuts). It bounds material like
/// <see cref="Cut"/>, so nesting, containment, lead clearance and cut-offs all see the
/// blank, but it has no pierce, lead or motion in any emitted program. It must be one whole
/// closed contour enclosing every other contour.
/// </summary>
DisplayPerimeter,
}
}
@@ -183,6 +183,9 @@ namespace OpenNest.Converters
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
return LayerType.Tab;
if (string.Equals(name, SpecialLayers.DisplayPerimeter.Name, System.StringComparison.OrdinalIgnoreCase))
return LayerType.DisplayPerimeter;
return LayerType.Cut;
}
}
@@ -213,6 +213,9 @@ namespace OpenNest.Converters
case LayerType.Tab:
return SpecialLayers.Tab;
case LayerType.DisplayPerimeter:
return SpecialLayers.DisplayPerimeter;
default:
return new Layer(layer.ToString());
}
+5 -1
View File
@@ -24,12 +24,16 @@ namespace OpenNest
/// <summary>A tabbed cutout's uncut tabs: material boundary that is never cut.</summary>
public static readonly Layer Tab = new Layer("TAB") { Color = Color.Orange };
/// <summary>A supplied blank's shown-only outside edge: material boundary that is never cut.</summary>
public static readonly Layer DisplayPerimeter = new Layer("DISPLAY PERIMETER") { Color = Color.DarkCyan };
/// <summary>
/// True when an entity converted from a part program describes part material. Rapids
/// and scribe/etch marks are excluded: marks are only on the surface, so they never
/// bound material and must not affect nesting, collision, area, or validation. Break
/// cuts are excluded too: they run through material that stays attached at tabs. Tabs are
/// material: they close a tabbed cutout's boundary.
/// material: they close a tabbed cutout's boundary. A display perimeter is material: it is the
/// supplied blank's edge.
/// </summary>
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break;
}
@@ -51,7 +51,7 @@ internal static class BestEffortCuttingPlan
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
var route = CuttingHoleOrder.Plan(holes, CuttingHoleOrder.Centres(prepared, approach, token),
approach, null, token);
foreach (var contour in route.Append(prepared.PerimeterOrdinal))
foreach (var contour in prepared.HasCutPerimeter ? route.Append(prepared.PerimeterOrdinal) : route)
{
token.ThrowIfCancellationRequested();
choices.Add(prepared.ClosestEntry(contour, approach));
@@ -518,13 +518,16 @@ internal static class JointCuttingPlanSearch
continue;
}
var prepared = source.Prepared;
if (contourRecovery && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null)
// Recovery and endpoint branches are led by the cut perimeter; a display perimeter
// is never cut, so its holes take the plain contour-by-contour search below.
if (contourRecovery && prepared.HasCutPerimeter && prepared.Count > 1
&& prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null)
{
foreach (var edge in RecoverPart(node, sequence, source, attempt))
yield return edge;
continue;
}
if (prepared.Count > 1 && node.Active == null)
if (prepared.HasCutPerimeter && prepared.Count > 1 && node.Active == null)
{
// Selecting an endpoint does not cut it. Each endpoint owns a separate
// branch, whose holes are still emitted first and checked normally.
@@ -547,7 +550,7 @@ internal static class JointCuttingPlanSearch
var arrival = node.Active?.Arrival ?? node.Position;
var before = node.Active?.Before ?? node.Checker;
var boundary = node.Active?.Boundary ?? node;
var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
var contours = prepared.HasCutPerimeter && choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
: Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c));
// First try one ranked hole chain per endpoint. A later-part failure then
// changes the endpoint before replaying all earlier hole combinations.
@@ -835,7 +838,7 @@ internal static class JointCuttingPlanSearch
{
reason = null;
var prepared = source.Prepared;
if (snapshot.PierceZoneRadius <= 0 || prepared.IsPinned(prepared.PerimeterOrdinal)
if (snapshot.PierceZoneRadius <= 0 || !prepared.HasCutPerimeter || prepared.IsPinned(prepared.PerimeterOrdinal)
|| source.Material is not { IsComplete: true })
return [];
CountExpansion(source);
@@ -1076,7 +1079,7 @@ internal static class JointCuttingPlanSearch
}
// Recovery's contour solver first: its first route whose only violations, if any, are
// where the arrival rapid crosses cut contours. It passes every check when it can.
if (prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles
if (prepared.HasCutPerimeter && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles
&& RecoverPart(node, KeptSequence, source, new Attempt(null), arrivalMayCross: true).FirstOrDefault() is { } recovered)
{
var proposal = recovered.Node.Order[^1];
@@ -1100,7 +1103,8 @@ internal static class JointCuttingPlanSearch
var local = (execution?.DeparturePoint ?? arrival) - source.Location;
var before = severity;
ContourChoice chosen = null;
foreach (var contour in remaining.Count == 0 ? [prepared.PerimeterOrdinal] : remaining.Take(SafestHoleWindow))
foreach (var contour in remaining.Count == 0 ? (prepared.HasCutPerimeter ? [prepared.PerimeterOrdinal] : [])
: remaining.Take(SafestHoleWindow))
{
// Leave each hole toward the next one along the tour, so its exit rapid does
// not run back across it; the perimeter faces the next part.
@@ -24,7 +24,8 @@ internal static class SupportTabCapture
CuttingPlanRequest request, CancellationToken token)
{
var parameters = request.ConfirmedParameters;
if (prepared == null || request.SupportBed == null || parameters?.SupportTabs != true
// A display perimeter is a supplied blank: it is never cut free, so it needs no tab.
if (prepared == null || !prepared.HasCutPerimeter || request.SupportBed == null || parameters?.SupportTabs != true
|| parameters.TabConfig == null)
return prepared;
token.ThrowIfCancellationRequested();
@@ -0,0 +1,143 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// A supplied blank (a purchased door) whose outline is a display perimeter: shown and treated as
/// material, never cut. Only its cutouts are cut, each as a hole.
/// </summary>
public class DisplayPerimeterTests
{
// The door fills a 30 x 20 sheet: new Size(width, length) puts Length (30) along X.
private static Plate DoorPlate(Program program)
{
var plate = new Plate(new Size(20, 30));
plate.Parts.Add(new Part(new Drawing("door", program), Vector.Zero));
return plate;
}
internal static Program Door(LayerType outline = LayerType.DisplayPerimeter, bool cutouts = true,
LayerType? lastEdge = null)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(0, 20) { Layer = outline });
program.Codes.Add(new LinearMove(30, 20) { Layer = outline });
program.Codes.Add(new LinearMove(30, 0) { Layer = outline });
program.Codes.Add(new LinearMove(0, 0) { Layer = lastEdge ?? outline });
if (cutouts)
AddCutouts(program);
return program;
}
private static void AddCutouts(Program program)
{
program.MoveTo(5, 5); program.LineTo(5, 10); program.LineTo(10, 10); program.LineTo(10, 5); program.LineTo(5, 5);
program.MoveTo(15, 5); program.LineTo(15, 12); program.LineTo(25, 12); program.LineTo(25, 5); program.LineTo(15, 5);
program.MoveTo(9.5, 15);
program.Codes.Add(new ArcMove(new Vector(9.5, 15), new Vector(8, 15), RotationType.CW));
}
// Cutout extents, for checking where pierces and cuts land.
private static readonly Box[] Cutouts =
[
new Box(5, 5, 5, 5),
new Box(15, 5, 10, 7),
new Box(6.5, 13.5, 3, 3),
];
private static bool InsideACutout(Vector point) => Cutouts.Any(box =>
point.X >= box.Left - 1e-6 && point.X <= box.Right + 1e-6 && point.Y >= box.Bottom - 1e-6 && point.Y <= box.Top + 1e-6);
[Fact]
public void Door_PlansEveryCutoutAsAHoleAndNeverCutsTheOutline()
{
var plate = DoorPlate(Door());
var part = plate.Parts[0];
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var result = Assert.Single(proposal.Plates).Result;
Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
var motions = ExecutionMotionReader.Read(part.Program, part.Location, null, default).Motions;
Assert.DoesNotContain(motions, m => m.Layer == LayerType.DisplayPerimeter);
var pierces = motions.Where((m, i) => m.Layer == LayerType.Leadin && m.Start is { } && (i == 0 || motions[i - 1].Rapid))
.Select(m => m.Start!.Value).ToArray();
Assert.Equal(3, pierces.Length);
Assert.All(pierces, p => Assert.True(InsideACutout(p), $"Pierce ({p.X}, {p.Y}) is on the door."));
Assert.All(motions.Where(m => !m.Rapid), m => Assert.True(InsideACutout(m.End), $"Cut to ({m.End.X}, {m.End.Y}) leaves the cutouts."));
}
[Fact]
public void Door_AutomaticCutOffsAddNoLineAcrossTheBlank()
{
var plate = DoorPlate(Door());
var staging = AutomaticCutOffBatch.Stage([plate],
new AutomaticCutOffOptions { Spacing = 10, MinimumStripLength = 5 }, new CutOffSettings());
Assert.False(staging.IsBlocked);
Assert.Empty(Assert.Single(staging.Plans).Definitions);
}
[Theory]
[InlineData("mixed", "one whole closed contour")]
[InlineData("hole", "enclose every other contour")]
[InlineData("nothing", "noncutting")]
public void InvalidDisplayPerimeters_AreRefusedWithTheirReason(string layout, string reason)
{
var program = layout switch
{
"mixed" => Door(lastEdge: LayerType.Cut),
"nothing" => Door(cutouts: false),
_ => HoleOnDisplay(),
};
var plate = DoorPlate(program);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters()));
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
Assert.Contains(result.Findings, f => f.Message.Contains(reason, StringComparison.OrdinalIgnoreCase));
static Program HoleOnDisplay()
{
var program = Door(LayerType.Cut, cutouts: false);
program.MoveTo(5, 5);
foreach (var (x, y) in new[] { (5.0, 10.0), (10.0, 10.0), (10.0, 5.0), (5.0, 5.0) })
program.Codes.Add(new LinearMove(x, y) { Layer = LayerType.DisplayPerimeter });
return program;
}
}
[Fact]
public void Replay_RefusesAProgramThatCutsTheDisplayPerimeter()
{
var material = LeadMaterialSnapshot.Capture(Door(), Vector.Zero);
Assert.True(material.IsComplete, material.Reason);
var holesOnly = new Program();
AddCutouts(holesOnly);
var cutsOutline = Door(LayerType.Cut);
Assert.True(ContourProgramVerifier.Verify(
ExecutionMotionReader.ReadSupported(holesOnly, Vector.Zero, null, default), material, null, default));
Assert.False(ContourProgramVerifier.Verify(
ExecutionMotionReader.ReadSupported(cutsOutline, Vector.Zero, null, default), material, null, default));
// Same count of cut rings as a valid plan: the outline in place of the circular cutout.
var outlineForAHole = Door(LayerType.Cut, cutouts: false);
outlineForAHole.MoveTo(5, 5); outlineForAHole.LineTo(5, 10); outlineForAHole.LineTo(10, 10);
outlineForAHole.LineTo(10, 5); outlineForAHole.LineTo(5, 5);
outlineForAHole.MoveTo(15, 5); outlineForAHole.LineTo(15, 12); outlineForAHole.LineTo(25, 12);
outlineForAHole.LineTo(25, 5); outlineForAHole.LineTo(15, 5);
Assert.False(ContourProgramVerifier.Verify(
ExecutionMotionReader.ReadSupported(outlineForAHole, Vector.Zero, null, default), material, null, default));
}
}