mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-13 05:41:13 -05:00
fix(planning): raise the head out of a cutout's slug after its lead-out
A cutout's lead-out ends in its slug, so the rapid after it always crossed the cutout just cut and the strict search refused every plan with cutout lead-outs. The slug is free and may drop or tip, so emission now marks that rapid to raise the head, and the rapid check accepts a raised rapid that starts at the lead-out's end and leaves its cutout exactly once. Cutouts without a lead-out, or with an overcut, still leave from the cut head down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c4ebe49b3
commit
53d46303cd
7 files changed
+201
-12
No files matched your search
@@ -58,7 +58,7 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
|
||||
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
|
||||
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
|
||||
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
|
||||
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
|
||||
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. The one designed raise is the rapid out of a cutout's slug after its lead-out, marked at emission (see the cutting planner doc); no lead-out or an overcut keeps the head down. Every post must raise before a marked rapid or refuse the nest.
|
||||
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
|
||||
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
|
||||
|
||||
|
||||
@@ -126,11 +126,19 @@ public sealed class ReleasedContourState
|
||||
var contourNumber = contoursBefore;
|
||||
var inBreakCut = false;
|
||||
var reportedTab = false;
|
||||
// The contour last closed, and the one a lead-out has just left it into: a cutout's
|
||||
// lead-out ends in its slug, so the rapid after it must leave over that cutout's own cut.
|
||||
CutGroup lastClosed = null;
|
||||
Obstacle lastObstacle = null;
|
||||
Obstacle exited = null;
|
||||
var exitPoint = Vector.Zero;
|
||||
for (var index = 0; index < moves.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var move = moves[index];
|
||||
var owned = isFixed?.Invoke(index) == true;
|
||||
if (!move.Rapid && move.Layer != LayerType.Leadout)
|
||||
exited = null;
|
||||
if (!move.Rapid && move.Layer == LayerType.Break)
|
||||
{
|
||||
// A break cut is intentionally open and retained by its uncut tabs: it is never a
|
||||
@@ -161,8 +169,15 @@ public sealed class ReleasedContourState
|
||||
loose.Leads.AddRange(leadIns);
|
||||
leadIns.Clear();
|
||||
previous = null;
|
||||
var leaving = exited;
|
||||
exited = null;
|
||||
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
||||
continue;
|
||||
// A raised rapid out of a slug its cutout's lead-out ended in is the designed way
|
||||
// out: it may cross that cutout once, and passes over the rest with the head up.
|
||||
if (move.HeadRaised && leaving != null && start.DistanceTo(exitPoint) <= PostVerificationGeometry.Epsilon
|
||||
&& LeavesOnce(leaving, start, move.End, token))
|
||||
continue;
|
||||
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
|
||||
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
|
||||
System.Math.Max(start.Y, move.End.Y));
|
||||
@@ -217,6 +232,11 @@ public sealed class ReleasedContourState
|
||||
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
||||
"Rapid safety for that contour requires manual review."), owned);
|
||||
(previous ?? loose).Leads.Add(move.Curve);
|
||||
if (previous != null && ReferenceEquals(previous, lastClosed))
|
||||
{
|
||||
exited = lastObstacle;
|
||||
exitPoint = move.Curve.End;
|
||||
}
|
||||
}
|
||||
Finish();
|
||||
hasLead = false;
|
||||
@@ -324,8 +344,10 @@ public sealed class ReleasedContourState
|
||||
// no flag or tab configuration is used as evidence of retention.
|
||||
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
||||
{
|
||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
|
||||
lastObstacle = new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd);
|
||||
obstacles.Add(lastObstacle);
|
||||
closed.Add(current);
|
||||
lastClosed = current;
|
||||
}
|
||||
else if (!cutoff)
|
||||
unfinished.Add(current);
|
||||
@@ -333,6 +355,16 @@ public sealed class ReleasedContourState
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the rapid from <paramref name="start"/> inside <paramref name="cutout"/> to
|
||||
/// <paramref name="end"/> outside it meets its cut just once, on the way out: a rapid that
|
||||
/// leaves a re-entrant cutout and passes back over it crosses the cut where the slug may lie.
|
||||
/// </summary>
|
||||
private static bool LeavesOnce(Obstacle cutout, Vector start, Vector end, CancellationToken token) =>
|
||||
PostVerificationGeometry.Inside(start, cutout.Curves, token)
|
||||
&& !PostVerificationGeometry.Inside(end, cutout.Curves, token)
|
||||
&& PostVerificationGeometry.ContactPoints(start, end, cutout.Curves, token).Count == 1;
|
||||
|
||||
/// <summary>
|
||||
/// The closing piece of a contour cut on past its own start along its first cut, as a source
|
||||
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
|
||||
|
||||
@@ -312,17 +312,25 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var shared = Parameters;
|
||||
try
|
||||
{
|
||||
var inSlug = false;
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
// A contour's own settings apply to its emission alone; scribes and every
|
||||
// other contour keep the shared settings.
|
||||
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
|
||||
var shape = shapes[choice.ContourOrdinal];
|
||||
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
var arrival = result.Codes.Count;
|
||||
var leftInSlug = EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
|
||||
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
|
||||
exactCirclePrograms: true, scrapPierce: choice.Pierce,
|
||||
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null,
|
||||
memoContour: choice.ContourOrdinal);
|
||||
// A cutout's lead-out ends in its slug, which may drop or tip once cut free, so
|
||||
// the rapid out over the cut is made head up. An overcut or no lead-out leaves
|
||||
// from the cut itself and stays head down.
|
||||
if (inSlug && arrival < result.Codes.Count && result.Codes[arrival] is RapidMove or SubProgramCall)
|
||||
result.Codes.Insert(arrival, new Comment(HeadRaise.Marker));
|
||||
inSlug = leftInSlug;
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -441,7 +449,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
|
||||
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
|
||||
|
||||
private void EmitContour(
|
||||
/// <returns>Whether the contour is a cutout left by a lead-out, which ends in its slug.</returns>
|
||||
private bool EmitContour(
|
||||
Program program,
|
||||
Shape shape,
|
||||
Vector point,
|
||||
@@ -504,7 +513,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
|
||||
|
||||
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
|
||||
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
|
||||
var holeLeadOut = leadOut.Generate(relativePoint, normal, winding);
|
||||
subPgm.Codes.AddRange(holeLeadOut);
|
||||
subPgm.Mode = Mode.Incremental;
|
||||
|
||||
// Deduplicate: check if an identical sub-program already exists
|
||||
@@ -523,7 +533,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
return holeLeadOut.Count > 0;
|
||||
}
|
||||
|
||||
var turn = LeadTurn(contourType, winding);
|
||||
@@ -587,7 +597,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
|
||||
var leadOutCodes = leadOut.Generate(leadOutPoint, leadOutNormal, turn);
|
||||
program.Codes.AddRange(leadOutCodes);
|
||||
return contourType != ContourType.External && leadOutCodes.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,7 +6,8 @@ namespace OpenNest.CNC
|
||||
{
|
||||
/// <summary>
|
||||
/// A rapid the head must make raised because it passes over parts already cut, which may have
|
||||
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
|
||||
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing, and always
|
||||
/// marks the rapid out of a cutout's slug after its lead-out (see docs/cutting-planner.md): a
|
||||
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>, or before the
|
||||
/// <see cref="SubProgramCall"/> whose arrival rapid it is, since a sub-program may be shared and
|
||||
/// is never marked itself. Rapid checks accept a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
// A cutout's lead-out ends in its slug, so the rapid after it must leave over the cutout just cut.
|
||||
// Plan Cutting raises the head for that rapid; without a lead-out the tool leaves from the cut.
|
||||
public class CutoutLeadOutTests
|
||||
{
|
||||
private static readonly Vector Start = new(-2, -2);
|
||||
|
||||
private static Program SquareCutoutAndHole()
|
||||
{
|
||||
var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||
clean.MoveTo(3, 3);
|
||||
clean.LineTo(5, 3);
|
||||
clean.LineTo(5, 5);
|
||||
clean.LineTo(3, 5);
|
||||
clean.LineTo(3, 3);
|
||||
clean.MoveTo(8, 7.5);
|
||||
clean.ArcTo(8, 7.5, 7.5, 7.5, RotationType.CCW);
|
||||
return clean;
|
||||
}
|
||||
|
||||
private static CuttingParameters Parameters(LeadOut internalLeadOut)
|
||||
{
|
||||
var lead = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
|
||||
return new CuttingParameters
|
||||
{
|
||||
ExternalLeadIn = lead,
|
||||
InternalLeadIn = lead,
|
||||
ExternalLeadOut = new LineLeadOut { Length = 0.1, ApproachAngle = 90 },
|
||||
InternalLeadOut = internalLeadOut,
|
||||
PierceClearance = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static CuttingPlanResult Plan(LeadOut internalLeadOut, out Part part)
|
||||
{
|
||||
part = new Part(new Drawing("cutout", SquareCutoutAndHole()));
|
||||
return CuttingPlanService.Plan(new CuttingPlanRequest([part], Start,
|
||||
confirmedParameters: Parameters(internalLeadOut)));
|
||||
}
|
||||
|
||||
private static int Raises(Program program) =>
|
||||
program.Codes.Count(HeadRaise.IsMarker);
|
||||
|
||||
[Fact]
|
||||
public void CutoutLeadOutsPlanWithTheHeadRaisedOutOfEachSlug()
|
||||
{
|
||||
var result = Plan(new LineLeadOut { Length = 0.1, ApproachAngle = 90 }, out _);
|
||||
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready,
|
||||
$"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message)));
|
||||
Assert.False(result.IsSafestRouteFallback);
|
||||
Assert.Equal(0, result.HeadRaises);
|
||||
Assert.Empty(result.Findings);
|
||||
// Both cutouts are left from their slugs; the perimeter's lead-out ends outside the part.
|
||||
Assert.Equal(2, Raises(result.ProposedOrder[0].CopyProgram()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutoutsWithoutLeadOutsKeepTheHeadDown()
|
||||
{
|
||||
var result = Plan(new NoLeadOut(), out _);
|
||||
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready,
|
||||
$"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message)));
|
||||
Assert.Equal(0, Raises(result.ProposedOrder[0].CopyProgram()));
|
||||
}
|
||||
|
||||
// Pierce in the slug, cut the square cutout, lead out into the slug, then rapid away.
|
||||
private static OwnedExecution SlugExit(bool raised)
|
||||
{
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new RapidMove(4, 4));
|
||||
program.Codes.Add(new LinearMove(3, 4) { Layer = LayerType.Leadin });
|
||||
foreach (var point in new[] { new Vector(3, 3), new Vector(5, 3), new Vector(5, 5), new Vector(3, 5), new Vector(3, 4) })
|
||||
program.Codes.Add(new LinearMove(point) { Layer = LayerType.Cut });
|
||||
program.Codes.Add(new LinearMove(3.2, 4.2) { Layer = LayerType.Leadout });
|
||||
if (raised)
|
||||
program.Codes.Add(new Comment(HeadRaise.Marker));
|
||||
program.Codes.Add(new RapidMove(0, 8));
|
||||
return ExecutionMotionReader.Read(program, Vector.Zero, Vector.Zero, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHeadDownRapidOutOfASlugStillCrossesItsCutout()
|
||||
{
|
||||
var findings = new ReleasedContourState().Check(SlugExit(raised: false), Vector.Zero, 1);
|
||||
|
||||
Assert.Contains(findings, f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARaisedRapidOutOfASlugIsTheDesignedWayOut()
|
||||
{
|
||||
var (strict, _, raised) = new ReleasedContourState()
|
||||
.Check(SlugExit(raised: true), Vector.Zero, 1, false, null, default);
|
||||
|
||||
Assert.Empty(strict);
|
||||
Assert.Empty(raised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARaisedRapidThatCrossesItsCutoutAgainIsNotASlugExit()
|
||||
{
|
||||
// A C-shaped cutout: the way out of its upper arm runs down across the lower arm too.
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new RapidMove(4, 6.5));
|
||||
program.Codes.Add(new LinearMove(4, 7) { Layer = LayerType.Leadin });
|
||||
foreach (var point in new[]
|
||||
{
|
||||
new Vector(3, 7), new Vector(3, 6), new Vector(6, 6), new Vector(6, 4), new Vector(3, 4),
|
||||
new Vector(3, 3), new Vector(7, 3), new Vector(7, 7), new Vector(4, 7),
|
||||
})
|
||||
program.Codes.Add(new LinearMove(point) { Layer = LayerType.Cut });
|
||||
program.Codes.Add(new LinearMove(4.2, 6.5) { Layer = LayerType.Leadout });
|
||||
program.Codes.Add(new Comment(HeadRaise.Marker));
|
||||
program.Codes.Add(new RapidMove(4.2, 2));
|
||||
var execution = ExecutionMotionReader.Read(program, Vector.Zero, Vector.Zero, default);
|
||||
|
||||
var (_, _, raised) = new ReleasedContourState().Check(execution, Vector.Zero, 1, false, null, default);
|
||||
|
||||
Assert.Contains(raised, f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -858,9 +858,21 @@ raise the head (`RaisedRapids`, `OpenNest.CNC.HeadRaise`): a `HEAD RAISE` commen
|
||||
before the rapid in the part's program, which saves with the nest. A sub-program may be shared,
|
||||
so it is never marked: when the rapid arrives at a hole sub-program (a round hole is cut as one),
|
||||
the comment goes directly before the part's own call, and the call's arrival rapid is the raised
|
||||
one (`ExecutionMotion.CallCode` names the call a sub-program's motions run in). This is the only place a
|
||||
raise is added, so a plate the strict search can plan never gets one: it is the last resort for
|
||||
a route no search kept head-down, not a shortcut. Only rapids the planner chose in a
|
||||
one (`ExecutionMotion.CallCode` names the call a sub-program's motions run in). Apart from the
|
||||
slug exits below, this is the only place a raise is added, so a plate the strict search can plan
|
||||
never gets one: it is the last resort for a route no search kept head-down, not a shortcut.
|
||||
|
||||
A cutout's lead-out ends in its slug, which is cut free and may drop or tip, so the rapid after
|
||||
it always leaves over the cutout just cut. Emission therefore marks that rapid to raise the head
|
||||
(or the next hole's call, as above) whenever a cutout has a lead-out; the checker accepts a raised
|
||||
rapid that starts at the lead-out's end, inside the cutout, and meets its cut exactly once on the
|
||||
way out, and reports none of its crossings, in the search, in replay and in pre-post
|
||||
verification. These designed raises are not counted in `HeadRaises` or listed as notes. A raised
|
||||
rapid that crosses its cutout again (a re-entrant slug) is treated as any raised rapid, and a
|
||||
head-down one still crosses its cutout. A cutout cut without a lead-out, or with its cut run on
|
||||
along itself as an overcut, leaves from the cut and keeps the head down. A cutout cut last in a
|
||||
part with no cut perimeter leaves into the next part's arrival rapid, which emission cannot mark,
|
||||
so that rapid is still checked strictly. Only rapids the planner chose in a
|
||||
regenerated program are marked; a rapid into or inside a locked program or a cut-off, and a
|
||||
rapid inside a hole sub-program after its arrival, keeps its crossing as a finding. The checker sets a raised rapid's crossings apart
|
||||
instead of reporting them, in replay and in pre-post verification alike.
|
||||
|
||||
@@ -91,7 +91,8 @@ shows the first three check categories, even when there are no findings:
|
||||
bypass. A rapid Plan Cutting marked to raise the head (see
|
||||
[the safest-route fallback](cutting-planner.md#safest-route-fallback-when-the-search-runs-out))
|
||||
passes over cut contours with the head up, so its contour crossings are not warnings; its
|
||||
clamp crossings still are.
|
||||
clamp crossings still are. The raised rapid out of a cutout's slug after its lead-out is
|
||||
accepted the same way; a head-down one still warns that it crosses the cutout.
|
||||
- **Fixed leads:** Plan Cutting accepts fixed emissions as given and only warns (see
|
||||
[cutting planner](cutting-planner.md#fixed-emissions-warn)), so posting checks them
|
||||
again: every lead-in and lead-out of a locked program, and of each contour a part's
|
||||
|
||||
Reference in new issue
Block a user