diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs index 5c1fbdff..d053251b 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs @@ -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. diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs index aedf2715..2530c648 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs @@ -12,8 +12,9 @@ namespace OpenNest.CNC.CuttingPlanning; public sealed class LeadMaterialSnapshot { private LeadMaterialSnapshot(IReadOnlyList rings, IReadOnlyList tabs, - string reason) + string reason, bool displayPerimeter = false) { + HasDisplayPerimeter = displayPerimeter; Rings = rings; Tabs = tabs; Reason = reason; @@ -41,6 +42,12 @@ public sealed class LeadMaterialSnapshot /// internal IReadOnlyList Tabs { get; } + /// + /// True when the perimeter ([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. + /// + internal bool HasDisplayPerimeter { get; } + /// Capture a stable, clean, rotation-baked program, applying location once. /// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws. public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location, @@ -53,6 +60,8 @@ public sealed class LeadMaterialSnapshot var ringTabs = new List(); var chain = new List(); var chainTabs = new List(); + var ringDisplay = new List(); + var chainDisplay = new List(); 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) diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 3d20ebac..e8836eab 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -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 scribes, List breakCuts, List tabbedCutouts, CuttingParameters parameters) + : this(shapes, scribes, breakCuts, tabbedCutouts, parameters, hasCutPerimeter: true) { + } + + private PreparedContours(Shape[] shapes, List scribes, List breakCuts, List 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 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(); @@ -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; /// Whether the emitted perimeter is tabbed: by the plate settings, its pin or support. - internal bool PerimeterTabbed => ParametersFor(PerimeterOrdinal).TabsEnabled; + internal bool PerimeterTabbed => hasCutPerimeter && ParametersFor(PerimeterOrdinal).TabsEnabled; public int Count => shapes.Length; /// The pierce zone every lead-in pierce of these contours keeps; 0 when off. internal double PierceZoneRadius => parameters.GetPierceZoneRadius(); - public int PerimeterOrdinal => Count - 1; + /// + /// The cut perimeter, emitted after every hole; (no contour) when the outside + /// is a display perimeter, so every contour below it is a hole. + /// + public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count; + + /// False when the outside is a display perimeter that is never cut: all contours are holes. + public bool HasCutPerimeter => hasCutPerimeter; /// Resolved pins this preparation could not honour: a tab on an internal contour. public IReadOnlyList RejectedPins { get; } = Array.Empty(); /// True when a drawing pin fixes this contour's entry, leads and tab. - 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; /// The one-based drawing pin that fixes this contour, or null. public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null; @@ -215,6 +237,7 @@ public sealed class PreparedContours var scribes = new List(); var breakCuts = new List(); var tabbedCutouts = new List(); + 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); } /// Open break cuts this preparation cuts before its contours. @@ -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); } diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 4aa28dd7..0cf123a7 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -251,14 +251,14 @@ namespace OpenNest.CNC.CuttingStrategy internal Program EmitPrepared(Shape[] shapes, List scribes, IReadOnlyList choices, IReadOnlyList contourParameters = null, IReadOnlyList breakCuts = null, - IReadOnlyList tabbedCutouts = null) + IReadOnlyList 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()) 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 scribes, - CuttingPlanning.ContourChoice choice, IReadOnlyList contourParameters = null) + CuttingPlanning.ContourChoice choice, IReadOnlyList 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 choices, IReadOnlyList contourParameters) + IReadOnlyList choices, IReadOnlyList 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); } } diff --git a/OpenNest.Core/CNC/LayerType.cs b/OpenNest.Core/CNC/LayerType.cs index 461b2ab6..d50a2aae 100644 --- a/OpenNest.Core/CNC/LayerType.cs +++ b/OpenNest.Core/CNC/LayerType.cs @@ -21,5 +21,14 @@ namespace OpenNest.CNC /// leaves it out, cutting the rest of the contour as open pieces. /// Tab, + + /// + /// 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 + /// , 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. + /// + DisplayPerimeter, } } diff --git a/OpenNest.Core/Converters/ConvertGeometry.cs b/OpenNest.Core/Converters/ConvertGeometry.cs index 66f99f5b..046ba195 100644 --- a/OpenNest.Core/Converters/ConvertGeometry.cs +++ b/OpenNest.Core/Converters/ConvertGeometry.cs @@ -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; } } diff --git a/OpenNest.Core/Converters/ConvertProgram.cs b/OpenNest.Core/Converters/ConvertProgram.cs index 4ed6936f..cf80d61f 100644 --- a/OpenNest.Core/Converters/ConvertProgram.cs +++ b/OpenNest.Core/Converters/ConvertProgram.cs @@ -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()); } diff --git a/OpenNest.Core/SpecialLayers.cs b/OpenNest.Core/SpecialLayers.cs index f882d01f..9390a62a 100644 --- a/OpenNest.Core/SpecialLayers.cs +++ b/OpenNest.Core/SpecialLayers.cs @@ -24,12 +24,16 @@ namespace OpenNest /// A tabbed cutout's uncut tabs: material boundary that is never cut. public static readonly Layer Tab = new Layer("TAB") { Color = Color.Orange }; + /// A supplied blank's shown-only outside edge: material boundary that is never cut. + public static readonly Layer DisplayPerimeter = new Layer("DISPLAY PERIMETER") { Color = Color.DarkCyan }; + /// /// 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. /// public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break; } diff --git a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs index b8b4403e..31639bf2 100644 --- a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs +++ b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs @@ -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)); diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 927de574..8b0a4762 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -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. diff --git a/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs index a84deb76..1ab1a952 100644 --- a/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs +++ b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs @@ -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(); diff --git a/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs new file mode 100644 index 00000000..56598c66 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterTests.cs @@ -0,0 +1,143 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// 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. +/// +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)); + } +}