feat(cutoffs): head-locked overcut at the far edge, reversible by Plan Cutting

Stock can arrive up to 1.5 in wider than nominal, so only the origin-side
edge is certain. A cut-off with a head-locked overcut cuts past the nominal
far edge with the head height locked, covering the oversize. With that lock,
Plan Cutting may start the far-edge segment at the far edge: overcut outward,
rapid back still locked, unlock, cut toward the origin. Without it the
direction stays fixed, as decided in Project Memory a20ea455.

- CutOff.HeadLockOvercut / FarEdgeFirst, persisted in .nest; HEAD HEIGHT
  LOCK/UNLOCK comment markers; far edge is quadrant-aware.
- Planner offers the reversed far-edge segment in the joint search, the
  safest route and segment insertion; Apply records the direction.
- Cincinnati CL and CI Fiber posts gain lock/unlock code settings and refuse
  head-locked cut-offs while either is empty.
- Automatic cut-off dialog setting (off by default, 1.5 in), kept between
  sessions and used for new cut-offs.

Lock codes and machine behaviour with the head locked are unconfirmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-23 09:51:59 -04:00
1 parent 13352b8acd
commit 993555dba6
40 files changed
+1192 -61

No files matched your search

+1
View File
@@ -49,6 +49,7 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
- 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.
- 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.
+13
View File
@@ -234,6 +234,17 @@ public static class AutomaticCutOffPlanner
if (!shifted)
fixedGridCutLength = stripCutLength;
// Lines are planned and measured without the head-locked overcut: it only extends the
// segment reaching the far edge past the sheet, where there is nothing to separate.
if (settings.NewCutOffHeadLockOvercut > 0)
{
foreach (var definition in definitions)
{
definition.HeadLockOvercut = settings.NewCutOffHeadLockOvercut;
definition.Regenerate(plate, settings, cache);
}
}
return new AutomaticCutOffPlan
{
Definitions = definitions.AsReadOnly(),
@@ -423,6 +434,8 @@ public static class AutomaticCutOffPlanner
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
"A head-locked overcut must be finite and positive.", nameof(settings));
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
Require(plate.Parts != null && plate.CutOffs != null,
"Plate parts and cut-off collections are required.", nameof(plate));
@@ -22,20 +22,39 @@ internal sealed class PlannedPartProgram
public CuttingParameters Parameters { get; }
}
/// <summary>
/// An owned cutoff segment program cut the other way at the far sheet edge
/// (<see cref="CutOff.ReverseAtFarEdge"/>); Apply installs it and records the direction on the
/// segment's cutoff definition, so regeneration keeps it.
/// </summary>
internal sealed class PlannedCutOffProgram
{
public PlannedCutOffProgram(Part segment, Program program)
{
Segment = segment;
Program = program;
}
public Part Segment { get; }
public Program Program { get; }
}
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
internal sealed class PlateCuttingPlan
{
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
IEnumerable<PlannedPartProgram> programs = null)
IEnumerable<PlannedPartProgram> programs = null, IEnumerable<PlannedCutOffProgram> cutOffs = null)
{
Expected = expected;
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
Programs = Array.AsReadOnly((programs ?? []).ToArray());
CutOffs = Array.AsReadOnly((cutOffs ?? []).ToArray());
}
public PlateCuttingState Expected { get; }
public IReadOnlyList<Part> Order { get; }
public IReadOnlyList<PlannedPartProgram> Programs { get; }
public IReadOnlyList<PlannedCutOffProgram> CutOffs { get; }
}
public enum CuttingCommitStatus
@@ -161,27 +180,49 @@ internal static class CuttingPlanCommit
}
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
}
var cutOffs = new List<(Part, Program, CutOff)>();
foreach (var planned in plan.CutOffs)
{
if (planned?.Segment == null || !onPlate.Contains(planned.Segment) || !targets.Add(planned.Segment))
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
var definition = plate.CutOffs.FirstOrDefault(c => ReferenceEquals(c?.Drawing, planned.Segment.BaseDrawing));
// Only the placed far-edge segment, cut exactly the other way, may be installed.
if (definition == null || planned.Program == null || live.Contains(planned.Program)
|| !installed.Add(planned.Program)
|| CutOff.ReverseAtFarEdge(planned.Segment.Program) is not { } expected
|| !ProgramContent.Equal(expected, planned.Program, token))
return Invalid(plate, "A cutoff segment can only be installed cut the other way at the far edge.");
cutOffs.Add((planned.Segment, planned.Program, definition));
}
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
staged.Add(new(plate, order, programs, cutOffs, reordered || programs.Count != 0 || cutOffs.Count != 0));
}
// Last cancellation point. The boundary below has no await, search or geometry work.
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
try
{
foreach (var item in staged)
{
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
item.Programs.Select(p => p.Part).Concat(item.CutOffs.Select(c => c.Segment))
.Select(part => (part, part.CaptureCuttingState())).ToArray(),
item.CutOffs.Select(c => (c.CutOff, c.CutOff.FarEdgeFirst, c.CutOff.Drawing.Program)).ToArray()));
item.Plate.Parts.SetOrder(item.Order);
foreach (var (part, program, bounds, parameters) in item.Programs)
{
beforeInstall?.Invoke(item.Plate, part);
part.InstallPlannedProgram(program, bounds, parameters);
}
foreach (var (segment, program, cutOff) in item.CutOffs)
{
beforeInstall?.Invoke(item.Plate, segment);
cutOff.InstallFarEdgeSegment(segment, program);
}
}
}
catch (Exception ex)
@@ -190,6 +231,11 @@ internal static class CuttingPlanCommit
{
foreach (var (part, state) in undo[i].States)
part.RestoreCuttingState(state);
foreach (var (cutOff, farEdgeFirst, program) in undo[i].CutOffs)
{
cutOff.FarEdgeFirst = farEdgeFirst;
cutOff.Drawing.Program = program;
}
undo[i].Plate.Parts.SetOrder(undo[i].Order);
}
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
@@ -208,5 +254,6 @@ internal static class CuttingPlanCommit
new(CuttingCommitStatus.InvalidInput, message, plate);
private sealed record Staged(Plate Plate, Part[] Order,
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs,
List<(Part Segment, Program Program, CutOff CutOff)> CutOffs, bool Changed);
}
@@ -82,7 +82,8 @@ public sealed class PlateCuttingState
}
var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.")
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit,
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings, settings);
void Fingerprint(CuttingParameters parameters)
@@ -155,7 +156,8 @@ public sealed class PlateCuttingState
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|| !Bits(cutOff.Position.Y, record.Position.Y)
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
return $"Cutoff {i + 1} definition changed.";
}
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
@@ -204,5 +206,5 @@ public sealed class PlateCuttingState
List<ContourCutPin> Pins, string PinsFingerprint);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit);
double? StartLimit, double? EndLimit, double HeadLockOvercut, bool FarEdgeFirst);
}
+103
View File
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
namespace OpenNest.CNC
{
/// <summary>
/// The head-height lock around a cut-off's overcut past the nominal far sheet edge. Stock can
/// arrive wider than nominal, so a cut-off overcuts past the far edge to cut the oversize off;
/// past the real edge there is no material to sense, so the head height is locked first. A
/// cut-off program marks the locked stretch with a <see cref="Lock"/> comment and ends it with an
/// <see cref="Unlock"/> comment; a rapid between them keeps the head locked and is not a head
/// raise. Posts write each marker as their machine's lock or unlock code and refuse to post a
/// marked program when no code is configured, because the overcut is unsafe without the lock.
/// See docs/automatic-scrap-cutoffs.md.
/// </summary>
public static class HeadHeightLock
{
/// <summary>The comment before the first motion made with the head height locked.</summary>
public const string Lock = "HEAD HEIGHT LOCK";
/// <summary>The comment after the last motion made with the head height locked.</summary>
public const string Unlock = "HEAD HEIGHT UNLOCK";
public static bool IsLock(ICode code) => code is Comment { Value: Lock };
public static bool IsUnlock(ICode code) => code is Comment { Value: Unlock };
/// <summary>Whether <paramref name="program"/>'s own codes hold a lock marker.</summary>
public static bool IsMarked(Program program)
{
if (program?.Codes == null)
return false;
foreach (var code in program.Codes)
if (IsLock(code))
return true;
return false;
}
/// <summary>
/// For each of <paramref name="codes"/>, whether the head height is locked while it runs: the
/// codes after a lock marker up to its unlock marker. Markers must alternate, starting with a
/// lock and ending unlocked; anything else is a malformed program (<see cref="ArgumentException"/>),
/// since a post that lost track of the lock could cut off the sheet unlocked or never unlock.
/// </summary>
public static bool[] LockedMask(IReadOnlyList<ICode> codes)
{
ArgumentNullException.ThrowIfNull(codes);
var mask = new bool[codes.Count];
var locked = false;
for (var i = 0; i < codes.Count; i++)
{
if (IsLock(codes[i]))
{
if (locked)
throw new ArgumentException("A head-height lock is set twice without an unlock.");
locked = true;
}
else if (IsUnlock(codes[i]))
{
if (!locked)
throw new ArgumentException("A head-height unlock has no lock before it.");
locked = false;
}
else
mask[i] = locked;
}
if (locked)
throw new ArgumentException("A head-height lock is never released.");
return mask;
}
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCodes, string postName)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part?.Program == null || !IsMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCodes)
throw new InvalidOperationException(
$"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
+ "has no head-height lock and unlock codes. Enter them in the post's configuration, "
+ "or delete the cut-off and add it again with the head-height lock off.");
try
{
LockedMask(part.Program.Codes);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
}
}
+146 -8
View File
@@ -21,6 +21,23 @@ namespace OpenNest
public double? EndLimit { get; set; }
public Drawing Drawing { get; private set; }
/// <summary>
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
/// arrive wider than nominal, so only the origin-side edge is certain: the overcut cuts the
/// oversize off. It replaces <see cref="CutOffSettings.Overtravel"/> at the far edge and
/// applies only when the line runs to that edge (no limit on that side).
/// </summary>
public double HeadLockOvercut { get; set; }
/// <summary>
/// Whether the segment reaching the far edge is cut from it toward the origin: it starts at
/// the nominal far edge, overcuts outward with the head locked, rapids back with the head
/// still locked, unlocks and cuts toward the origin. Ignored without a head-locked overcut,
/// since nothing else may start a cut at an edge whose position is unknown.
/// </summary>
public bool FarEdgeFirst { get; set; }
public CutOff(Vector position, CutOffAxis axis)
{
Position = position;
@@ -35,13 +52,14 @@ namespace OpenNest
)
{
var segments = ComputeSegments(plate, settings, cache);
var program = BuildProgram(segments, settings);
var program = BuildProgram(segments, settings, LockedFarEdge(plate));
Drawing.Program = program;
}
/// <summary>
/// The regenerated program split into its cutting segments, one rapid-and-cut program
/// each in program order, so the plate can sequence every segment on its own.
/// each in program order, so the plate can sequence every segment on its own. A rapid with
/// the head height locked returns along the overcut and stays in its segment.
/// </summary>
public IReadOnlyList<Program> SegmentPrograms()
{
@@ -50,18 +68,112 @@ namespace OpenNest
if (program == null)
return result;
Program current = null;
var locked = false;
foreach (var code in program.Codes)
{
if (code is RapidMove || current == null)
if (code is RapidMove && !locked || current == null)
{
current = new Program(program.Mode);
result.Add(current);
}
if (HeadHeightLock.IsLock(code))
locked = true;
else if (HeadHeightLock.IsUnlock(code))
locked = false;
current.Codes.Add(code);
}
return result;
}
/// <summary>
/// Whether the far edge, along this axis on <paramref name="plate"/>, is the high
/// coordinate: the sheet runs from the origin toward positive coordinates on that axis.
/// </summary>
internal static bool FarEdgeIsHigh(CutOffAxis axis, int quadrant) =>
axis == CutOffAxis.Vertical ? quadrant is 1 or 2 : quadrant is 1 or 4;
/// <summary>
/// The nominal far edge this cut-off overcuts with the head locked, along its axis, or null
/// when it has no head-locked overcut or a limit stops it short of that edge.
/// </summary>
private (double Edge, bool High)? LockedFarEdge(Plate plate)
{
if (!(HeadLockOvercut > 0) || !double.IsFinite(HeadLockOvercut))
return null;
var bounds = plate.BoundingBox(includeParts: false);
var high = FarEdgeIsHigh(Axis, plate.Quadrant);
if ((high ? EndLimit : StartLimit) != null)
return null;
var vertical = Axis == CutOffAxis.Vertical;
return (high ? (vertical ? bounds.Top : bounds.Right) : (vertical ? bounds.Bottom : bounds.Left), high);
}
/// <summary>
/// The same segment cut the other way at the far edge: a head-locked segment cut toward the
/// far edge becomes one started there, and back; null when <paramref name="segment"/> is not
/// one of the two forms <see cref="Regenerate"/> builds for the segment reaching that edge.
/// </summary>
public static Program ReverseAtFarEdge(Program segment)
{
if (segment?.Codes == null || segment.Mode != Mode.Absolute)
return null;
var codes = segment.Codes;
var result = new Program(segment.Mode);
// Toward the far edge: rapid near, cut to the edge, lock, overcut, unlock.
if (codes.Count == 5 && codes[0] is RapidMove { Suppressed: false } near
&& codes[1] is LinearMove { Suppressed: false, Layer: LayerType.Cut } toEdge
&& HeadHeightLock.IsLock(codes[2])
&& codes[3] is LinearMove { Suppressed: false, Layer: LayerType.Cut } overcut
&& HeadHeightLock.IsUnlock(codes[4]))
{
result.Codes.Add(new RapidMove(toEdge.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Lock));
result.Codes.Add(new LinearMove(overcut.EndPoint));
result.Codes.Add(new RapidMove(toEdge.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
result.Codes.Add(new LinearMove(near.EndPoint));
return result;
}
// From the far edge: rapid to the edge, lock, overcut, rapid back, unlock, cut away.
if (codes.Count == 6 && codes[0] is RapidMove { Suppressed: false } edge
&& HeadHeightLock.IsLock(codes[1])
&& codes[2] is LinearMove { Suppressed: false, Layer: LayerType.Cut } outward
&& codes[3] is RapidMove { Suppressed: false } back && back.EndPoint == edge.EndPoint
&& HeadHeightLock.IsUnlock(codes[4])
&& codes[5] is LinearMove { Suppressed: false, Layer: LayerType.Cut } away)
{
result.Codes.Add(new RapidMove(away.EndPoint));
result.Codes.Add(new LinearMove(edge.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Lock));
result.Codes.Add(new LinearMove(outward.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
return result;
}
return null;
}
/// <summary>
/// Installs Plan Cutting's choice for the far-edge segment: <paramref name="segment"/> (one
/// of this cut-off's placed parts) takes <paramref name="program"/>, the same segment cut the
/// other way, and <see cref="FarEdgeFirst"/> and the whole-line program follow it, so
/// regeneration keeps the direction. The caller has checked that the program is that flip.
/// </summary>
internal void InstallFarEdgeSegment(Part segment, Program program)
{
var codes = new List<ICode>();
foreach (var part in SegmentPrograms())
codes.AddRange((ReverseAtFarEdge(part) != null ? program : part).Codes);
var whole = new Program(Drawing.Program?.Mode ?? Mode.Absolute);
whole.Codes.AddRange(codes);
segment.ReplaceCutOffProgram(program);
FarEdgeFirst = StartsAtFarEdge(program);
Drawing.Program = whole;
}
/// <summary>Whether <paramref name="segment"/> is the far-edge segment cut from the far edge.</summary>
public static bool StartsAtFarEdge(Program segment) =>
segment?.Codes is { Count: 6 } codes && codes[0] is RapidMove && HeadHeightLock.IsLock(codes[1]);
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
internal (double Length, int SegmentCount) MeasureSegments(
Plate plate,
@@ -96,13 +208,20 @@ namespace OpenNest
Vector? position = null;
double? low = null, high = null;
var cutting = false;
var locked = false;
foreach (var code in part.Program.Codes)
{
if (HeadHeightLock.IsLock(code))
locked = true;
else if (HeadHeightLock.IsUnlock(code))
locked = false;
switch (code)
{
case RapidMove rapid:
if (cutting)
return false; // A second pierce leaves an uncut gap.
// A second pierce leaves an uncut gap; a head-locked rapid returns along
// the overcut to where the cut toward the origin begins.
if (cutting && !locked)
return false;
position = rapid.EndPoint + part.Location;
break;
case LinearMove move when position != null:
@@ -142,6 +261,8 @@ namespace OpenNest
)
{
var bounds = plate.BoundingBox(includeParts: false);
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
double lineStart,
lineEnd,
@@ -151,13 +272,13 @@ namespace OpenNest
{
cutPosition = Position.X;
lineStart = StartLimit ?? bounds.Y;
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + overtravel);
}
else
{
cutPosition = Position.Y;
lineStart = StartLimit ?? bounds.X;
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
}
var exclusions = new List<(double Start, double End)>();
@@ -459,7 +580,8 @@ namespace OpenNest
private Program BuildProgram(
List<(double Start, double End)> segments,
CutOffSettings settings
CutOffSettings settings,
(double Edge, bool High)? farEdge
)
{
var program = new Program();
@@ -476,6 +598,22 @@ namespace OpenNest
foreach (var seg in segments)
{
if (farEdge is var (edge, high) && (high ? seg.End : seg.Start) == edge)
{
// The segment reaching the far edge overcuts it with the head height locked,
// whichever way the rest of the line runs.
var near = MakePoint(cutPos, high ? seg.Start : seg.End);
var atEdge = MakePoint(cutPos, edge);
var beyond = MakePoint(cutPos, high ? edge + HeadLockOvercut : edge - HeadLockOvercut);
var towardEdge = new Program();
towardEdge.Codes.Add(new RapidMove(near));
towardEdge.Codes.Add(new LinearMove(atEdge));
towardEdge.Codes.Add(new Comment(HeadHeightLock.Lock));
towardEdge.Codes.Add(new LinearMove(beyond));
towardEdge.Codes.Add(new Comment(HeadHeightLock.Unlock));
program.Codes.AddRange((FarEdgeFirst ? ReverseAtFarEdge(towardEdge) : towardEdge).Codes);
continue;
}
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
+13
View File
@@ -12,5 +12,18 @@ namespace OpenNest
public double Overtravel { get; set; }
public double MinSegmentLength { get; set; } = 0.05;
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
/// <summary>
/// Whether new cut-offs lock the head height and overcut past the nominal far sheet edge
/// by <see cref="HeadLockOvercut"/> (see <see cref="CutOff.HeadLockOvercut"/>). Off until
/// the machine's post has head-height lock codes.
/// </summary>
public bool LockHeadHeight { get; set; }
/// <summary>How far past the nominal far edge a head-locked cut-off cuts, in model units.</summary>
public double HeadLockOvercut { get; set; } = 1.5;
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
}
}
@@ -28,12 +28,17 @@ public static class ClampCutDiagnostics
var cut = plate.CutOffs[index];
if (cut == null || !Enum.IsDefined(cut.Axis))
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
// A head-locked overcut runs on past the far edge, where an off-sheet clamp can be.
var overcut = cut.HeadLockOvercut > 0 && double.IsFinite(cut.HeadLockOvercut) ? cut.HeadLockOvercut : 0;
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
var low = farHigh ? 0 : overcut;
var high = farHigh ? overcut : 0;
var start = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low)
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y);
var end = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
: new Vector(cut.EndLimit ?? sheet.Right + high, cut.Position.Y);
foreach (var zone in zones)
if (Intersects(start, end, zone.Bounds, out var location))
findings.Add(new CutOffClampFinding(index, zone.Id, location));
@@ -12,7 +12,7 @@ public sealed class OverlapGeometryStamp
private readonly Plate plate;
private readonly Entry[] entries;
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs;
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End, long Overcut)[] cutoffs;
private readonly (long Width, long Length, int Quadrant) sheet;
private OverlapGeometryStamp(Plate plate)
@@ -25,7 +25,7 @@ public sealed class OverlapGeometryStamp
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
for (var i = 0; i < zones.Length; i++)
zones[i] = ZoneState(plate.ClampZones[i]);
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count];
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, long)[plate.CutOffs.Count];
for (var i = 0; i < cutoffs.Length; i++)
cutoffs[i] = CutOffState(plate.CutOffs[i]);
}
@@ -36,11 +36,12 @@ public sealed class OverlapGeometryStamp
zone?.Bounds is not { } box ? default
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) =>
private static (CutOff, CutOffAxis, long, long, long?, long?, long) CutOffState(CutOff cut) =>
cut == null ? default
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null);
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
Bits(cut.HeadLockOvercut));
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
+10
View File
@@ -186,6 +186,16 @@ namespace OpenNest
BoundingBox = bounds;
}
/// <summary>Installs an owned program for a cut-off segment, cut the other way; nothing else changes.</summary>
internal void ReplaceCutOffProgram(Program program)
{
var bounds = program.BoundingBox();
bounds.Offset(Location);
Program = program;
ownsProgram = true;
BoundingBox = bounds;
}
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
@@ -72,26 +72,33 @@ internal static class CutOffInsertion
// Latest gap first: a segment due late never takes a gap an earlier one needs.
foreach (var cutOff in cutOffs.OrderByDescending(c => latest[c]).ThenBy(c => c))
{
var cut = snapshot.Placements[cutOff];
var source = snapshot.Placements[cutOff];
var placed = false;
for (var gap = latest[cutOff]; gap >= 0 && !placed; gap--)
{
token.ThrowIfCancellationRequested();
countExpansion(cut);
var chain = chains[gap];
var arrival = chain is { Count: > 0 } ? chain[^1].Execution.DeparturePoint : arrivals[gap];
if (states[gap].Copy().Check(cut.Execution, arrival, cut.SourceOrdinal + 1, true, null, token).Strict.Count != 0)
continue;
if (gap < count)
// A head-locked far-edge segment may be cut from either end: the nearer first.
var variants = source.Reversed == null ? [source]
: new[] { source, source.Reversed }.OrderBy(v => v.Execution.RapidDistanceFrom(arrival)).ToArray();
foreach (var cut in variants)
{
var next = planned[gap];
var findings = states[gap].Copy().Check(next.Execution, cut.Execution.DeparturePoint,
next.SourceOrdinal + 1, next.IsCutOff, next.FixedMotions(next.Execution), token).Strict;
if (findings.Count != 0)
countExpansion(source);
if (states[gap].Copy().Check(cut.Execution, arrival, cut.SourceOrdinal + 1, true, null, token).Strict.Count != 0)
continue;
if (gap < count)
{
var next = planned[gap];
var findings = states[gap].Copy().Check(next.Execution, cut.Execution.DeparturePoint,
next.SourceOrdinal + 1, next.IsCutOff, next.FixedMotions(next.Execution), token).Strict;
if (findings.Count != 0)
continue;
}
(chains[gap] ??= []).Add(cut);
placed = true;
break;
}
(chains[gap] ??= []).Add(cut);
placed = true;
}
if (!placed)
return null;
@@ -329,6 +329,9 @@ public sealed class CuttingPlanPlateResult
public int PartCount => Result.ProposedOrder.Count;
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
/// <summary>Head-locked far-edge cutoff segments the plan cuts the other way.</summary>
public int ReversedCutOffCount => Result.ProposedOrder.Count(p => p.ReversesCutOff);
public bool OrderChanged =>
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
}
@@ -507,7 +510,9 @@ public sealed class CuttingPlanProposal
: plate.OrderChanged ? "The part order changes." : "The part order is unchanged.";
yield return heading + $"ready. {Count(plate.PartCount, "part")}, {plate.RegeneratedCount} "
+ $"regenerated, {kept} kept as is; rapid travel "
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}";
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}"
+ (plate.ReversedCutOffCount == 0 ? ""
: $" {Count(plate.ReversedCutOffCount, "cutoff segment")} at the far edge change direction.");
// A ready plan's findings are advisory notes (support tabs that may not hold a part level).
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
yield return line;
@@ -256,6 +256,16 @@ public sealed class FixedProgramPlacement
/// <summary>A cutoff that splits the whole sheet; it is cut after every other placement.</summary>
public bool SeparatesSheet { get; internal init; }
/// <summary>
/// For a cutoff segment reaching the far sheet edge with a head-locked overcut, the same
/// segment cut the other way at that edge (<see cref="CutOff.ReverseAtFarEdge"/>); the search
/// may propose it in place of this placement. Null for everything else.
/// </summary>
internal FixedProgramPlacement Reversed { get; init; }
/// <summary>Whether this proposal cuts its cutoff segment the other way from the placed program.</summary>
public bool ReversesCutOff { get; internal init; }
public Part SourcePart { get; }
public int SourceOrdinal { get; }
public Vector Location { get; }
@@ -98,9 +98,15 @@ public static class CuttingPlanService
: new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit);
}
nodes.Add(new(true, nominal, source.BoundingBox, null, null, separates));
// A head-locked far-edge segment may be cut from either end (docs/automatic-scrap-cutoffs.md).
FixedProgramPlacement reversed = null;
if (defined && CutOff.ReverseAtFarEdge(source.Program) is { } flipped)
reversed = new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
ExecutionMotionReader.ReadSupported(flipped, source.Location, request.StartPoint, token), flipped)
{ IsCutOff = true, SeparatesSheet = separates, ReversesCutOff = true };
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
cut, OwnedProgramCopy.Copy(source.Program, token))
{ IsCutOff = true, SeparatesSheet = separates });
{ IsCutOff = true, SeparatesSheet = separates, Reversed = reversed });
continue;
}
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
@@ -226,8 +232,11 @@ public static class CuttingPlanService
return new(CuttingCommitStatus.InvalidInput,
"Only Ready, independently replayed plate-scoped proposals can be applied.");
var programs = new List<PlannedPartProgram>();
var cutOffs = new List<PlannedCutOffProgram>();
foreach (var proposal in result.ProposedOrder)
{
if (proposal.ReversesCutOff)
cutOffs.Add(new(proposal.SourcePart, proposal.CopyProgram()));
if (!proposal.IsRegenerated)
continue;
if (snapshot.OwnedParameters == null)
@@ -238,7 +247,7 @@ public static class CuttingPlanService
programs.Add(new(proposal.SourcePart, proposal.CopyProgram(),
OwnedCuttingParameters.Copy(snapshot.OwnedParameters)));
}
plans.Add(new(snapshot.PlateState, result.ProposedOrder.Select(p => p.SourcePart), programs));
plans.Add(new(snapshot.PlateState, result.ProposedOrder.Select(p => p.SourcePart), programs, cutOffs));
}
return CuttingPlanCommit.Apply(plans, token, beforeInstall);
}
@@ -399,7 +408,7 @@ public static class CuttingPlanService
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
if (source.Prepared == null)
{
if (!ReferenceEquals(source, proposal))
if (!ReferenceEquals(source, proposal) && !ReferenceEquals(source.Reversed, proposal))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
}
else if (proposal.SelectedProgram == null
@@ -512,14 +512,20 @@ internal static class JointCuttingPlanSearch
token.ThrowIfCancellationRequested();
if (source.Prepared == null)
{
CountExpansion(source);
var checker = node.Checker.Copy();
if (Check(source, source.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.
yield return new(new([.. node.Order, source], source.Execution.DeparturePoint, checker, null, node,
IsSegment(source) ? node.RoutePosition : null),
source.Execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, -1);
// A head-locked far-edge cutoff segment may be cut from either end: nearer first.
var variants = source.Reversed == null ? [source]
: new[] { source, source.Reversed }.OrderBy(v => v.Execution.RapidDistanceFrom(node.Position)).ToArray();
foreach (var cut in variants)
{
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.
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);
}
continue;
}
var prepared = source.Prepared;
@@ -1154,10 +1160,19 @@ internal static class JointCuttingPlanSearch
var arrival = node.Position;
if (source.Prepared is not { } prepared)
{
CountExpansion(source);
var fixedChecker = node.Checker.Copy();
return new(new([.. node.Order, source], source.Execution.DeparturePoint, fixedChecker, null, node),
Score(source, source.Execution, arrival, fixedChecker), source.Execution.RapidDistanceFrom(arrival));
// A head-locked far-edge cutoff segment: the end that breaks fewer checks, then the nearer.
SafestStep fixedBest = null;
foreach (var cut in source.Reversed == null ? [source] : new[] { source, source.Reversed })
{
CountExpansion(source);
var fixedChecker = node.Checker.Copy();
var step = new SafestStep(new([.. node.Order, cut], cut.Execution.DeparturePoint, fixedChecker, null, node),
Score(cut, cut.Execution, arrival, fixedChecker), cut.Execution.RapidDistanceFrom(arrival));
if (fixedBest == null || step.Severity < fixedBest.Severity
|| step.Severity == fixedBest.Severity && step.Distance < fixedBest.Distance)
fixedBest = step;
}
return fixedBest;
}
// 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.
+14
View File
@@ -623,6 +623,20 @@ namespace OpenNest.IO
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int[] Segments { get; init; }
/// <summary>
/// How far past the nominal far sheet edge the cut-off cuts with the head height
/// locked (<c>CutOff.HeadLockOvercut</c>). Omitted when zero, as in older files.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double HeadLockOvercut { get; init; }
/// <summary>
/// Whether the segment reaching the far edge is cut from it toward the origin
/// (<c>CutOff.FarEdgeFirst</c>). Omitted when false, as in older files.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool FarEdgeFirst { get; init; }
}
public record SizeDto
+4
View File
@@ -656,6 +656,10 @@ namespace OpenNest.IO
{
StartLimit = cutoffDto.StartLimit,
EndLimit = cutoffDto.EndLimit,
// A nonpositive or nonfinite overcut is no head-locked overcut.
HeadLockOvercut = double.IsFinite(cutoffDto.HeadLockOvercut)
&& cutoffDto.HeadLockOvercut > 0 ? cutoffDto.HeadLockOvercut : 0,
FarEdgeFirst = cutoffDto.FarEdgeFirst,
};
plate.CutOffs.Add(cutoff);
+2
View File
@@ -386,6 +386,8 @@ namespace OpenNest.IO
EndLimit = cutoff.EndLimit,
Sequence = segments.Length > 0 ? segments.Min() : null,
Segments = consecutive ? null : segments,
HeadLockOvercut = cutoff.HeadLockOvercut,
FarEdgeFirst = cutoff.FarEdgeFirst,
}
);
}
@@ -0,0 +1,121 @@
using System.Text;
using OpenNest.Geometry;
using OpenNest.Posts.Cincinnati;
using OpenNest.Posts.CincinnatiCIFiber;
namespace OpenNest.Tests.Cincinnati;
/// <summary>
/// Both Cincinnati posts write the configured head-height lock and unlock blocks around a
/// cut-off's overcut past the far sheet edge, and refuse the nest when the blocks are not set.
/// </summary>
public class HeadLockedCutOffPostTests
{
private const string LockCode = "M210 (LOCK HEAD)";
private const string UnlockCode = "M211 (UNLOCK HEAD)";
[Fact]
public void ClPost_TowardTheFarEdge_LocksAroundTheOvercut()
{
var lines = ClLines(Nest(farEdgeFirst: false));
var locked = lines.IndexOf(LockCode);
Assert.True(locked > 0, string.Join("\n", lines));
AssertLine(lines, lines[locked - 1], "G1 X40 Y#110"); // The nominal far edge: the sheet width.
AssertLine(lines, lines[locked + 1], "G1 X40 Y61.5");
Assert.Equal(UnlockCode, lines[locked + 2]);
}
[Fact]
public void ClPost_FromTheFarEdge_KeepsTheHeadDownForTheRapidBack()
{
var lines = ClLines(Nest(farEdgeFirst: true));
var locked = lines.IndexOf(LockCode);
var unlocked = lines.IndexOf(UnlockCode);
Assert.True(locked > 0 && unlocked > locked, string.Join("\n", lines));
AssertLine(lines, lines[locked + 1], "G1 X40 Y61.5");
var between = lines.GetRange(locked, unlocked - locked);
// No head raise while the head is locked; the rapid back returns to the nominal edge.
Assert.DoesNotContain(between, line => line.Contains("M47"));
Assert.Contains(between, line => line.Contains("G0 X40 Y#110"));
AssertLine(lines, lines[unlocked + 1], "G1 X40 Y0");
}
[Fact]
public void ClPost_WithoutLockCodes_RefusesBeforeWriting()
{
using var stream = new MemoryStream();
var post = new CincinnatiPostProcessor(new CincinnatiPostConfig());
var refusal = Assert.Throws<InvalidOperationException>(() => post.Post(Nest(farEdgeFirst: false), stream));
Assert.Contains("head-height lock", refusal.Message);
Assert.Equal(0, stream.Length);
}
[Fact]
public void CiFiberPost_FromTheFarEdge_LocksAroundTheOvercutAndUnlocksBeforeCuttingBack()
{
var lines = CiFiberLines(Nest(farEdgeFirst: true));
var locked = lines.IndexOf(LockCode);
var unlocked = lines.IndexOf(UnlockCode);
Assert.True(locked > 0 && unlocked > locked, string.Join("\n", lines));
AssertLine(lines, lines[locked + 1], "G1X40Y61.5");
AssertLine(lines, lines[unlocked + 1], "G1X40Y0");
}
[Fact]
public void CiFiberPost_WithoutLockCodes_Refuses()
{
var post = new CIFiberPostProcessor(new CIFiberPostConfig());
Assert.Throws<InvalidOperationException>(() => post.Post(Nest(farEdgeFirst: false), new MemoryStream()));
}
private static Nest Nest(bool farEdgeFirst)
{
var nest = new Nest("HEADLOCK");
var plate = new Plate(60, 120);
nest.Plates.Add(plate);
plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)
{
HeadLockOvercut = 1.5,
FarEdgeFirst = farEdgeFirst,
});
plate.RegenerateCutOffs(new CutOffSettings());
return nest;
}
private static List<string> ClLines(Nest nest)
{
var post = new CincinnatiPostProcessor(new CincinnatiPostConfig
{
HeadHeightLockCode = LockCode,
HeadHeightUnlockCode = UnlockCode,
});
using var stream = new MemoryStream();
post.Post(nest, stream);
return Lines(stream);
}
private static List<string> CiFiberLines(Nest nest)
{
var post = new CIFiberPostProcessor(new CIFiberPostConfig
{
HeadHeightLockCode = LockCode,
HeadHeightUnlockCode = UnlockCode,
});
using var stream = new MemoryStream();
post.Post(nest, stream);
return Lines(stream);
}
// A posted line holds the expected move (a kerf-compensation code may come first).
private static void AssertLine(List<string> lines, string line, string move) =>
Assert.True(line.Contains(move), $"Expected '{move}' in '{line}':\n" + string.Join("\n", lines));
private static List<string> Lines(MemoryStream stream) =>
Encoding.UTF8.GetString(stream.ToArray()).Replace("\r\n", "\n").Split('\n').Select(l => l.Trim()).ToList();
}
@@ -0,0 +1,220 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CutOffs;
/// <summary>
/// A head-locked cut-off overcuts past the nominal far sheet edge, where oversize stock may still
/// be, with the head height locked; started from that edge it overcuts outward, rapids back with
/// the head still locked, unlocks and cuts toward the origin.
/// </summary>
public class HeadLockedCutOffTests
{
private const double Overcut = 1.5;
[Fact]
public void TowardTheFarEdge_OvercutsItWithTheHeadLocked()
{
var plate = new Plate(60, 120);
var cutOff = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { HeadLockOvercut = Overcut };
cutOff.Regenerate(plate, new CutOffSettings());
AssertCodes(cutOff.Drawing.Program,
"rapid 40,0", "cut 40,60", HeadHeightLock.Lock, "cut 40,61.5", HeadHeightLock.Unlock);
Assert.Single(cutOff.SegmentPrograms());
}
[Fact]
public void FromTheFarEdge_OvercutsOutwardRapidsBackLockedThenCutsTowardTheOrigin()
{
var plate = new Plate(60, 120);
var cutOff = new CutOff(new Vector(40, 0), CutOffAxis.Vertical)
{
HeadLockOvercut = Overcut,
FarEdgeFirst = true,
};
cutOff.Regenerate(plate, new CutOffSettings());
AssertCodes(cutOff.Drawing.Program, "rapid 40,60", HeadHeightLock.Lock, "cut 40,61.5", "rapid 40,60",
HeadHeightLock.Unlock, "cut 40,0");
// The locked rapid returns along the overcut; it does not start a segment of its own.
var segment = Assert.Single(cutOff.SegmentPrograms());
Assert.True(CutOff.StartsAtFarEdge(segment));
// One continuous cut across the sheet still separates it.
Assert.True(CutOff.SeparatesSheet(new Part(cutOff.Drawing, segment), cutOff, plate.BoundingBox(false)));
}
[Fact]
public void WithoutAHeadLock_FarEdgeFirstIsIgnoredAndOvertravelStillApplies()
{
var plate = new Plate(60, 120);
var cutOff = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { FarEdgeFirst = true };
cutOff.Regenerate(plate, new CutOffSettings { Overtravel = 0.5 });
AssertCodes(cutOff.Drawing.Program, "rapid 40,0", "cut 40,60.5");
}
[Fact]
public void TheHeadLockedOvercutReplacesOvertravelAtTheFarEdge()
{
var plate = new Plate(60, 120);
var cutOff = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { HeadLockOvercut = Overcut };
cutOff.Regenerate(plate, new CutOffSettings { Overtravel = 0.5 });
AssertCodes(cutOff.Drawing.Program,
"rapid 40,0", "cut 40,60", HeadHeightLock.Lock, "cut 40,61.5", HeadHeightLock.Unlock);
}
[Fact]
public void ALimitShortOfTheFarEdge_HasNoOvercut()
{
var plate = new Plate(60, 120);
var cutOff = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { HeadLockOvercut = Overcut, EndLimit = 50 };
cutOff.Regenerate(plate, new CutOffSettings());
AssertCodes(cutOff.Drawing.Program, "rapid 40,0", "cut 40,50");
}
// The far edge is the one away from the plate's origin: a quadrant-3 sheet runs from the
// origin toward negative coordinates, so its far edges are the low ones.
[Theory]
[InlineData(1, CutOffAxis.Vertical, "rapid 40,0", "cut 40,60", "cut 40,61.5")]
[InlineData(3, CutOffAxis.Vertical, "rapid -40,0", "cut -40,-60", "cut -40,-61.5")]
[InlineData(1, CutOffAxis.Horizontal, "rapid 0,30", "cut 120,30", "cut 121.5,30")]
[InlineData(2, CutOffAxis.Horizontal, "rapid 0,30", "cut -120,30", "cut -121.5,30")]
public void TheOvercutIsAtTheEdgeAwayFromTheOrigin(int quadrant, CutOffAxis axis, string rapid, string toEdge,
string overcut)
{
var plate = new Plate(60, 120) { Quadrant = quadrant };
var sign = new Vector(quadrant is 2 or 3 ? -1 : 1, quadrant is 3 or 4 ? -1 : 1);
var position = axis == CutOffAxis.Vertical ? new Vector(40 * sign.X, 0) : new Vector(0, 30 * sign.Y);
var cutOff = new CutOff(position, axis) { HeadLockOvercut = Overcut };
cutOff.Regenerate(plate, new CutOffSettings());
AssertCodes(cutOff.Drawing.Program, rapid, toEdge, HeadHeightLock.Lock, overcut, HeadHeightLock.Unlock);
}
[Fact]
public void OnlyTheSegmentReachingTheFarEdgeIsHeadLocked()
{
var part = TestHelpers.MakePartAt(35, 20, 10);
var plate = TestHelpers.MakePlate(60, 120, part);
var cutOff = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { HeadLockOvercut = Overcut, FarEdgeFirst = true };
plate.CutOffs.Add(cutOff);
plate.RegenerateCutOffs(new CutOffSettings());
var segments = CutOffSegments.Of(plate, cutOff);
Assert.Equal(2, segments.Count);
Assert.False(HeadHeightLock.IsMarked(segments[0].Part.Program));
Assert.True(CutOff.StartsAtFarEdge(segments[1].Part.Program));
Assert.Equal(61.5, segments[1].End, 6);
}
[Fact]
public void ReverseAtFarEdge_TurnsEitherFormIntoTheOther()
{
var plate = new Plate(60, 120);
var cutOff = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { HeadLockOvercut = Overcut };
cutOff.Regenerate(plate, new CutOffSettings());
var toward = cutOff.Drawing.Program;
var from = CutOff.ReverseAtFarEdge(toward);
var back = CutOff.ReverseAtFarEdge(from);
Assert.True(CutOff.StartsAtFarEdge(from));
AssertCodes(back, "rapid 40,0", "cut 40,60", HeadHeightLock.Lock, "cut 40,61.5", HeadHeightLock.Unlock);
// A plain segment has no far-edge form.
cutOff.HeadLockOvercut = 0;
cutOff.Regenerate(plate, new CutOffSettings());
Assert.Null(CutOff.ReverseAtFarEdge(cutOff.Drawing.Program));
}
[Fact]
public void AutomaticCutOffs_GetTheHeadLockedOvercutWhenItIsOn()
{
var plate = TestHelpers.MakePlate(60, 120, TestHelpers.MakePartAt(5, 5, 10));
var settings = new CutOffSettings { LockHeadHeight = true, HeadLockOvercut = Overcut };
var plan = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings);
Assert.NotEmpty(plan.Definitions);
Assert.All(plan.Definitions, definition =>
{
Assert.Equal(Overcut, definition.HeadLockOvercut);
Assert.True(HeadHeightLock.IsMarked(definition.SegmentPrograms()[^1]));
});
Assert.All(AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, new CutOffSettings())
.Definitions, definition => Assert.Equal(0, definition.HeadLockOvercut));
}
[Fact]
public void RoundTrip_KeepsTheOvercutAndDirection()
{
var nest = new Nest();
var plate = new Plate(60, 120);
nest.Plates.Add(plate);
plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { HeadLockOvercut = Overcut, FarEdgeFirst = true });
plate.CutOffs.Add(new CutOff(new Vector(80, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(new CutOffSettings());
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var loaded = Assert.Single(new NestReader(stream).Read().Plates);
Assert.Equal(new[] { Overcut, 0 }, loaded.CutOffs.Select(c => c.HeadLockOvercut));
Assert.Equal(new[] { true, false }, loaded.CutOffs.Select(c => c.FarEdgeFirst));
Assert.True(CutOff.StartsAtFarEdge(Assert.Single(TestHelpers.CutOffParts(loaded, loaded.CutOffs[0])).Program));
}
[Fact]
public void LockedMask_RefusesMarkersThatDoNotPair()
{
var unlockFirst = new ICode[] { new Comment(HeadHeightLock.Unlock) };
var neverReleased = new ICode[] { new Comment(HeadHeightLock.Lock), new LinearMove(1, 0) };
var lockedTwice = new ICode[] { new Comment(HeadHeightLock.Lock), new Comment(HeadHeightLock.Lock) };
Assert.Throws<ArgumentException>(() => HeadHeightLock.LockedMask(unlockFirst));
Assert.Throws<ArgumentException>(() => HeadHeightLock.LockedMask(neverReleased));
Assert.Throws<ArgumentException>(() => HeadHeightLock.LockedMask(lockedTwice));
Assert.Equal(new[] { false, false, true, false },
HeadHeightLock.LockedMask([new RapidMove(0, 0), new Comment(HeadHeightLock.Lock), new LinearMove(1, 0),
new Comment(HeadHeightLock.Unlock)]));
}
[Fact]
public void EnsurePostable_RefusesAHeadLockedCutOffWithoutLockCodes()
{
var nest = new Nest();
var plate = new Plate(60, 120);
nest.Plates.Add(plate);
plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { HeadLockOvercut = Overcut });
plate.RegenerateCutOffs(new CutOffSettings());
var refusal = Assert.Throws<InvalidOperationException>(() => HeadHeightLock.EnsurePostable(nest, false, "Test"));
Assert.Contains("head-height lock and unlock codes", refusal.Message);
HeadHeightLock.EnsurePostable(nest, true, "Test");
}
internal static void AssertCodes(Program program, params string[] expected) =>
Assert.Equal(expected, program.Codes.Select(Describe));
private static string Describe(ICode code) => code switch
{
Comment comment => comment.Value,
RapidMove rapid => $"rapid {Point(rapid.EndPoint)}",
LinearMove line => $"cut {Point(line.EndPoint)}",
_ => code.GetType().Name,
};
private static string Point(Vector point) =>
FormattableString.Invariant($"{System.Math.Round(point.X, 6)},{System.Math.Round(point.Y, 6)}");
}
@@ -0,0 +1,127 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// Plan Cutting may cut a head-locked cut-off's far-edge segment from either end, and Apply keeps
/// the chosen direction on the definition. Without the head lock the direction stays fixed.
/// </summary>
public class HeadLockedCutOffPlanningTests
{
[Fact]
public void Plan_StartsAHeadLockedSegmentAtTheFarEdgeWhenTheToolIsThere_AndApplyKeepsIt()
{
var (plate, cutOff) = SingleLine(headLock: 1.5);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, startPoint: new Vector(40, 60), confirmedParameters: Parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed, Describe(result));
var proposal = Assert.Single(result.ProposedOrder);
Assert.True(proposal.ReversesCutOff);
Assert.True(CutOff.StartsAtFarEdge(proposal.CopyProgram()));
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
Assert.True(cutOff.FarEdgeFirst);
var segment = Assert.Single(plate.Parts);
Assert.True(CutOff.StartsAtFarEdge(segment.Program));
Assert.True(CutOff.StartsAtFarEdge(cutOff.Drawing.Program));
// Regeneration keeps the direction, and replanning from the origin turns it back.
plate.RegenerateCutOffs(new CutOffSettings());
Assert.True(CutOff.StartsAtFarEdge(Assert.Single(plate.Parts).Program));
var back = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: Parameters));
Assert.True(back.Status == CuttingPlanStatus.Ready, Describe(back));
Assert.True(Assert.Single(back.ProposedOrder).ReversesCutOff);
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([back]).Status);
Assert.False(cutOff.FarEdgeFirst);
Assert.False(CutOff.StartsAtFarEdge(Assert.Single(plate.Parts).Program));
}
[Fact]
public void Plan_KeepsTheDirectionWhenTheToolIsAtTheOrigin()
{
var (plate, cutOff) = SingleLine(headLock: 1.5);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: Parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.False(Assert.Single(result.ProposedOrder).ReversesCutOff);
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
Assert.False(cutOff.FarEdgeFirst);
}
[Fact]
public void Plan_NeverReversesACutOffWithoutTheHeadLock()
{
var (plate, _) = SingleLine(headLock: 0);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, startPoint: new Vector(40, 60), confirmedParameters: Parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
var proposal = Assert.Single(result.ProposedOrder);
Assert.False(proposal.ReversesCutOff);
Assert.Equal(new Vector(40, 0), ((RapidMove)proposal.CopyProgram().Codes[0]).EndPoint);
}
// A line through a part is cut in segments placed before the part; only the segment reaching
// the far edge is head-locked, so only it may be started from that edge.
[Fact]
public void Plan_SegmentsBeforeAPart_OnlyTheFarEdgeSegmentMayStartAtTheFarEdge()
{
var (plate, cutOff) = SingleLine(headLock: 1.5);
plate.Parts.Add(TestHelpers.MakePartAt(35, 30, 10));
plate.RegenerateCutOffs(new CutOffSettings());
var segments = CutOffSegments.Of(plate, cutOff);
Assert.Equal(2, segments.Count);
// Segments due in the same gap chain in plate order: the far one first, from the start point.
plate.Parts.Reorder([segments[1].Part, segments[0].Part, plate.Parts.Single(p => !p.BaseDrawing.IsCutOff)]);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, startPoint: new Vector(40, 62),
confirmedParameters: Parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed, Describe(result));
var order = result.ProposedOrder.Select(p => p.SourcePart).ToList();
var part = plate.Parts.Single(p => !p.BaseDrawing.IsCutOff);
Assert.All(segments, segment => Assert.True(order.IndexOf(segment.Part) < order.IndexOf(part)));
var far = result.ProposedOrder.Single(p => ReferenceEquals(p.SourcePart, segments[1].Part));
Assert.True(far.ReversesCutOff);
Assert.False(result.ProposedOrder.Single(p => ReferenceEquals(p.SourcePart, segments[0].Part)).ReversesCutOff);
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
Assert.True(cutOff.FarEdgeFirst);
Assert.True(CutOff.StartsAtFarEdge(segments[1].Part.Program));
Assert.False(HeadHeightLock.IsMarked(segments[0].Part.Program));
}
[Fact]
public void Apply_ChangedDirectionAfterPlanning_IsStale()
{
var (plate, cutOff) = SingleLine(headLock: 1.5);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, startPoint: new Vector(40, 60), confirmedParameters: Parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
cutOff.FarEdgeFirst = true;
Assert.Equal(CuttingCommitStatus.Stale, CuttingPlanService.Apply([result]).Status);
Assert.False(CutOff.StartsAtFarEdge(Assert.Single(plate.Parts).Program));
}
private static readonly OpenNest.CNC.CuttingStrategy.CuttingParameters Parameters = ExplicitContourTests.Parameters();
private static (Plate Plate, CutOff CutOff) SingleLine(double headLock)
{
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Size = new Size(60, 120);
var cutOff = new CutOff(new Vector(40, 0), CutOffAxis.Vertical) { HeadLockOvercut = headLock };
plate.CutOffs.Add(cutOff);
plate.RegenerateCutOffs(new CutOffSettings());
return (plate, cutOff);
}
private static string Describe(CuttingPlanResult r) =>
$"{r.Status}, expanded {r.Expansions}: " + string.Join("; ", r.Findings.Select(f => f.Message));
}
+6 -3
View File
@@ -53,7 +53,7 @@ namespace OpenNest.Actions
private void OnMouseMove(object sender, MouseEventArgs e)
{
var pt = plateView.CurrentPoint;
previewCutOff = new CutOff(pt, lockedAxis);
previewCutOff = new CutOff(pt, lockedAxis) { HeadLockOvercut = settings.NewCutOffHeadLockOvercut };
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
plateView.Invalidate();
}
@@ -64,7 +64,7 @@ namespace OpenNest.Actions
return;
var pt = plateView.CurrentPoint;
var cutoff = new CutOff(pt, lockedAxis);
var cutoff = new CutOff(pt, lockedAxis) { HeadLockOvercut = settings.NewCutOffHeadLockOvercut };
plateView.Plate.CutOffs.Add(cutoff);
plateView.Plate.RegenerateCutOffs(settings);
@@ -80,7 +80,10 @@ namespace OpenNest.Actions
if (previewCutOff != null)
{
previewCutOff = new CutOff(plateView.CurrentPoint, lockedAxis);
previewCutOff = new CutOff(plateView.CurrentPoint, lockedAxis)
{
HeadLockOvercut = settings.NewCutOffHeadLockOvercut,
};
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
plateView.Invalidate();
}
+29
View File
@@ -0,0 +1,29 @@
namespace OpenNest
{
/// <summary>
/// Whether new cut-offs lock the head height and overcut the far sheet edge, kept between
/// sessions with the overcut in inches and converted to each nest's units.
/// </summary>
internal static class CutOffHeadLockDefaults
{
private const double DefaultOvercutInches = 1.5;
private const double MillimetersPerInch = 25.4;
internal static void Load(CutOffSettings settings, Units units)
{
var inches = Properties.Settings.Default.CutOffHeadLockOvercutInches;
if (!double.IsFinite(inches) || inches <= 0)
inches = DefaultOvercutInches;
settings.LockHeadHeight = Properties.Settings.Default.CutOffLockHeadHeight;
settings.HeadLockOvercut = units == Units.Millimeters ? inches * MillimetersPerInch : inches;
}
internal static void Save(bool lockHeadHeight, double overcut, Units units)
{
Properties.Settings.Default.CutOffLockHeadHeight = lockHeadHeight;
Properties.Settings.Default.CutOffHeadLockOvercutInches =
units == Units.Millimeters ? overcut / MillimetersPerInch : overcut;
Properties.Settings.Default.Save();
}
}
}
+28 -1
View File
@@ -29,6 +29,8 @@ partial class AutomaticCutOffForm
minimumStripBox = new TextBox();
minimumTailLabel = new Label();
minimumTailBox = new TextBox();
headLockBox = new CheckBox();
headLockOvercutBox = new TextBox();
statusLabel = new Label();
metricsPanel = new TableLayoutPanel();
occupiedCaption = new Label();
@@ -110,10 +112,13 @@ partial class AutomaticCutOffForm
spacingPanel.Controls.Add(minimumStripBox, 1, 1);
spacingPanel.Controls.Add(minimumTailLabel, 0, 2);
spacingPanel.Controls.Add(minimumTailBox, 1, 2);
spacingPanel.Controls.Add(headLockBox, 0, 3);
spacingPanel.Controls.Add(headLockOvercutBox, 1, 3);
spacingPanel.Dock = DockStyle.Fill;
spacingPanel.Margin = new Padding(0, 0, 0, 6);
spacingPanel.Name = "spacingPanel";
spacingPanel.RowCount = 3;
spacingPanel.RowCount = 4;
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
@@ -164,6 +169,26 @@ partial class AutomaticCutOffForm
minimumTailBox.TabIndex = 5;
minimumTailBox.TextChanged += SettingsBox_TextChanged;
//
// headLockBox
//
headLockBox.Anchor = AnchorStyles.Left;
headLockBox.AutoSize = true;
headLockBox.Name = "headLockBox";
headLockBox.TabIndex = 6;
headLockBox.Text = "Lock head height and overcut the far edge by:";
toolTip.SetToolTip(headLockBox,
"Stock can arrive wider than nominal. New cut-offs cut past the far sheet edge by this much with "
+ "the head height locked, and Plan Cutting may start them there. Posting needs the post's "
+ "head-height lock codes.");
headLockBox.CheckedChanged += HeadLockBox_CheckedChanged;
//
// headLockOvercutBox
//
headLockOvercutBox.Dock = DockStyle.Fill;
headLockOvercutBox.Name = "headLockOvercutBox";
headLockOvercutBox.TabIndex = 7;
headLockOvercutBox.TextChanged += SettingsBox_TextChanged;
//
// statusLabel
//
statusLabel.AutoSize = true;
@@ -409,6 +434,8 @@ partial class AutomaticCutOffForm
private TextBox minimumStripBox;
private Label minimumTailLabel;
private TextBox minimumTailBox;
private CheckBox headLockBox;
private TextBox headLockOvercutBox;
private Label statusLabel;
private TableLayoutPanel metricsPanel;
private Label occupiedCaption;
+42 -1
View File
@@ -19,6 +19,7 @@ public partial class AutomaticCutOffForm : Form
private readonly Plate plate;
private readonly Nest nest;
private readonly Func<bool> isOperationBusy;
private readonly Units units;
private readonly string unit;
private bool minimumStripEdited;
private bool settingMinimumStrip;
@@ -59,6 +60,7 @@ public partial class AutomaticCutOffForm : Form
plate = plateView.Plate ?? throw new ArgumentException("An active plate is required.", nameof(plateView));
this.nest = nest;
this.isOperationBusy = isOperationBusy;
this.units = units;
unit = UnitsHelper.GetShortString(units);
InitializeComponent();
spacingLabel.Text = $"Nominal spacing along sheet length ({unit}):";
@@ -66,6 +68,10 @@ public partial class AutomaticCutOffForm : Form
minimumStripLabel.Text = $"Minimum strip ({unit}):";
minimumTailLabel.Text = $"Minimum tail to keep ({unit}):";
minimumTailBox.Text = (units == Units.Millimeters ? 304.8 : 12).ToString(CultureInfo.CurrentCulture);
headLockBox.Text = $"Lock head height and overcut the far edge by ({unit}):";
headLockBox.Checked = plateView.CutOffSettings.LockHeadHeight;
headLockOvercutBox.Text = plateView.CutOffSettings.HeadLockOvercut.ToString(CultureInfo.CurrentCulture);
headLockOvercutBox.Enabled = headLockBox.Checked;
sheetLabel.Text = $"Sheet: {Measure(plate.Size.Length)} length × {Measure(plate.Size.Width)} width";
if (nest != null)
{
@@ -116,7 +122,7 @@ public partial class AutomaticCutOffForm : Form
throw new InvalidOperationException("The active plate is no longer in this nest. Close this dialog and try again.");
var options = ParseOptions();
var settings = plateView.CutOffSettings;
var settings = WithHeadLock(plateView.CutOffSettings);
var plans = apply
? AutomaticCutOffBatch.Apply(plates, options, settings)
: AutomaticCutOffBatch.Create(plates, options, settings);
@@ -140,6 +146,39 @@ public partial class AutomaticCutOffForm : Form
};
}
// The view's settings with this dialog's head-lock choice; the view keeps its own until OK.
private CutOffSettings WithHeadLock(CutOffSettings current)
{
var overcut = current.HeadLockOvercut;
if (headLockBox.Checked && (!double.TryParse(headLockOvercutBox.Text, NumberStyles.Float, CultureInfo.CurrentCulture,
out overcut) || !double.IsFinite(overcut) || overcut <= 0))
throw new ArgumentException($"Enter a positive head-locked overcut in {unit}.");
return new CutOffSettings
{
PartClearance = current.PartClearance,
Overtravel = current.Overtravel,
MinSegmentLength = current.MinSegmentLength,
CutDirection = current.CutDirection,
LockHeadHeight = headLockBox.Checked,
HeadLockOvercut = overcut,
};
}
// New cut-offs from here on, including Plan Cutting's and the manual tool's, use the choice.
private void KeepHeadLock()
{
var chosen = WithHeadLock(plateView.CutOffSettings);
plateView.CutOffSettings.LockHeadHeight = chosen.LockHeadHeight;
plateView.CutOffSettings.HeadLockOvercut = chosen.HeadLockOvercut;
CutOffHeadLockDefaults.Save(chosen.LockHeadHeight, chosen.HeadLockOvercut, units);
}
private void HeadLockBox_CheckedChanged(object sender, EventArgs e)
{
headLockOvercutBox.Enabled = headLockBox.Checked;
SettingsBox_TextChanged(sender, e);
}
private string DiagnosticLine(AutomaticCutOffPlan plan) => string.Join(Environment.NewLine,
plan.Diagnostics.Select(d =>
$"{(d.IsBlocking ? "BLOCKING" : "Notice")} [{d.Code}]"
@@ -318,6 +357,7 @@ public partial class AutomaticCutOffForm : Form
// Validate exactly as planning will, without changing any plate.
Calculate(apply: false);
SelectedOptions = ParseOptions();
KeepHeadLock();
DialogResult = DialogResult.OK;
Close();
}
@@ -339,6 +379,7 @@ public partial class AutomaticCutOffForm : Form
if (plans.Any(p => p.HasBlockingDiagnostics) || plans.All(p => p.Definitions.Count == 0))
return;
KeepHeadLock();
DialogResult = DialogResult.OK;
Close();
}
+1
View File
@@ -428,6 +428,7 @@ namespace OpenNest.Forms
Document = new Document { Nest = nest };
// Units are read when each check starts, matching the manual command.
PlateView.SetOverlapAutoCheck(() => Nest.Units);
CutOffHeadLockDefaults.Load(PlateView.CutOffSettings, nest.Units);
PlateManager = new PlateManager(nest);
PlateManager.CurrentPlateChanged += PlateManager_CurrentPlateChanged;
+26
View File
@@ -358,5 +358,31 @@ namespace OpenNest.Properties {
this["CuttingPlanEffort"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("False")]
// New cut-offs lock the head height and overcut the far sheet edge.
public bool CutOffLockHeadHeight {
get {
return ((bool)(this["CutOffLockHeadHeight"]));
}
set {
this["CutOffLockHeadHeight"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("1.5")]
// How far a head-locked cut-off overcuts the far sheet edge, in inches.
public double CutOffHeadLockOvercutInches {
get {
return ((double)(this["CutOffHeadLockOvercutInches"]));
}
set {
this["CutOffHeadLockOvercutInches"] = value;
}
}
}
}
+6
View File
@@ -86,5 +86,11 @@
<Setting Name="CuttingPlanEffort" Type="System.Double" Scope="User">
<Value Profile="(Default)">1</Value>
</Setting>
<Setting Name="CutOffLockHeadHeight" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
<Setting Name="CutOffHeadLockOvercutInches" Type="System.Double" Scope="User">
<Value Profile="(Default)">1.5</Value>
</Setting>
</Settings>
</SettingsFile>
@@ -123,11 +123,26 @@ public sealed class CincinnatiFeatureWriter
currentPos = piercePoint;
// 6. Lead-in + contour moves with kerf comp and feedrate variables
var headLocked = false;
foreach (var code in ctx.Codes)
{
if (code is RapidMove)
continue; // skip rapids in contour (already handled above)
// A cut-off's overcut past the far sheet edge runs with the head height locked.
if (HeadHeightLock.IsLock(code))
{
writer.WriteLine(_config.HeadHeightLockCode);
headLocked = true;
continue;
}
if (HeadHeightLock.IsUnlock(code))
{
writer.WriteLine(_config.HeadHeightUnlockCode);
headLocked = false;
continue;
}
if (code is LinearMove linear)
{
var sb = new StringBuilder();
@@ -230,8 +245,9 @@ public sealed class CincinnatiFeatureWriter
if (_config.UseAntiDive)
writer.WriteLine("M131 (ANTI DIVE ON)");
// 10. Head raise (unless last feature on sheet)
if (!ctx.IsLastFeatureOnSheet)
// 10. Head raise (unless last feature on sheet). A locked head stays down for the
// rapid back along a cut-off's overcut; the next feature unlocks it.
if (!ctx.IsLastFeatureOnSheet && !headLocked)
WriteM47(writer, ctx);
}
@@ -191,6 +191,24 @@ namespace OpenNest.Posts.Cincinnati
)]
public double? SafetyHeadraiseRapidDistance { get; set; } = 12.0;
[Category("7. Safety")]
[DisplayName("Head Height Lock Code")]
[Description(
"Block that locks the head height before a cut-off overcuts past the far sheet edge. Empty = not set: a nest with head-locked cut-offs is refused."
)]
public string HeadHeightLockCode { get; set; } = "";
[Category("7. Safety")]
[DisplayName("Head Height Unlock Code")]
[Description(
"Block that releases the head height lock after a cut-off's overcut. Empty = not set: a nest with head-locked cut-offs is refused."
)]
public string HeadHeightUnlockCode { get; set; } = "";
/// <summary>Whether both head-height lock blocks are set.</summary>
public bool HasHeadHeightLockCodes() =>
!string.IsNullOrWhiteSpace(HeadHeightLockCode) && !string.IsNullOrWhiteSpace(HeadHeightUnlockCode);
[Category("8. Pallet")]
[DisplayName("Pallet Exchange")]
[Description("When pallet exchange occurs (None, EndOfSheet, or StartAndEnd).")]
@@ -95,6 +95,9 @@ namespace OpenNest.Posts.Cincinnati
public void Post(Nest nest, Stream outputStream)
{
// Refuse before writing anything: a head-locked overcut needs the lock codes.
HeadHeightLock.EnsurePostable(nest, Config.HasHeadHeightLockCodes(), Name);
// 1. Create variable manager and register standard variables
var vars = CreateVariableManager();
@@ -252,6 +255,7 @@ namespace OpenNest.Posts.Cincinnati
{
// Refuse before creating the file, so a refusal never empties an existing program.
DisplayPerimeterTail.EnsurePostable(nest);
HeadHeightLock.EnsurePostable(nest, Config.HasHeadHeightLockCodes(), Name);
using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
Post(nest, fs);
}
@@ -21,6 +21,8 @@
"M47OverrideDistanceThreshold": null,
"SafetyHeadraiseDistance": 2000,
"SafetyHeadraiseRapidDistance": 12.0,
"HeadHeightLockCode": "",
"HeadHeightUnlockCode": "",
"PalletExchange": "EndOfSheet",
"LeadInFeedratePercent": 0.5,
"LeadInArcLine2FeedratePercent": 0.5,
@@ -35,6 +35,13 @@ namespace OpenNest.Posts.CincinnatiCIFiber
/// <summary>True when the contour is an external perimeter (exterior).</summary>
public bool IsExterior { get; set; }
/// <summary>
/// Head-height lock changes in a cut-off's overcut: each is written before the cut at
/// <c>Before</c> (<see cref="Cuts"/>.Count: after the last cut), locking when
/// <c>Lock</c> is true and unlocking otherwise.
/// </summary>
public List<(int Before, bool Lock)> HeadLocks { get; } = new();
}
/// <summary>
@@ -101,6 +108,13 @@ namespace OpenNest.Posts.CincinnatiCIFiber
if (code is Motion suppressed && suppressed.Suppressed)
continue;
if (HeadHeightLock.IsLock(code) || HeadHeightLock.IsUnlock(code))
{
current ??= new CIFiberContour { Pierce = origin };
current.HeadLocks.Add((current.Cuts.Count, HeadHeightLock.IsLock(code)));
continue;
}
switch (code)
{
case RapidMove rapid:
@@ -171,6 +171,26 @@ namespace OpenNest.Posts.CincinnatiCIFiber
[PostSetting(MacrosSection, 6)]
public string ProgramEndMacro { get; set; } = "PROGRAMEND.NC";
/// <summary>Block that locks the head height before a cut-off overcuts past the far sheet edge.</summary>
[DisplayName("Head height lock")]
[Description(
"Written before a cut-off overcuts past the far sheet edge. Blank = not set: a nest with head-locked cut-offs is refused."
)]
[PostSetting(MacrosSection, 7)]
public string HeadHeightLockCode { get; set; } = "";
/// <summary>Block that releases the head height lock after a cut-off's overcut.</summary>
[DisplayName("Head height unlock")]
[Description(
"Written after a cut-off's overcut, back at the nominal sheet edge. Blank = not set: a nest with head-locked cut-offs is refused."
)]
[PostSetting(MacrosSection, 8)]
public string HeadHeightUnlockCode { get; set; } = "";
/// <summary>Whether both head-height lock blocks are set.</summary>
public bool HasHeadHeightLockCodes() =>
!string.IsNullOrWhiteSpace(HeadHeightLockCode) && !string.IsNullOrWhiteSpace(HeadHeightUnlockCode);
/// <summary>
/// Material name (case-insensitive) to machine material code map for
/// V.E.MATERIAL. The setter re-keys any assigned map (including one
@@ -73,6 +73,7 @@ namespace OpenNest.Posts.CincinnatiCIFiber
if (nest == null)
throw new ArgumentNullException(nameof(nest));
HeadHeightLock.EnsurePostable(nest, _config.HasHeadHeightLockCodes(), "Cincinnati CI Fiber");
Write(nest, PostedSheets(nest), 1, w);
}
@@ -87,6 +88,7 @@ namespace OpenNest.Posts.CincinnatiCIFiber
if (sheet == null)
throw new ArgumentNullException(nameof(sheet));
HeadHeightLock.EnsurePostable(nest, _config.HasHeadHeightLockCodes(), "Cincinnati CI Fiber");
Write(nest, new[] { sheet }, sheetNumber, w);
}
@@ -320,12 +322,23 @@ namespace OpenNest.Posts.CincinnatiCIFiber
prev = contour.LeadIn.EndPoint;
}
foreach (var motion in contour.LeadInExtra.Concat(contour.Cuts))
foreach (var motion in contour.LeadInExtra)
{
CIFiberFormatter.Line(w, FormatMotion(motion, prev));
prev = motion.EndPoint;
}
// The overcut past the far sheet edge runs with the head height locked.
for (var i = 0; i <= contour.Cuts.Count; i++)
{
foreach (var (_, locks) in contour.HeadLocks.Where(change => change.Before == i))
CIFiberFormatter.Line(w, locks ? _config.HeadHeightLockCode : _config.HeadHeightUnlockCode);
if (i == contour.Cuts.Count)
break;
CIFiberFormatter.Line(w, FormatMotion(contour.Cuts[i], prev));
prev = contour.Cuts[i].EndPoint;
}
CIFiberFormatter.Line(w, SkippableLine(_config.LayerCutEnd));
}
@@ -16,6 +16,8 @@
"LayerCutEnd": "ZHSOFF",
"ProgramStartMacro": "PROGRAMSTART.NC",
"ProgramEndMacro": "PROGRAMEND.NC",
"HeadHeightLockCode": "",
"HeadHeightUnlockCode": "",
"MaterialCodes": {
"Carbon Steel": "MSN",
"Mild Steel": "MSN"
+16 -1
View File
@@ -41,6 +41,21 @@ Each trimmed segment of a cut-off is materialized as a part of its own with its
- Internal-hole scrap is not processed. This command reuses the outside-skeleton behavior of manual cutoffs; it does not force cuts through parts or remove clearance to guarantee separation.
- Review the preview and posted NC using the normal machine-review process before production cutting.
## Head-locked overcut at the far edge
A cut-off starts only where an edge is certain. The origin-side edge always is. Stock often arrives up to 1.5 in wider than nominal, so the far edge (the one away from the plate's origin, by quadrant) may still have material past it. Without a head-height lock a cut-off never starts at the far edge, and Plan Cutting never reverses one.
**Lock head height and overcut the far edge by** in the automatic cut-off dialog (also opened from Plan Cutting's cut-off settings) turns on a head-locked overcut for cut-offs created afterwards: automatic, Plan Cutting's staged cut-offs, and the manual **Sheet Cut-Off** tool. The choice and amount (1.5 in, or 38.1 mm in a metric nest, by default) are kept between sessions. Existing cut-offs keep theirs; delete and re-add one to change it. Each cut-off stores its own overcut (`CutOff.HeadLockOvercut`), so loading a nest regenerates it exactly.
Only the segment that reaches the far edge is affected, and only when no limit stops the line short of it. The overcut replaces **Overtravel** there.
- Cut toward the far edge (the default): rapid to the segment's start, cut to the nominal far edge, lock the head height, cut on past the edge by the overcut, unlock.
- Started at the far edge (`CutOff.FarEdgeFirst`): rapid to the nominal far edge (for a 48-in sheet that is really 48.5 in, the 48-in mark), lock, cut outward past the edge by the overcut, rapid back to the nominal edge with the head still locked, unlock, cut toward the origin.
Plan Cutting may cut a head-locked far-edge segment either way: the joint search, the safest-route fallback and segment placement each try the nearer end first and keep whichever passes the checks (the safest route takes the one breaking fewer). The plan summary reports how many far-edge segments change direction. Apply installs the segment's program and records the direction on its cut-off, so regenerating, saving and loading keep it. A plan made without confirmed cutting parameters only reorders and never reverses. A direction changed after planning makes the plan stale.
The program marks the locked stretch with `HEAD HEIGHT LOCK` and `HEAD HEIGHT UNLOCK` comments (`HeadHeightLock`). Posts write their configured lock and unlock codes there and refuse a nest with a head-locked cut-off when the codes are not set; see [Cincinnati CL](cincinnati-post-output.md#head-locked-cut-off-overcuts) and [CI Fiber](cincinnati-ci-fiber-post-output.md). The overcut leg lies outside the nominal sheet; the clamp-contact check includes it. Machine behavior with the head locked (piercing at the nominal edge, beam off and rapid back) is unconfirmed: verify on the machine before production.
## Part sequencing
For **Plate > Sequence Parts > Manual Sequence**, choose the one-based sequence number and hover a part on the sheet; only the part under the pointer is outlined. Click it to place it at that exact position, then the number advances up to the last part. Clicking empty sheet space changes nothing. Use **Done**, the window close button, right-click on the sheet, or Escape to leave the tool. Manual reordering changes neither geometry nor quantities.
@@ -65,7 +80,7 @@ Rerunning unchanged suppresses equivalent existing full-span lines. A limited cu
In the plate view, a left-to-right selection window selects a cut-off only when it encloses every visible cut segment; a right-to-left window selects it when it crosses any visible segment (not an empty gap). Cut-offs and parts can be selected together and deleted together. Clicking a cut-off still starts its existing single-line drag.
Saving `.nest` retains ordinary cutoff positions, axes, limits, and the sequence place of every segment (see [the file format](nest-file-format.md#cut-offs)). Loading regenerates cutoff programs using the reader's default cutoff settings; exact toolpaths made with nondefault cutoff settings are not guaranteed to round-trip, and a cut-off that regenerates into a different number of segments loads them consecutively from its first saved place. Review regenerated paths before posting.
Saving `.nest` retains ordinary cutoff positions, axes, limits, head-locked overcut and far-edge direction, and the sequence place of every segment (see [the file format](nest-file-format.md#cut-offs)). Loading regenerates cutoff programs using the reader's default cutoff settings; exact toolpaths made with nondefault cutoff settings are not guaranteed to round-trip, and a cut-off that regenerates into a different number of segments loads them consecutively from its first saved place. Review regenerated paths before posting.
## Implementation boundary
+8
View File
@@ -47,6 +47,14 @@ uses the Cincinnati machine-sample convention, not a generic TF5200 laser API:
centreline and has no inside or outside. Whether the `L4` macro runs
correctly without a following lead-in move has not been confirmed on the
machine.
- A head-locked cut-off overcut (see
[automatic cutoffs](automatic-scrap-cutoffs.md#head-locked-overcut-at-the-far-edge))
writes the `Head height lock` block before the overcut's `G1` and the
`Head height unlock` block where the program unlocks: after the overcut, or,
for a segment started at the far edge, after the rapid back and before the
`G1` toward the origin. Both blocks are blank by default, and a nest with a
head-locked cut-off is refused while either is blank. Whether `ZHSOFF` and the
next contour's `L0` keep a locked head down for the rapid back is unconfirmed.
- Hole subprogram geometry is inlined. Suppressed moves and, by default,
wholly scribe contours are omitted.
- Files use UTF-8 without a BOM and CRLF lines. Default accuracy is three decimal
+15
View File
@@ -109,6 +109,21 @@ block in a sheet or sub-program output follows this order
10. Head raise before the rapid to the next feature, unless this is the last
feature on the sheet (§3.47). See [Head raises](#head-raises-m47).
### Head-locked cut-off overcuts
A cut-off with a head-locked overcut (see
[automatic cutoffs](automatic-scrap-cutoffs.md#head-locked-overcut-at-the-far-edge))
writes `HeadHeightLockCode` where its program locks the head and
`HeadHeightUnlockCode` where it unlocks, each as its own block inside the feature
loop: after the pierce, between cut moves. A feature that ends with the head
locked gets no head raise, so the rapid back along the overcut keeps the head
down; the next feature pierces at the nominal edge and then unlocks before it cuts
toward the origin. Both codes are empty by default. A nest with a head-locked
cut-off is refused before anything is written while either is empty
(`HeadHeightLock.EnsurePostable`). The codes and the machine's behavior when it
pierces, beams off or rapids with the head locked are unconfirmed; enter and
verify them on the machine before relying on the overcut.
## Head raises (M47)
Every raise is written at the end of the feature it follows, before the rapid
+5 -1
View File
@@ -76,7 +76,11 @@ segments are not consecutive, as after Plan Cutting cuts a strip line in pieces
the route passes them. Readers without `segments` place the segments consecutively
from `sequence`, which is still a dependency-safe order. A `segments` list whose length
differs from the regenerated segment count (nondefault cut-off settings, a moved part)
places the segments consecutively from its smallest entry.
places the segments consecutively from its smallest entry. `headLockOvercut`
(model units) and `farEdgeFirst` record a head-locked overcut past the far sheet
edge and whether its segment starts at that edge (see
[automatic cutoffs](automatic-scrap-cutoffs.md#head-locked-overcut-at-the-far-edge));
both are omitted when zero or false, and older files load without them.
## Plate cutting parameters