diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 782bde45..066227c8 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -52,7 +52,7 @@ internal static class JointCuttingPlanSearch { var inserted = CutOffInsertion.Insert(snapshot, outcome.Order, attached, walk.CountExpansion, token); if (inserted != null) - return walk.Ready(inserted); + return walk.Ready(walk.FollowSegments(inserted)); } catch (BudgetExceededException) { @@ -523,8 +523,9 @@ internal static class JointCuttingPlanSearch CountExpansion(source); var checker = node.Checker.Copy(); if (Check(cut, cut.Execution, node.Position, checker, attempt)) - // A segment is transparent: the part after it ranks its entries and hole route - // from where the tool was before the segment, as planned without it. + // A segment is transparent to the part after it: that part ranks its outside entry + // from where the tool was before the segment, as planned without it. Its hole + // route starts where the tool is, the segment's end, as FollowSegments plans it. yield return new(new([.. node.Order, cut], cut.Execution.DeparturePoint, checker, null, node, IsSegment(cut) ? node.RoutePosition : null), cut.Execution.RapidDistanceFrom(node.Position), cut.SourceOrdinal, -1, -1); @@ -559,7 +560,7 @@ internal static class JointCuttingPlanSearch } var preference = node.Active?.Preference; if (node.Active?.Perimeter is { } outside && preference == null) - preference = PlanHoles(node.Active, outside); + preference = PlanHoles(node.Active.Source, node.Active.Boundary.Position, outside); var choices = node.Active?.Choices ?? []; var arrival = node.Active?.Arrival ?? node.Position; var before = node.Active?.Before ?? node.Checker; @@ -1216,12 +1217,13 @@ internal static class JointCuttingPlanSearch return ordered; } - private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter) + // The hole route and preferred hole entries from the tool's global arrival onto the part, + // ending at the outside entry. + private HolePreference PlanHoles(FixedProgramPlacement source, Vector arrival, ContourChoice perimeter) { - var source = active.Source; var prepared = source.Prepared; var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray(); - var localArrival = active.Boundary.RoutePosition - source.Location; + var localArrival = arrival - source.Location; var centres = CuttingHoleOrder.Centres(prepared, localArrival, token); var endpoint = perimeter.Point; var entries = new Dictionary(); @@ -1255,6 +1257,72 @@ internal static class JointCuttingPlanSearch return new(route, entries, pierces, endpoint); } + /// + /// The order with segments placed, where each holed part cut straight after a segment + /// takes the hole route and entries planned from the segment's end, where the tool + /// really is. The free search planned the part as if the segment were not there, from + /// where the tool was before it. The part's outside entry and the order stay, so the + /// route is still the one planned without the lines; a kept order replanned later plans + /// the same holes from the same point. A part keeps its planned holes when the new + /// route has no preferred entry for every hole or does not pass the checks. + /// + internal IReadOnlyList FollowSegments(IReadOnlyList order) + { + var followed = order.ToArray(); + var checker = new ReleasedContourState(reportMissingLeadIns: false); + var position = snapshot.StartPoint; + try + { + for (var index = 0; index < followed.Length; index++) + { + if (index > 0 && IsSegment(followed[index - 1]) + && FromSegment(followed[index], position, checker) is { } replanned) + followed[index] = replanned; + var placement = followed[index]; + checker.Check(placement.Execution, position, placement.SourceOrdinal + 1, placement.IsCutOff, + placement.FixedMotions(placement.Execution), token); + position = placement.Execution.DeparturePoint; + } + } + catch (BudgetExceededException) + { + // The plan is complete without this refinement; keep it as placed. + return order; + } + return followed; + } + + // The part re-planned from arrival with its outside entry kept, or null to keep it. + private FixedProgramPlacement FromSegment(FixedProgramPlacement placement, Vector arrival, + ReleasedContourState checker) + { + var source = snapshot.Placements[placement.SourceOrdinal]; + var prepared = source.Prepared; + if (prepared is not { HasCutPerimeter: true } || prepared.PerimeterOrdinal == 0 + || placement.ContourChoices.Count != prepared.Count + || placement.ContourChoices[^1].ContourOrdinal != prepared.PerimeterOrdinal) + return null; + var perimeter = placement.ContourChoices[^1]; + var preference = PlanHoles(source, arrival, perimeter); + if (preference.Entries.Count != prepared.PerimeterOrdinal) + return null; + var choices = preference.Route.Select(hole => preference.Entries[hole]).Append(perimeter).ToArray(); + Program program; + OwnedExecution execution; + try + { + CountExpansion(source); + program = prepared.Emit(choices); + execution = ExecutionMotionReader.Read(program, source.Location, arrival, token); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException) + { + return null; + } + return Check(source, execution, arrival, checker.Copy(), new Attempt(null), choices) + ? source.Propose(program, execution, choices, token) : null; + } + // The placements cut at a node, and the excluded ones, which count as cut throughout. private HashSet Finished(Node node) { @@ -1568,8 +1636,8 @@ internal static class JointCuttingPlanSearch ContourChoice Perimeter = null, HolePreference Preference = null); /// /// A search state; Previous links a whole-part boundary to the boundary before it. Anchor, - /// when set, is where the tool was before the cut-off segments cut last: entries and hole - /// routes rank from , every rapid is checked from Position. + /// when set, is where the tool was before the cut-off segments cut last: outside entries + /// rank from ; hole routes and every rapid start from Position. /// private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker, ActivePart Active, Node Previous, Vector? Anchor = null) diff --git a/OpenNest.Tests/CuttingPlanning/CutOffSegmentPlanningTests.cs b/OpenNest.Tests/CuttingPlanning/CutOffSegmentPlanningTests.cs index 714af7ee..f9144293 100644 --- a/OpenNest.Tests/CuttingPlanning/CutOffSegmentPlanningTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CutOffSegmentPlanningTests.cs @@ -109,6 +109,65 @@ public class CutOffSegmentPlanningTests Assert.Equal(result.RapidDistance, kept.RapidDistance, 6); } + // A frame with two small holes on its left strip, one above the other, and a window, + // between a locked square A at the top left, cut before it, and a locked square B to its + // right, cut after it. A line through the frame's right strip stops halfway up, so its one + // segment runs from the bottom edge to just below the frame and is cut straight before it. + // Planned without the segment, from A, the frame would cut its upper hole first; the tool + // is really at the bottom, so it cuts the lower hole first instead of crossing its own + // rapid. Replanned with the order kept, the frame plans the same holes from the same point. + [Fact] + public void PartAfterSegment_PlansItsHolesFromTheSegmentsEnd() + { + var parameters = ExplicitContourTests.Parameters(); + var nest = new Nest(); + var plate = nest.CreatePlate(); + plate.Size = new Size(60, 120); + plate.Parts.Add(LockedSquare("A", new Vector(2, 42), topLeft: false)); + var frame = new Part(new Drawing("frame", Frame()), new Vector(30, 5)); + plate.Parts.Add(frame); + plate.Parts.Add(LockedSquare("B", new Vector(80, 40), topLeft: false)); + var cutOff = new CutOff(new Vector(65, 0), CutOffAxis.Vertical) { EndLimit = 25 }; + plate.CutOffs.Add(cutOff); + plate.RegenerateCutOffs(new CutOffSettings()); + PlateSequencing.PlaceCutOffs(plate, [cutOff]); + Assert.Single(CutOffSegments.Of(plate, cutOff)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters)); + + Assert.True(result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed, Describe(result)); + var order = result.ProposedOrder.Select(o => o.SourcePart.BaseDrawing.IsCutOff ? "s" : o.SourcePart.BaseDrawing.Name); + Assert.Equal("A s frame B", string.Join(" ", order)); + var planned = result.ProposedOrder[2]; + var rapids = planned.Execution.Motions.Where(m => m.Rapid).Select(m => m.End).ToArray(); + // Into the lower hole (y 14.5 to 15.1 on the sheet), then the upper one (34.5 to 35.1). + Assert.InRange(rapids[0].Y, 14, 16); + Assert.InRange(rapids[1].Y, 34, 36); + + Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status); + var kept = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters, + preservePartOrder: true)); + Assert.True(kept.Status == CuttingPlanStatus.Ready && kept.IndependentlyReplayed, Describe(kept)); + Assert.Equal(result.RapidDistance, kept.RapidDistance, 6); + } + + // 40 x 40 with 0.6 square holes at (2.5, 9.5) and (2.5, 29.5) and an 18 x 30 window. + private static Program Frame() + { + var program = new Program(); + void Loop(params Vector[] points) + { + program.MoveTo(points[0].X, points[0].Y); + foreach (var point in points.Skip(1).Append(points[0])) + program.LineTo(point.X, point.Y); + } + Loop(new(0, 0), new(0, 40), new(40, 40), new(40, 0)); + foreach (var y in new[] { 29.5, 9.5 }) + Loop(new(2.5, y), new(3.1, y), new(3.1, y + 0.6), new(2.5, y + 0.6)); + Loop(new(12, 5), new(30, 5), new(30, 35), new(12, 35)); + return program; + } + private const double Gap = 0.25, Width = 10.6, Neck = 5.6, Height = 20, Margin = 0.25; // The slanted shoulders need more horizontal room than the gap. private const double Pitch = (Width + Neck) / 2 + 2 * Gap; diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 88b96b18..482862b5 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -247,7 +247,14 @@ segment that must precede some part (`CutOffInsertion`) goes as late as it can b the first part in its span of the line, into the latest gap where both rapids cross nothing cut before that point: the part after it keeps the program it was planned with, and only its incoming rapid is checked again from the segment's end. Segments due in the -same gap are chained in the order placed, latest-due first. This keeps cutting local: +same gap are chained in the order placed, latest-due first. A holed part cut straight +after a segment was planned from where the tool was before the segment, but the tool is +at the segment's end, often across the part from there: its holes were routed toward the +wrong side and the rapid onto its first hole could cross the rapids between the others. +So once the segments are placed, each such part keeps its outside entry and its place in +the order, and takes the hole route and preferred hole entries planned from the +segment's end; it keeps its planned holes when that route has no preferred entry for +every hole or fails the checks. This keeps cutting local: the segment below a row of parts is cut while the route passes it, not up front, and never before a part it need not precede unless the geometry allows nothing later (a segment whose pierce is walled off by every part cut after a point goes before that @@ -258,8 +265,8 @@ separator or a line through open scrap precedes nothing and is planned like a pa separator after everything, the other at its tour position. Wherever a segment sits in an order, the search looks through it: the part before it -faces the next part, not the segment, and the part after it ranks its entries and hole -route from where the tool was before the segment, while every rapid is still checked +faces the next part, not the segment, and the part after it ranks its outside entry +from where the tool was before the segment, while its hole route and every rapid start from the segment's end. A kept order with segments where a plan put them (an applied plan replanned with `Keep the current part order`) therefore gets the entries and routes the plan had, and replays Ready again.