feat(cutoffs): apply nest-wide with minimum retained tail

This commit is contained in:
aj
2026-09-28 23:43:06 -04:00
parent 4afab63046
commit c3dd346b7a
12 changed files with 735 additions and 92 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ Keep vendor programming manuals and full-text extracts outside source control un
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. After its null/empty guards, `DefaultFillComparer` decides unequal counts without scoring; equal counts still use scores, and exact ties retain the current layout. `FillHelpers.FillPattern` computes eager scores only when no custom comparer is supplied; custom comparers remain authoritative and may perform their own scoring.
- **Extents column pitch**: for finite valid geometry, finite pair height, and finite nonnegative spacing, `FillExtents.BuildColumn` uses `pair.Bbox.Width + partSpacing` directly. The old vertical slide calculation clamps to the same pitch, so it need not prepare boundaries or temporary test clones. Negative/nonfinite spacing or nonfinite pair height retains the legacy calculation: public/interactive callers do not all validate spacing. Do not remove `BuildPair` boundary preparation or the adjusted-column overlap fallback, or turn this shortcut into a geometry/validation policy change.
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds each at its previous index in `Plate.Parts` (its cut sequence number; new cut-offs go at the end). Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load; `CutOffDto.Sequence` restores each one's place in the cut sequence). Posts must follow `Plate.Parts` order for cut-offs too, not move them to the end.
- **Automatic scrap cutoffs**: `AutomaticCutOffPlanner.Create` in Core proposes detached vertical `CutOff` definitions and preview parts from the real-part envelope, with quadrant-aware pitch and a verified full-width tail separator beyond `max(PartSpacing, PartClearance)` plus tolerance. Planning must not mutate the plate. Apply only an unblocked, current plan by adding its definitions to `Plate.CutOffs` and calling `Plate.RegenerateCutOffs`; never accept preview Parts directly. Existing equivalent full-span lines are suppressed, while same-line limited cuts require manual review. Nominal spacing is not certification of disconnected hopper-sized scrap. See [operator workflow and limitations](docs/automatic-scrap-cutoffs.md).
- **Automatic scrap cutoffs**: `AutomaticCutOffPlanner.Create` in Core proposes detached vertical `CutOff` definitions and preview parts from the real-part envelope, with quadrant-aware pitch and a verified full-width tail separator beyond `max(PartSpacing, PartClearance)` plus tolerance. Planning must not mutate the plate. Apply only an unblocked, current plan by adding its definitions to `Plate.CutOffs` and calling `Plate.RegenerateCutOffs`; never accept preview Parts directly. Existing equivalent full-span lines are suppressed, while same-line limited cuts require manual review. Nominal spacing is not certification of disconnected hopper-sized scrap. Both Plate and Nest menus use the modal `AutomaticCutOffForm`; Nest applies one setting set to every plate through Core's `AutomaticCutOffBatch`, which replans all plates before any changes, refuses the entire batch on blocking diagnostics, and rolls back cutoff programs/sequence on failure. Minimum tail-to-keep defaults to 12 in / 304.8 mm in the dialog; `AutomaticCutOffOptions.MinimumTailLength` is in model units (zero disables the minimum). A shorter tail suppresses only the new final separator, never other grid lines or existing definitions. See [operator workflow and limitations](docs/automatic-scrap-cutoffs.md).
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). MCP, API, and Training projects use `ImportDrawing` for headless conversion. The console uses `Import` followed by `BuildDrawing` so it can report bend-repair outcomes. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` and the saved `SCRIBE` layer (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
+119
View File
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest;
/// <summary>
/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates.
/// Callers must prevent concurrent edits for the duration of either operation.
/// </summary>
public static class AutomaticCutOffBatch
{
/// <summary>Detached plans in plate order. Invalid input identifies the one-based plate number.</summary>
public static IReadOnlyList<AutomaticCutOffPlan> Create(IReadOnlyList<Plate> plates,
AutomaticCutOffOptions options, CutOffSettings settings)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(settings);
if (plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
throw new ArgumentException("Each plate must occur only once.", nameof(plates));
var plans = new List<AutomaticCutOffPlan>(plates.Count);
for (var index = 0; index < plates.Count; index++)
{
try
{
plans.Add(AutomaticCutOffPlanner.Create(plates[index], options, settings));
}
catch (ArgumentException error)
{
throw new ArgumentException($"Plate {index + 1}: {error.Message}", nameof(plates), error);
}
}
return plans.AsReadOnly();
}
/// <summary>
/// Replans all plates before changing any. A blocking plan returns without applying any
/// definitions; empty/unchanged plates are untouched. On failure, restores cutoff state
/// on every touched plate, reporting explicitly if any restoration also fails.
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
/// </summary>
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
AutomaticCutOffOptions options, CutOffSettings settings)
{
var plans = Create(plates, options, settings);
if (plans.Any(plan => plan.HasBlockingDiagnostics))
return plans;
var restoreActions = new List<(int PlateNumber, Action Restore)>();
try
{
for (var index = 0; index < plates.Count; index++)
{
var plan = plans[index];
if (plan.Definitions.Count == 0)
continue;
var plate = plates[index];
// Register recovery before the first observable mutation, including AddRange.
restoreActions.Add((index + 1, CaptureRestore(plate, plan)));
plate.CutOffs.AddRange(plan.Definitions);
plate.RegenerateCutOffs(settings);
}
}
catch (Exception applyError)
{
var rollbackErrors = new List<Exception>();
for (var index = restoreActions.Count - 1; index >= 0; index--)
{
var saved = restoreActions[index];
try
{
saved.Restore();
}
catch (Exception rollbackError)
{
// A broken observer on one plate must not prevent recovery of the others.
rollbackErrors.Add(new InvalidOperationException(
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
}
}
if (rollbackErrors.Count > 0)
throw new InvalidOperationException(
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
+ string.Join("; ", rollbackErrors.Select(e => e.Message))
+ "\nThe nest may be incomplete; review it before saving or cutting.",
new AggregateException(new[] { applyError }.Concat(rollbackErrors)));
throw new InvalidOperationException(
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
}
return plans;
}
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
{
// Regeneration replaces drawing programs and removes/reinserts cutoff parts.
// Save only that state; real parts, poses, programs and quantities stay untouched.
var programs = plate.CutOffs.Select(c => (CutOff: c, Program: c.Drawing.Program)).ToArray();
var parts = plate.Parts.Select((part, index) => (Part: part, Index: index))
.Where(p => p.Part.BaseDrawing.IsCutOff).ToArray();
return () =>
{
foreach (var definition in plan.Definitions)
plate.CutOffs.Remove(definition);
for (var index = plate.Parts.Count - 1; index >= 0; index--)
{
if (plate.Parts[index].BaseDrawing.IsCutOff)
plate.Parts.RemoveAt(index);
}
foreach (var saved in programs)
saved.CutOff.Drawing.Program = saved.Program;
foreach (var saved in parts)
plate.Parts.Insert(saved.Index, saved.Part);
};
}
}
+19 -1
View File
@@ -15,6 +15,12 @@ public sealed class AutomaticCutOffOptions
/// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count.
/// </summary>
public double Spacing { get; set; }
/// <summary>
/// Minimum retained-tail length along X in model units. Zero permits any positive tail.
/// The desktop default is 12 inches (304.8 mm).
/// </summary>
public double MinimumTailLength { get; set; }
}
public enum AutomaticCutOffDiagnosticCode
@@ -24,6 +30,7 @@ public enum AutomaticCutOffDiagnosticCode
EmptyCut,
SegmentedCut,
NoSafeTailSeparator,
TailBelowMinimum,
}
public sealed record AutomaticCutOffDiagnostic(
@@ -133,7 +140,16 @@ public static class AutomaticCutOffPlanner
AddCandidate(sign * distance, false);
}
if (hasTailCandidate)
AddCandidate(sign * separator, true);
{
if (length - separator >= options.MinimumTailLength)
AddCandidate(sign * separator, true);
else
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
"The proposed tail is shorter than the minimum tail length; the final separator was skipped. "
+ "Other automatic lines and existing cut-offs are unchanged. No retained tail is claimed.",
X: sign * separator));
}
if (diagnostics.Any(d => d.IsBlocking))
{
@@ -219,6 +235,8 @@ public static class AutomaticCutOffPlanner
{
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
Require(Nonnegative(options.MinimumTailLength),
"Minimum tail length must be finite and nonnegative.", nameof(options));
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
"Sheet length and width must be positive and finite.", nameof(plate));
@@ -0,0 +1,216 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CutOffs;
public class AutomaticCutOffBatchTests
{
private static readonly AutomaticCutOffOptions Options = new() { Spacing = 35 };
[Fact]
public void Apply_AllPlates_UsesEachSheetAndPreservesRealPartsAndManualSequence()
{
var settings = new CutOffSettings();
var plates = new[] { CreatePlate(1, 36, 120), CreatePlate(3, 50, 100), new Plate(60, 120) };
var realParts = plates.SelectMany(p => p.Parts).ToArray();
var realPrograms = realParts.Select(p => p.Program).ToArray();
var quantities = realParts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var manual = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
plates[0].CutOffs.Add(manual);
plates[0].RegenerateCutOffs(settings);
var manualPart = plates[0].Parts[^1];
plates[0].Parts.Remove(manualPart);
plates[0].Parts.Insert(0, manualPart);
var preview = AutomaticCutOffBatch.Create(plates, Options, settings);
Assert.Same(manual, Assert.Single(plates[0].CutOffs));
Assert.Empty(plates[1].CutOffs);
Assert.Empty(preview[2].Definitions);
var plans = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.All(plans, p => Assert.False(p.HasBlockingDiagnostics));
Assert.Equal(3, plans[0].Definitions.Count);
Assert.Equal(3, plans[1].Definitions.Count);
Assert.Same(manual, plates[0].CutOffs[0]);
Assert.Same(manual.Drawing, plates[0].Parts[0].BaseDrawing);
Assert.Same(realParts[0], plates[0].Parts[1]);
Assert.Same(realParts[1], plates[1].Parts[0]);
Assert.All(plates.Take(2), p => Assert.Equal(p.CutOffs.Count, p.Parts.Count(x => x.BaseDrawing.IsCutOff)));
Assert.All(plates[1].CutOffs, c => Assert.True(c.Position.X < 0));
var tailProgram = plates[1].Parts[^1].Program;
Assert.Equal(50, tailProgram.BoundingBox().Width, 6);
Assert.Empty(plates[2].Parts);
Assert.Empty(plates[2].CutOffs);
Assert.Equal(realPrograms, realParts.Select(p => p.Program));
Assert.Equal(quantities, realParts.Select(p => p.BaseDrawing.Quantity.Nested));
Assert.All(preview.SelectMany(p => p.PreviewParts), p =>
Assert.DoesNotContain(p, plates.SelectMany(plate => plate.Parts)));
var originalParts = plates.Select(p => p.Parts.ToArray()).ToArray();
var repeated = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.All(repeated, p => Assert.Empty(p.Definitions));
for (var i = 0; i < plates.Length; i++)
Assert.Equal(originalParts[i], plates[i].Parts.ToArray());
var nest = new Nest("batch-cutoffs");
foreach (var part in realParts)
nest.Drawings.Add(part.BaseDrawing);
nest.Plates.AddRange(plates);
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var loaded = new NestReader(stream).Read();
// The existing writer omits empty plates.
Assert.Equal(2, loaded.Plates.Count);
for (var i = 0; i < loaded.Plates.Count; i++)
{
Assert.Equal(plates[i].CutOffs.Select(c => (c.Position.X, c.Axis, c.StartLimit, c.EndLimit)),
loaded.Plates[i].CutOffs.Select(c => (c.Position.X, c.Axis, c.StartLimit, c.EndLimit)));
Assert.Equal(plates[i].Parts.Select(p => p.BaseDrawing.IsCutOff),
loaded.Plates[i].Parts.Select(p => p.BaseDrawing.IsCutOff));
}
}
[Fact]
public void Apply_ConflictOnLaterPlate_ChangesNothingOnAnyPlate()
{
var plates = new[] { CreatePlate(), CreatePlate() };
var settings = new CutOffSettings();
var preview = AutomaticCutOffBatch.Create(plates, Options, settings);
Assert.All(preview, p => Assert.NotEmpty(p.Definitions));
var limited = new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 };
plates[1].CutOffs.Add(limited);
var beforeParts = plates.Select(p => p.Parts.ToArray()).ToArray();
var result = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.True(result[1].HasBlockingDiagnostics);
Assert.Empty(plates[0].CutOffs);
Assert.Same(limited, Assert.Single(plates[1].CutOffs));
for (var i = 0; i < plates.Length; i++)
Assert.Equal(beforeParts[i], plates[i].Parts.ToArray());
}
[Fact]
public void Apply_InvalidLaterPlate_ReportsPlateNumberBeforeChangingAnything()
{
var plates = new[] { CreatePlate(), CreatePlate() };
plates[1].Parts[0].Offset(500, 0);
var error = Assert.Throws<ArgumentException>(() =>
AutomaticCutOffBatch.Apply(plates, Options, new CutOffSettings()));
Assert.Contains("Plate 2:", error.Message);
Assert.All(plates, p => Assert.Empty(p.CutOffs));
Assert.All(plates, p => Assert.Single(p.Parts));
}
[Fact]
public void Apply_ReplansCurrentPartsAndSettingsInsteadOfAcceptingPreview()
{
var plates = new[] { CreatePlate(), CreatePlate() };
var settings = new CutOffSettings();
var preview = AutomaticCutOffBatch.Create(plates, Options, settings);
plates[1].Parts[0].Offset(5, 0);
settings.PartClearance = 2;
var expected = AutomaticCutOffPlanner.Create(plates[1], Options, settings);
var result = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.NotEqual(preview[1].TailSeparatorX, result[1].TailSeparatorX);
Assert.Equal(expected.Definitions.Select(c => c.Position.X), plates[1].CutOffs.Select(c => c.Position.X));
}
[Fact]
public void Apply_RegenerationFailureOnLaterPlate_RestoresEveryTouchedPlate()
{
var plates = new[] { CreatePlate(), CreatePlate(), CreatePlate() };
var settings = new CutOffSettings();
foreach (var plate in plates)
{
plate.CutOffs.Add(new CutOff(new Vector(5, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(settings);
var cutoffPart = plate.Parts[^1];
plate.Parts.Remove(cutoffPart);
plate.Parts.Insert(0, cutoffPart);
}
var beforeParts = plates.Select(p => p.Parts.ToArray()).ToArray();
var beforeDefinitions = plates.Select(p => p.CutOffs.ToArray()).ToArray();
var beforePrograms = plates.Select(p => p.CutOffs[0].Drawing.Program).ToArray();
var quantities = plates.Select(p => p.Parts[1].BaseDrawing.Quantity.Nested).ToArray();
var failOnce = true;
plates[1].PartAdded += (_, e) =>
{
if (failOnce && e.Item.BaseDrawing.IsCutOff)
{
failOnce = false;
throw new InvalidOperationException("Injected regeneration failure");
}
};
var error = Assert.Throws<InvalidOperationException>(() =>
AutomaticCutOffBatch.Apply(plates, Options, settings));
Assert.False(failOnce);
Assert.Contains("original cut-offs were restored", error.Message);
for (var i = 0; i < plates.Length; i++)
{
Assert.Equal(beforeParts[i], plates[i].Parts.ToArray());
Assert.Equal(beforeDefinitions[i], plates[i].CutOffs.ToArray());
Assert.Same(beforePrograms[i], plates[i].CutOffs[0].Drawing.Program);
Assert.Equal(quantities[i], plates[i].Parts[1].BaseDrawing.Quantity.Nested);
}
}
[Fact]
public void Apply_MinimumTailIsEvaluatedIndependentlyForEachPlate()
{
var plates = new[] { CreatePlate(length: 90), CreatePlate(), new Plate(36, 90) };
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 };
var result = AutomaticCutOffBatch.Apply(plates, options, new CutOffSettings());
Assert.False(result[0].HasSeparatedTail);
Assert.Equal(new[] { 35.0, 70.0 }, plates[0].CutOffs.Select(c => c.Position.X));
Assert.True(result[1].HasSeparatedTail);
Assert.Equal(3, plates[1].CutOffs.Count);
Assert.Empty(plates[2].CutOffs);
Assert.Empty(AutomaticCutOffBatch.Apply(plates, options, new CutOffSettings()).SelectMany(p => p.Definitions));
}
[Fact]
public void Apply_RollbackFailureIsExplicitAndDoesNotStopRecoveryOfOtherPlates()
{
var plates = new[] { CreatePlate(), CreatePlate() };
var originals = plates.Select(p => p.Parts.ToArray()).ToArray();
plates[1].PartAdded += (_, _) => throw new InvalidOperationException("Apply observer failed");
plates[1].CutOffs.ItemRemoved += (_, _) => throw new InvalidOperationException("Rollback observer failed");
var error = Assert.Throws<InvalidOperationException>(() =>
AutomaticCutOffBatch.Apply(plates, Options, new CutOffSettings()));
Assert.Contains("Restoring cut-offs also failed", error.Message);
Assert.Contains("Plate 2", error.Message);
Assert.Contains("may be incomplete", error.Message);
Assert.IsType<AggregateException>(error.InnerException);
Assert.Empty(plates[0].CutOffs);
Assert.Equal(originals[0], plates[0].Parts.ToArray());
}
private static Plate CreatePlate(int quadrant = 1, double width = 36, double length = 120)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(70, 0));
program.Codes.Add(new LinearMove(70, 20));
program.Codes.Add(new LinearMove(0, 20));
program.Codes.Add(new LinearMove(0, 0));
var plate = new Plate(width, length) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 };
var drawing = new Drawing($"rectangle-q{quadrant}", program);
var location = new Vector(quadrant is 2 or 3 ? -80 : 10, quadrant is 3 or 4 ? -28 : 8);
plate.Parts.Add(new Part(drawing, location));
return plate;
}
}
@@ -68,6 +68,93 @@ public class AutomaticCutOffPlannerTests
Assert.Equal(new Vector(separator, 81), segment.To);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MinimumTail_SkipsOnlyFinalSeparatorWithoutExtendingSkeletonGrid(int quadrant)
{
// The proposed separator is just before X=105, with a tail shorter than 16.
// Skipping it must not add the otherwise-next nominal line at X=105.
var plate = MakePlate(quadrant, occupied: 104);
var before = Plan(plate);
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 16 };
var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
Assert.Equal(before.Definitions.Take(before.Definitions.Count - 1).Select(c => c.Position.X), Positions(plan));
Assert.False(plan.HasSeparatedTail);
Assert.Null(plan.TailSeparatorX);
Assert.Equal(0, plan.TailLength);
Assert.Equal(120, plan.UsedSpan);
Assert.Contains(plan.Diagnostics, d => d.Message.Contains("minimum tail length"));
Assert.Empty(plate.CutOffs);
Assert.Single(plate.Parts);
}
[Theory]
[InlineData(1, -0.01, false)]
[InlineData(1, 0, true)]
[InlineData(1, 0.01, true)]
[InlineData(2, -0.01, false)]
[InlineData(2, 0, true)]
[InlineData(2, 0.01, true)]
[InlineData(3, -0.01, false)]
[InlineData(3, 0, true)]
[InlineData(3, 0.01, true)]
[InlineData(4, -0.01, false)]
[InlineData(4, 0, true)]
[InlineData(4, 0.01, true)]
public void MinimumTail_TwelveInchesIsInclusiveAfterSeparatorClearance(int quadrant, double extra, bool keep)
{
var occupied = 120 - 12 - 0.5 - Tolerance.Epsilon - extra;
var plate = MakePlate(quadrant, occupied);
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 };
var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
Assert.Equal(keep, plan.HasSeparatedTail);
Assert.Equal(keep ? 4 : 3, plan.Definitions.Count);
if (keep)
Assert.Equal(12 + extra, plan.TailLength, 6);
}
[Fact]
public void MinimumTail_DoesNotRemoveExistingSeparatorWhenRerunWithHigherMinimum()
{
var plate = MakePlate(occupied: 110);
var settings = new CutOffSettings();
var original = Plan(plate);
plate.CutOffs.AddRange(original.Definitions);
plate.RegenerateCutOffs(settings);
var definitions = plate.CutOffs.ToArray();
var parts = plate.Parts.ToArray();
var programs = definitions.Select(c => c.Drawing.Program).ToArray();
var plan = AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 }, settings);
Assert.Empty(plan.Definitions);
Assert.False(plan.HasSeparatedTail);
Assert.Equal(definitions, plate.CutOffs.ToArray());
Assert.Equal(parts, plate.Parts.ToArray());
Assert.Equal(programs, plate.CutOffs.Select(c => c.Drawing.Program));
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void MinimumTail_InvalidValueIsRejected(double minimum)
{
var plate = MakePlate();
Assert.Throws<ArgumentException>(() => AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = minimum }, new CutOffSettings()));
Assert.Empty(plate.CutOffs);
}
[Theory]
[InlineData(2, 0.5)]
[InlineData(0.5, 2)]
@@ -11,9 +11,9 @@ namespace OpenNest.WinForms.Tests.Forms;
public class AutomaticCutOffFormTests
{
[Theory]
[InlineData(Units.Inches, "35", "in")]
[InlineData(Units.Millimeters, "889", "mm")]
public void InitialSpacingUsesNestUnitsAndActualSheetWidth(Units units, string spacing, string suffix)
[InlineData(Units.Inches, "35", "in", 12)]
[InlineData(Units.Millimeters, "889", "mm", 304.8)]
public void InitialSpacingUsesNestUnitsAndActualSheetWidth(Units units, string spacing, string suffix, double minimumTail)
{
RunSta(() =>
{
@@ -21,6 +21,8 @@ public class AutomaticCutOffFormTests
using var form = new AutomaticCutOffForm(view, units);
Assert.Equal(spacing, Control<TextBox>(form, "spacingBox").Text);
Assert.Contains(suffix, Control<Label>(form, "spacingLabel").Text);
Assert.Contains(suffix, Control<Label>(form, "minimumTailLabel").Text);
Assert.Equal(minimumTail, double.Parse(Control<TextBox>(form, "minimumTailBox").Text));
Assert.Contains($"36 {suffix} width", Control<Label>(form, "sheetLabel").Text);
});
}
@@ -194,6 +196,140 @@ public class AutomaticCutOffFormTests
});
}
[Fact]
public void NestApply_PreviewsWithoutMutationAndAppliesEveryPlate()
{
RunSta(() =>
{
using var view = CreateView();
using var otherView = CreateView();
var nest = new Nest();
nest.Plates.Add(view.Plate);
nest.Plates.Add(otherView.Plate);
nest.Plates.Add(new Plate(36, 120));
using var form = new AutomaticCutOffForm(view, nest);
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
Assert.Contains("All 3 plates", Control<Label>(form, "sheetLabel").Text);
Assert.Contains("Plate 2", Control<TextBox>(form, "diagnosticsBox").Text);
Assert.Contains("Plate 3", Control<TextBox>(form, "diagnosticsBox").Text);
Assert.NotEmpty(PreviewParts(view));
Assert.All(nest.Plates, p => Assert.Empty(p.CutOffs));
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Equal(DialogResult.OK, form.DialogResult);
Assert.NotEmpty(view.Plate.CutOffs);
Assert.NotEmpty(otherView.Plate.CutOffs);
Assert.Empty(nest.Plates[2].CutOffs);
Assert.Empty(PreviewParts(view));
Assert.Same(nest.Plates[0], view.Plate);
});
}
[Fact]
public void NestApply_LaterConflictBlocksEntireNest()
{
RunSta(() =>
{
using var view = CreateView();
using var otherView = CreateView();
var nest = new Nest();
nest.Plates.Add(view.Plate);
nest.Plates.Add(otherView.Plate);
using var form = new AutomaticCutOffForm(view, nest);
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
otherView.Plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 });
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.NotEqual(DialogResult.OK, form.DialogResult);
Assert.Empty(view.Plate.CutOffs);
Assert.Single(otherView.Plate.CutOffs);
Assert.False(Control<Button>(form, "applyButton").Enabled);
Assert.Contains("Plate 2", Control<TextBox>(form, "diagnosticsBox").Text);
Assert.Contains("BLOCKING", Control<TextBox>(form, "diagnosticsBox").Text);
Assert.Empty(PreviewParts(view));
});
}
[Fact]
public void NestApply_RechecksOperationGuardAfterPreview()
{
RunSta(() =>
{
using var view = CreateView();
var nest = new Nest();
nest.Plates.Add(view.Plate);
var busy = false;
using var form = new AutomaticCutOffForm(view, nest, () => busy);
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
Assert.NotEmpty(PreviewParts(view));
busy = true;
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.NotEqual(DialogResult.OK, form.DialogResult);
Assert.Empty(view.Plate.CutOffs);
Assert.Empty(PreviewParts(view));
Assert.False(Control<Button>(form, "applyButton").Enabled);
});
}
[Fact]
public void NestPreview_DisposeLeavesEveryPlateUntouched()
{
RunSta(() =>
{
using var view = CreateView();
using var otherView = CreateView();
var nest = new Nest();
nest.Plates.Add(view.Plate);
nest.Plates.Add(otherView.Plate);
using (var form = new AutomaticCutOffForm(view, nest))
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
Assert.All(nest.Plates, p => Assert.Empty(p.CutOffs));
Assert.All(nest.Plates, p => Assert.Single(p.Parts));
Assert.Empty(PreviewParts(view));
});
}
[Fact]
public void MinimumTailChangeClearsPreviewAndApplyUsesCurrentValue()
{
RunSta(() =>
{
using var view = CreateView();
view.Plate.Size = new Size(36, 90);
using var form = new AutomaticCutOffForm(view, Units.Inches);
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
Assert.Equal(2, PreviewParts(view).Count);
Assert.Contains("TailBelowMinimum", Control<TextBox>(form, "diagnosticsBox").Text);
Control<TextBox>(form, "minimumTailBox").Text = "0";
Assert.Empty(PreviewParts(view));
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Equal(DialogResult.OK, form.DialogResult);
Assert.Equal(3, view.Plate.CutOffs.Count);
});
}
[Theory]
[InlineData("not a number")]
[InlineData("-1")]
[InlineData("NaN")]
public void InvalidMinimumTailBlocksApply(string text)
{
RunSta(() =>
{
using var view = CreateView();
using var form = new AutomaticCutOffForm(view, Units.Inches);
Control<TextBox>(form, "minimumTailBox").Text = text;
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.NotEqual(DialogResult.OK, form.DialogResult);
Assert.Empty(view.Plate.CutOffs);
Assert.False(Control<Button>(form, "applyButton").Enabled);
});
}
private static PlateView CreateView()
{
var program = new CNC.Program();
+25 -3
View File
@@ -24,6 +24,8 @@ partial class AutomaticCutOffForm
spacingPanel = new TableLayoutPanel();
spacingLabel = new Label();
spacingBox = new TextBox();
minimumTailLabel = new Label();
minimumTailBox = new TextBox();
warningLabel = new Label();
occupiedLabel = new Label();
usedLabel = new Label();
@@ -86,10 +88,13 @@ partial class AutomaticCutOffForm
spacingPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
spacingPanel.Controls.Add(spacingLabel, 0, 0);
spacingPanel.Controls.Add(spacingBox, 1, 0);
spacingPanel.Controls.Add(minimumTailLabel, 0, 1);
spacingPanel.Controls.Add(minimumTailBox, 1, 1);
spacingPanel.Dock = DockStyle.Fill;
spacingPanel.Margin = new Padding(0, 0, 0, 6);
spacingPanel.Name = "spacingPanel";
spacingPanel.RowCount = 1;
spacingPanel.RowCount = 2;
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
spacingPanel.TabIndex = 1;
//
@@ -106,7 +111,22 @@ partial class AutomaticCutOffForm
spacingBox.Dock = DockStyle.Fill;
spacingBox.Name = "spacingBox";
spacingBox.TabIndex = 1;
spacingBox.TextChanged += SpacingBox_TextChanged;
spacingBox.TextChanged += SettingsBox_TextChanged;
//
// minimumTailLabel
//
minimumTailLabel.Anchor = AnchorStyles.Left;
minimumTailLabel.AutoSize = true;
minimumTailLabel.Name = "minimumTailLabel";
minimumTailLabel.TabIndex = 2;
minimumTailLabel.Text = "Minimum tail to keep:";
//
// minimumTailBox
//
minimumTailBox.Dock = DockStyle.Fill;
minimumTailBox.Name = "minimumTailBox";
minimumTailBox.TabIndex = 3;
minimumTailBox.TextChanged += SettingsBox_TextChanged;
//
// warningLabel
//
@@ -221,7 +241,7 @@ partial class AutomaticCutOffForm
AutoScaleDimensions = new SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
CancelButton = cancelButton;
ClientSize = new Size(600, 470);
ClientSize = new Size(660, 520);
Controls.Add(layout);
Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point);
FormBorderStyle = FormBorderStyle.FixedDialog;
@@ -247,6 +267,8 @@ partial class AutomaticCutOffForm
private TableLayoutPanel spacingPanel;
private Label spacingLabel;
private TextBox spacingBox;
private Label minimumTailLabel;
private TextBox minimumTailBox;
private Label warningLabel;
private Label occupiedLabel;
private Label usedLabel;
+89 -70
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Windows.Forms;
@@ -10,17 +11,35 @@ public partial class AutomaticCutOffForm : Form
{
private readonly PlateView plateView;
private readonly Plate plate;
private readonly Nest nest;
private readonly Func<bool> isOperationBusy;
private readonly string unit;
public AutomaticCutOffForm(PlateView plateView, Units units)
public AutomaticCutOffForm(PlateView plateView, Units units, Func<bool> isOperationBusy = null)
: this(plateView, units, null, isOperationBusy) { }
public AutomaticCutOffForm(PlateView plateView, Nest nest, Func<bool> isOperationBusy = null)
: this(plateView, (nest ?? throw new ArgumentNullException(nameof(nest))).Units, nest, isOperationBusy) { }
private AutomaticCutOffForm(PlateView plateView, Units units, Nest nest, Func<bool> isOperationBusy)
{
this.plateView = plateView ?? throw new ArgumentNullException(nameof(plateView));
plate = plateView.Plate ?? throw new ArgumentException("An active plate is required.", nameof(plateView));
this.nest = nest;
this.isOperationBusy = isOperationBusy;
unit = UnitsHelper.GetShortString(units);
InitializeComponent();
spacingLabel.Text = $"Nominal spacing along sheet length ({unit}):";
spacingBox.Text = units == Units.Millimeters ? "889" : "35";
minimumTailLabel.Text = $"Minimum tail to keep ({unit}):";
minimumTailBox.Text = (units == Units.Millimeters ? 304.8 : 12).ToString(CultureInfo.CurrentCulture);
sheetLabel.Text = $"Sheet: {Measure(plate.Size.Length)} length × {Measure(plate.Size.Width)} width";
if (nest != null)
{
Text = "Automatic Scrap Cutoffs — All Plates";
applyButton.Text = "&Apply to All Plates";
sheetLabel.Text = $"All {nest.Plates.Count} plates — active sheet shown below";
}
}
protected override void OnShown(EventArgs e)
@@ -45,22 +64,46 @@ public partial class AutomaticCutOffForm : Form
private string SignedPosition(double value) => $"{value:+0.####;-0.####;0} {unit}";
private AutomaticCutOffPlan CreatePlan(CutOffSettings settings)
private (Plate[] Plates, IReadOnlyList<AutomaticCutOffPlan> Plans) Calculate(bool apply)
{
if (plateView.IsFillInProgress)
throw new InvalidOperationException("Wait for the current fill to finish before creating cut-offs.");
if (plateView.IsFillInProgress || isOperationBusy?.Invoke() == true)
throw new InvalidOperationException("Wait for the current nesting or plate action to finish before creating cut-offs.");
if (plateView.IsDisposed || !ReferenceEquals(plateView.Plate, plate))
throw new InvalidOperationException("The active plate changed. Close this dialog and try again.");
var plates = nest?.Plates.ToArray() ?? new[] { plate };
if (!plates.Contains(plate))
throw new InvalidOperationException("The active plate is no longer in this nest. Close this dialog and try again.");
if (!double.TryParse(spacingBox.Text, NumberStyles.Float, CultureInfo.CurrentCulture, out var spacing))
throw new ArgumentException($"Enter a numeric spacing in {unit}.");
if (!double.TryParse(minimumTailBox.Text, NumberStyles.Float, CultureInfo.CurrentCulture, out var minimumTail))
throw new ArgumentException($"Enter a numeric minimum tail length in {unit}.");
return AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = spacing }, settings);
var options = new AutomaticCutOffOptions { Spacing = spacing, MinimumTailLength = minimumTail };
var settings = plateView.CutOffSettings;
var plans = apply
? AutomaticCutOffBatch.Apply(plates, options, settings)
: AutomaticCutOffBatch.Create(plates, options, settings);
return (plates, plans);
}
private void DisplayPlan(AutomaticCutOffPlan plan)
private IEnumerable<string> Diagnostics(AutomaticCutOffPlan plan) => plan.Diagnostics.Select(d =>
$"{(d.IsBlocking ? "BLOCKING" : "Notice")} [{d.Code}]"
+ (d.X.HasValue ? $" X = {SignedPosition(d.X.Value)}" : string.Empty)
+ $": {d.Message}");
private static string PlanStatus(AutomaticCutOffPlan plan) => plan.HasBlockingDiagnostics
? "Blocked: resolve the diagnostics before applying."
: plan.Definitions.Count == 0
? "No new usable cut-offs to apply."
: $"{plan.Definitions.Count} new cut-off definition(s) ready to apply.";
private void DisplayPlans(Plate[] plates, IReadOnlyList<AutomaticCutOffPlan> plans)
{
var currentIndex = Array.IndexOf(plates, plate);
var plan = plans[currentIndex];
occupiedLabel.Text = $"Occupied span from origin: {Measure(plan.OccupiedSpan)}";
usedLabel.Text = $"Proposed used span from origin: {Measure(plan.UsedSpan)}";
separatorLabel.Text = plan.HasSeparatedTail
@@ -70,18 +113,36 @@ public partial class AutomaticCutOffForm : Form
? $"Proposed retained tail: {Measure(plan.TailLength)} length × {Measure(plate.Size.Width)} width"
: "Proposed retained tail: none verified";
var status = plan.HasBlockingDiagnostics
? "Blocked: resolve the diagnostics before applying."
: plan.Definitions.Count == 0
? "No new usable cut-offs to apply."
: $"{plan.Definitions.Count} new cut-off definition(s) ready to apply.";
var diagnostics = plan.Diagnostics.Select(d =>
$"{(d.IsBlocking ? "BLOCKING" : "Notice")} [{d.Code}]"
+ (d.X.HasValue ? $" X = {SignedPosition(d.X.Value)}" : string.Empty)
+ $": {d.Message}");
diagnosticsBox.Text = string.Join(Environment.NewLine + Environment.NewLine,
new[] { status }.Concat(diagnostics));
applyButton.Enabled = !plan.HasBlockingDiagnostics && plan.Definitions.Count > 0;
var blocked = plans.Any(p => p.HasBlockingDiagnostics);
var definitionCount = plans.Sum(p => p.Definitions.Count);
applyButton.Enabled = !blocked && definitionCount > 0;
if (nest == null)
{
diagnosticsBox.Text = string.Join(Environment.NewLine + Environment.NewLine,
new[] { PlanStatus(plan) }.Concat(Diagnostics(plan)));
return;
}
sheetLabel.Text = $"All {plates.Length} plates — active plate {currentIndex + 1}: "
+ $"{Measure(plate.Size.Length)} length × {Measure(plate.Size.Width)} width";
var status = blocked
? "BLOCKED: no plates will be changed until every blocking issue is resolved."
: $"{definitionCount} new cut-off definition(s) across {plans.Count(p => p.Definitions.Count > 0)} plate(s). "
+ "Empty/unchanged plates will be left untouched.";
var summaries = plans.Select((p, index) =>
{
var sheet = plates[index];
var tail = p.HasSeparatedTail
? $"Retained tail: {Measure(p.TailLength)} length × {Measure(sheet.Size.Width)} width; "
+ $"separator X = {SignedPosition(p.TailSeparatorX.Value)}."
: "Retained tail: none verified.";
return string.Join(Environment.NewLine, new[]
{
$"Plate {index + 1} ({Measure(sheet.Size.Length)} length × {Measure(sheet.Size.Width)} width): {PlanStatus(p)}",
$"Occupied: {Measure(p.OccupiedSpan)}; used span: {Measure(p.UsedSpan)}. {tail}",
}.Concat(Diagnostics(p)));
});
diagnosticsBox.Text = string.Join(Environment.NewLine + Environment.NewLine, new[] { status }.Concat(summaries));
}
private void ResetSummary()
@@ -100,11 +161,11 @@ public partial class AutomaticCutOffForm : Form
diagnosticsBox.Text = $"Unable to complete the cut-off operation: {error.Message}";
}
private void SpacingBox_TextChanged(object sender, EventArgs e)
private void SettingsBox_TextChanged(object sender, EventArgs e)
{
ClearPreview();
ResetSummary();
diagnosticsBox.Text = "Spacing changed. Preview or Apply will calculate a fresh plan.";
diagnosticsBox.Text = "Settings changed. Preview or Apply will calculate a fresh plan.";
applyButton.Enabled = true;
}
@@ -115,10 +176,10 @@ public partial class AutomaticCutOffForm : Form
ClearPreview();
try
{
var plan = CreatePlan(plateView.CutOffSettings);
DisplayPlan(plan);
if (!plan.HasBlockingDiagnostics)
plateView.SetActiveParts(plan.PreviewParts.ToList());
var (plates, plans) = Calculate(apply: false);
DisplayPlans(plates, plans);
if (!plans.Any(p => p.HasBlockingDiagnostics))
plateView.SetActiveParts(plans[Array.IndexOf(plates, plate)].PreviewParts.ToList());
}
catch (Exception ex)
{
@@ -131,14 +192,12 @@ public partial class AutomaticCutOffForm : Form
ClearPreview();
try
{
// Never accept cached preview parts: recompute with the current model/settings.
var settings = plateView.CutOffSettings;
var plan = CreatePlan(settings);
DisplayPlan(plan);
if (plan.HasBlockingDiagnostics || plan.Definitions.Count == 0)
// The batch service replans every plate before mutating any; never accept previews.
var (plates, plans) = Calculate(apply: true);
DisplayPlans(plates, plans);
if (plans.Any(p => p.HasBlockingDiagnostics) || plans.All(p => p.Definitions.Count == 0))
return;
ApplyDefinitions(plan, settings);
DialogResult = DialogResult.OK;
Close();
}
@@ -151,44 +210,4 @@ public partial class AutomaticCutOffForm : Form
ClearPreview();
}
}
private void ApplyDefinitions(AutomaticCutOffPlan plan, CutOffSettings settings)
{
// Regeneration replaces drawing programs and removes/reinserts cut-off parts.
// Keep only the state it can change; leave real parts and quantities untouched.
var programs = plate.CutOffs.Select(c => (CutOff: c, Program: c.Drawing.Program)).ToList();
var parts = plate.Parts.Select((part, index) => (Part: part, Index: index))
.Where(p => p.Part.BaseDrawing.IsCutOff).ToList();
try
{
plate.CutOffs.AddRange(plan.Definitions);
plate.RegenerateCutOffs(settings);
}
catch (Exception applyError)
{
try
{
foreach (var definition in plan.Definitions)
plate.CutOffs.Remove(definition);
for (var index = plate.Parts.Count - 1; index >= 0; index--)
{
if (plate.Parts[index].BaseDrawing.IsCutOff)
plate.Parts.RemoveAt(index);
}
foreach (var saved in programs)
saved.CutOff.Drawing.Program = saved.Program;
foreach (var saved in parts)
plate.Parts.Insert(saved.Index, saved.Part);
}
catch (Exception rollbackError)
{
throw new InvalidOperationException(
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: {rollbackError.Message}\n"
+ "The plate may be incomplete; review it before saving or cutting.", rollbackError);
}
throw new InvalidOperationException(
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
}
}
}
+10 -1
View File
@@ -111,6 +111,7 @@
toolStripMenuItem19 = new System.Windows.Forms.ToolStripSeparator();
calculateCutTimeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem22 = new System.Windows.Forms.ToolStripSeparator();
mnuNestAutomaticCutOff = new System.Windows.Forms.ToolStripMenuItem();
mnuNestAssignLeadIns = new System.Windows.Forms.ToolStripMenuItem();
mnuNestRemoveLeadIns = new System.Windows.Forms.ToolStripMenuItem();
mnuPlate = new System.Windows.Forms.ToolStripMenuItem();
@@ -644,7 +645,7 @@
//
// mnuNest
//
mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestImportDrawing, mnuNestShapeLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAssignLeadIns, mnuNestRemoveLeadIns });
mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestImportDrawing, mnuNestShapeLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAutomaticCutOff, mnuNestAssignLeadIns, mnuNestRemoveLeadIns });
mnuNest.Name = "mnuNest";
mnuNest.Size = new System.Drawing.Size(43, 20);
mnuNest.Text = "&Nest";
@@ -764,6 +765,13 @@
toolStripMenuItem22.Name = "toolStripMenuItem22";
toolStripMenuItem22.Size = new System.Drawing.Size(202, 6);
//
// mnuNestAutomaticCutOff
//
mnuNestAutomaticCutOff.Name = "mnuNestAutomaticCutOff";
mnuNestAutomaticCutOff.Size = new System.Drawing.Size(205, 22);
mnuNestAutomaticCutOff.Text = "Automatic Scrap Cutoffs (All Plates)...";
mnuNestAutomaticCutOff.Click += NestAutomaticCutOff_Click;
//
// mnuNestAssignLeadIns
//
mnuNestAssignLeadIns.Name = "mnuNestAssignLeadIns";
@@ -1426,6 +1434,7 @@
private System.Windows.Forms.ToolStripMenuItem mnuPlatePlaceLeadIn;
private System.Windows.Forms.ToolStripMenuItem mnuPlateRemoveLeadIns;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem22;
private System.Windows.Forms.ToolStripMenuItem mnuNestAutomaticCutOff;
private System.Windows.Forms.ToolStripMenuItem mnuNestAssignLeadIns;
private System.Windows.Forms.ToolStripMenuItem mnuNestRemoveLeadIns;
}
+19 -7
View File
@@ -1720,16 +1720,25 @@ namespace OpenNest.Forms
activeForm.PlateView.SetAction(typeof(ActionCutOff));
}
private void AutomaticCutOff_Click(object sender, EventArgs e)
private void AutomaticCutOff_Click(object sender, EventArgs e) => ShowAutomaticCutOff(allPlates: false);
private void NestAutomaticCutOff_Click(object sender, EventArgs e) => ShowAutomaticCutOff(allPlates: true);
private void ShowAutomaticCutOff(bool allPlates)
{
var view = activeForm?.PlateView;
var editForm = activeForm;
var view = editForm?.PlateView;
if (view?.Plate == null)
return;
var nest = editForm.Nest;
var views = MdiChildren.OfType<EditNestForm>()
.Where(form => ReferenceEquals(form.Nest, nest))
.Select(form => form.PlateView).ToArray();
// A closed progress window can still have a fill awaiting completion/commit.
if (nestingInProgress || view.IsFillInProgress
|| Application.OpenForms.OfType<NestProgressForm>().Any()
|| view.Actions.CurrentAction?.IsBusy() == true)
bool IsBusy() => nestingInProgress || Application.OpenForms.OfType<NestProgressForm>().Any()
|| views.Any(v => v.IsFillInProgress || v.Actions.CurrentAction?.IsBusy() == true);
if (IsBusy())
{
MessageBox.Show(this, "Finish or cancel the current nesting or plate action first.",
"Automatic Scrap Cutoffs", MessageBoxButtons.OK, MessageBoxIcon.Information);
@@ -1738,8 +1747,11 @@ namespace OpenNest.Forms
try
{
view.SetAction(typeof(ActionSelect));
using var form = new AutomaticCutOffForm(view, activeForm.Nest.Units);
foreach (var plateView in views)
plateView.SetAction(typeof(ActionSelect));
using var form = allPlates
? new AutomaticCutOffForm(view, nest, IsBusy)
: new AutomaticCutOffForm(view, nest.Units, IsBusy);
form.ShowDialog(this);
}
catch (Exception ex)
+1 -1
View File
@@ -11,7 +11,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
- **Import / export** — DXF & DWG parts (ACadSharp), Excel BOMs, bend-line detection, built-in parametric shapes; export DXF or post-processed G-code.
- **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs.
- **Plate operations** — manual sheet cut-offs, [automatic scrap cutoffs with unused-tail preservation](docs/automatic-scrap-cutoffs.md), oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts.
- **Plate operations** — manual sheet cut-offs, [plate- or nest-wide automatic scrap cutoffs with a minimum tail-to-keep setting](docs/automatic-scrap-cutoffs.md), oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts.
- **Visual overlap check** — manually highlight shared material on the active plate, including containment and cutouts, with area shading, pair centroids, and hover details through View > Overlap Check. [Usage and limitations](docs/geometry/visual-overlap-check.md).
- **CNC output** — configurable lead-ins/outs and tabs, contour editing, user-defined G-code variables (`$name` → `#200+` machine variables), plugin post-processors (Cincinnati CL-707/800/900/940/CLX included). [Pre-post verification](docs/post-verification.md) checks overlaps, missing lead-ins, and rapid crossings, with explicit risk acknowledgment required to bypass warnings.
+10 -5
View File
@@ -1,17 +1,22 @@
# Automatic scrap cutoffs
Finish nesting the active plate, then choose **Plate > Automatic Scrap Cutoffs**. The manual **Sheet Cut-Off** command remains available.
Finish nesting, then choose **Nest > Automatic Scrap Cutoffs (All Plates)** to process the entire nest with one dialog. **Plate > Automatic Scrap Cutoffs** still processes only the active plate. The manual **Sheet Cut-Off** command remains available.
## Preview and apply
1. Finish or cancel any active nesting/fill operation first, and wait for it to finish stopping; closing its progress window alone does not finish the operation. Enter strip spacing along the sheet's length. The initial value is **35 in**, or **889 mm** for a metric nest. This is the distance between nominal cut lines, not the length of a cut.
2. Opening the modal dialog previews the initial proposal. After changing spacing, choose **Preview** to refresh it. Cuts run across the active sheet's actual width, advancing along X from the origin-side edge in the plate's quadrant. Review the used span, proposed separator, retained tail dimensions, and diagnostics; the sheet cannot be edited while the dialog is open.
3. Choose **Apply** to recalculate against the current parts/settings and add ordinary cutoff definitions at the end of the cutting sequence. Blocked or empty proposals add nothing. Existing parts and manual cutoff definitions are not moved or reordered. **Cancel**, Escape, and closing the dialog discard the detached preview.
2. Set **Minimum tail to keep**, initially **12 in** or **304.8 mm**. This measures the reusable tail's length along X, after separator clearance, not its area. A shorter tail does not get a new final separator; a tail at or above the minimum can be retained. Zero allows any positive tail.
3. Opening the modal dialog previews the initial proposal. After changing either setting, choose **Preview** to refresh it. Cuts run across the active sheet's actual width, advancing along X from the origin-side edge in the plate's quadrant. Review the used span, proposed separator, retained tail dimensions, and diagnostics; the sheet cannot be edited while the dialog is open.
4. Choose **Apply** (or **Apply to All Plates**) to recalculate against the current parts/settings and add ordinary cutoff definitions at the end of each plate's cutting sequence. Blocked or empty proposals add nothing. Existing parts and manual cutoff definitions are not moved or reordered. **Cancel**, Escape, and closing the dialog discard the detached preview.
For the Nest command, the on-sheet overlay and upper measurements show the active plate; the scrollable diagnostics list reports every plate's proposed cuts, occupied/used spans, retained tail and warnings. Each plate uses its own dimensions, quadrant and part spacing with the dialog's common spacing/minimum-tail settings and current cutoff clearance/direction settings. No plate navigation or per-plate confirmation is required. Empty or unchanged plates are left untouched. A blocking conflict or invalid plate prevents changes to **all** plates, with the plate number reported; resolve it before retrying. If application fails after changes begin, all touched plates' original cutoff programs and sequence are restored. An explicit incomplete-recovery warning requires manual review before saving or cutting.
An empty sheet is left untouched. Invalid input or out-of-sheet part geometry prevents automatic planning. Excessively small spacing is rejected rather than allocating an unbounded number of cuts. Ordinary floating-point roundoff between translated material and cached bounds is accepted without changing the part or its program; cutoff obstacle checks and fallback exclusions receive the same conservative numerical padding. Larger discrepancies (for example, an inconsistent arc center corrected during conversion) still prevent planning rather than risking a cut through material; repair the geometry before retrying.
## Preserving the unused tail
A proposed tail shorter than **Minimum tail to keep** is left attached to the scrap skeleton by omitting only the final separator. The nominal skeleton grid is not extended into that tail, and no retained tail is claimed. Existing cutoff definitions—including an end cutoff from an earlier run—are never removed by this setting.
Only real parts contribute to the occupied envelope; existing cutoff parts do not. When there is room, the final separator is beyond the furthest occupied extent by the larger of plate part spacing and cutoff part clearance, plus numerical tolerance. Repeated skeleton cuts stop before that separator. The larger margin affects only the separator: repeated cuts keep the existing manual-cutoff clearance behavior.
Cutoff trimming measures clearance from the part contour, excluding tagged lead-in and lead-out moves when building the closed outline. Leads must not turn a recessed outline into a convex hull and leave oversized gaps elsewhere. Genuinely open contours still use the conservative hull/bounds fallback. The conservative curve/offset approximation can leave a small extra gap (about 0.0003 in on a straight edge at the default 0.02-in clearance), not an extra quarter inch.
@@ -31,7 +36,7 @@ For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal ske
## Rerunning, editing, and saving
The command is one-shot. Generated lines are ordinary cutoffs and can subsequently be dragged, deleted, or sequenced with the existing tools. Moving parts regenerates their trimmed segments but does not reposition the cutoff grid or recalculate the retained tail automatically.
The command is one-shot; spacing and minimum-tail inputs start at their defaults each time the dialog opens. Generated lines are ordinary cutoffs and can subsequently be dragged, deleted, or sequenced with the existing tools. Moving parts regenerates their trimmed segments but does not reposition the cutoff grid or recalculate the retained tail automatically.
Rerunning unchanged suppresses equivalent existing full-span lines. A limited cutoff at a proposed line is a conflict requiring manual review, not permission to replace it or add overlapping NC moves. Existing definitions are never silently deleted or expanded.
@@ -39,4 +44,4 @@ Saving `.nest` retains ordinary cutoff positions, axes, limits, and sequence. Lo
## Implementation boundary
`AutomaticCutOffPlanner` in Core builds detached definitions and diagnostics without changing the input plate. The desktop dialog renders the preview through `PlateView.SetActiveParts`; Apply adds definitions to `Plate.CutOffs` and uses `Plate.RegenerateCutOffs`. It must not accept preview parts directly, because doing so would bypass cutoff persistence and regeneration. There are no new nesting, post-processing, or `.nest` format rules.
`AutomaticCutOffPlanner` in Core builds detached definitions and diagnostics without changing the input plate. `AutomaticCutOffOptions.MinimumTailLength` is a finite, nonnegative distance in model units; its programmatic default of zero preserves the previous behavior, while the desktop explicitly supplies its unit-aware 12-in / 304.8-mm default. `AutomaticCutOffBatch.Create` plans an ordered plate list, and `Apply` replans the complete list before mutating any plate, refusing a blocked batch and recovering touched cutoff state on exceptions. Callers must prevent concurrent edits throughout planning/apply. The desktop dialog renders the active plate's detached preview through `PlateView.SetActiveParts`; both menu commands apply through the shared batch service, which adds definitions to `Plate.CutOffs` and uses `Plate.RegenerateCutOffs`. It must not accept preview parts directly, because doing so would bypass cutoff persistence and regeneration. There are no new nesting, post-processing, or `.nest` format rules.