feat(cutoffs): add automatic skeleton cutoffs with tail preservation
This commit is contained in:
@@ -155,6 +155,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).
|
||||
- **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).
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public sealed class AutomaticCutOffOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Nominal distance between vertical cut lines in model units. Must be finite and
|
||||
/// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count.
|
||||
/// </summary>
|
||||
public double Spacing { get; set; }
|
||||
}
|
||||
|
||||
public enum AutomaticCutOffDiagnosticCode
|
||||
{
|
||||
ExistingCutOff,
|
||||
LimitedCutOffConflict,
|
||||
EmptyCut,
|
||||
SegmentedCut,
|
||||
NoSafeTailSeparator,
|
||||
}
|
||||
|
||||
public sealed record AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode Code, string Message, bool IsBlocking = false, double? X = null);
|
||||
|
||||
/// <summary>
|
||||
/// Detached proposal. Collections are read-only; contained definitions/preview parts belong
|
||||
/// to the caller. Never accept a blocked plan or a preview made for an older layout/settings.
|
||||
/// </summary>
|
||||
public sealed class AutomaticCutOffPlan
|
||||
{
|
||||
/// <summary>Only new, usable definitions; existing equivalent definitions are not returned.</summary>
|
||||
public IReadOnlyList<CutOff> Definitions { get; internal set; } = Array.Empty<CutOff>();
|
||||
|
||||
/// <summary>Detached display parts, in the same order as Definitions. Not for acceptance.</summary>
|
||||
public IReadOnlyList<Part> PreviewParts { get; internal set; } = Array.Empty<Part>();
|
||||
|
||||
public IReadOnlyList<AutomaticCutOffDiagnostic> Diagnostics { get; internal set; } =
|
||||
Array.Empty<AutomaticCutOffDiagnostic>();
|
||||
|
||||
/// <summary>Furthest real-part X distance from the origin, excluding cut-off parts.</summary>
|
||||
public double OccupiedSpan { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Distance to the verified separator, or full sheet length when no separated tail can
|
||||
/// be claimed. Zero on empty sheets. This is not a disconnected-scrap-size guarantee.
|
||||
/// </summary>
|
||||
public double UsedSpan { get; internal set; }
|
||||
|
||||
/// <summary>Length beyond a verified separator, otherwise zero (also on empty sheets).</summary>
|
||||
public double TailLength { get; internal set; }
|
||||
|
||||
/// <summary>Signed X coordinate of a verified new or equivalent existing separator.</summary>
|
||||
public double? TailSeparatorX { get; internal set; }
|
||||
|
||||
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
||||
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
||||
|
||||
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
|
||||
}
|
||||
|
||||
/// <summary>Pure proposals for vertical scrap cuts, measured in the plate's model units.</summary>
|
||||
public static class AutomaticCutOffPlanner
|
||||
{
|
||||
public const double GeometryTolerance = Tolerance.Epsilon;
|
||||
public const double MinimumSpacing = 2 * GeometryTolerance;
|
||||
public const int MaximumCandidateCount = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
|
||||
/// Invalid inputs throw ArgumentException. Blocking diagnostics return no definitions.
|
||||
/// Accept by adding Definitions to Plate.CutOffs and calling RegenerateCutOffs with the
|
||||
/// same settings, only while the layout is unchanged. Do not add PreviewParts to the plate.
|
||||
/// Nominal spacing is not a guarantee of fully disconnected, hopper-sized scrap.
|
||||
/// </summary>
|
||||
public static AutomaticCutOffPlan Create(Plate plate, AutomaticCutOffOptions options,
|
||||
CutOffSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
ValidateInputs(plate, options, settings);
|
||||
var bounds = plate.BoundingBox(false);
|
||||
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
|
||||
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
||||
|
||||
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
|
||||
var occupied = 0.0;
|
||||
var hasParts = false;
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
Require(part?.BaseDrawing != null, "Every part must have a drawing.", nameof(plate));
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
var partBounds = ValidatePart(part, bounds);
|
||||
occupied = System.Math.Max(occupied, sign > 0 ? partBounds.Right : -partBounds.Left);
|
||||
hasParts = true;
|
||||
}
|
||||
if (!hasParts)
|
||||
return new AutomaticCutOffPlan();
|
||||
|
||||
// Use distances from the coordinate origin, not the sheet's lower-left corner.
|
||||
var length = plate.Size.Length;
|
||||
occupied = System.Math.Min(occupied, length);
|
||||
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
|
||||
+ GeometryTolerance;
|
||||
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
|
||||
var usedSpan = hasTailCandidate ? separator : length;
|
||||
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
|
||||
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
|
||||
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
|
||||
|
||||
// Validate and bound the candidate count before preparing geometry or allocating lines.
|
||||
ValidateExisting(plate, bounds, settings);
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
var definitions = new List<CutOff>();
|
||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||
var separated = false;
|
||||
var separatorX = (double?)null;
|
||||
|
||||
// Multiplication by an integer avoids drift from repeated floating-point addition.
|
||||
for (var index = 1; index <= (int)candidateCount; index++)
|
||||
{
|
||||
var distance = index * options.Spacing;
|
||||
if (distance >= usedSpan - GeometryTolerance)
|
||||
break;
|
||||
AddCandidate(sign * distance, false);
|
||||
}
|
||||
if (hasTailCandidate)
|
||||
AddCandidate(sign * separator, true);
|
||||
|
||||
if (diagnostics.Any(d => d.IsBlocking))
|
||||
{
|
||||
// A partial proposal must not accidentally be accepted around a manual conflict.
|
||||
definitions.Clear();
|
||||
separated = false;
|
||||
separatorX = null;
|
||||
}
|
||||
|
||||
return new AutomaticCutOffPlan
|
||||
{
|
||||
Definitions = definitions.AsReadOnly(),
|
||||
PreviewParts = definitions.Select(c => new Part(c.Drawing)).ToList().AsReadOnly(),
|
||||
Diagnostics = diagnostics.AsReadOnly(),
|
||||
OccupiedSpan = occupied,
|
||||
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
|
||||
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
|
||||
TailSeparatorX = separatorX,
|
||||
};
|
||||
|
||||
void AddCandidate(double x, bool isSeparator)
|
||||
{
|
||||
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
||||
Near(c.Position.X, x)).ToList();
|
||||
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
|
||||
{
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
|
||||
"A same-line manual cut has different limits. Manual review is required; no cuts may be applied.",
|
||||
true, x));
|
||||
if (isSeparator)
|
||||
WarnNoSeparator(x);
|
||||
return;
|
||||
}
|
||||
|
||||
// Even a duplicate must be regenerated detached with CURRENT settings. Its live
|
||||
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
||||
var existing = matches.FirstOrDefault();
|
||||
var candidate = existing == null
|
||||
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
|
||||
: new CutOff(existing.Position, existing.Axis)
|
||||
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
|
||||
candidate.Regenerate(plate, settings, cache);
|
||||
var program = candidate.Drawing.Program;
|
||||
var usable = HasUsableSegments(program);
|
||||
if (existing != null)
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.ExistingCutOff,
|
||||
"An equivalent full-span cut-off already exists; no duplicate was added.", X: x));
|
||||
if (!usable)
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.EmptyCut,
|
||||
"The line has no usable cut segments after part clearance and minimum-length filtering; it is not a partition.",
|
||||
X: x));
|
||||
|
||||
if (isSeparator)
|
||||
{
|
||||
if (!usable || !IsFullSpanProgram(program, bounds))
|
||||
{
|
||||
WarnNoSeparator(x);
|
||||
return;
|
||||
}
|
||||
separated = true;
|
||||
separatorX = candidate.Position.X;
|
||||
}
|
||||
else if (usable && !IsFullSpanProgram(program, bounds))
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
||||
"Part clearance or segment filtering interrupts this line; nominal spacing does not guarantee disconnected scrap.",
|
||||
X: x));
|
||||
|
||||
if (usable && existing == null)
|
||||
definitions.Add(candidate);
|
||||
}
|
||||
|
||||
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
||||
"No safe full-width tail separator survives the current settings. No separated tail is claimed; review manually.",
|
||||
X: x));
|
||||
}
|
||||
|
||||
private static void ValidateInputs(Plate plate, AutomaticCutOffOptions options, CutOffSettings settings)
|
||||
{
|
||||
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
|
||||
$"Spacing must be finite and greater than {MinimumSpacing} model units.", 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));
|
||||
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
|
||||
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
||||
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
||||
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", 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));
|
||||
}
|
||||
|
||||
private static Box ValidatePart(Part part, Box sheet)
|
||||
{
|
||||
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
|
||||
"Part pose must be finite.", "plate");
|
||||
ValidateProgram(part.Program, new HashSet<Program>());
|
||||
var box = part.Program.BoundingBox();
|
||||
box.Offset(part.Location);
|
||||
Require(ValidBox(box) && box.Length > 0 && box.Width > 0,
|
||||
"Real parts must have finite, nonempty geometry.", "plate");
|
||||
var cached = part.BoundingBox;
|
||||
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
|
||||
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
|
||||
"Part geometry has stale bounds; update it before planning.", "plate");
|
||||
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||
|
||||
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
|
||||
// Checking converted entities catches overflowing incremental moves and curve bounds.
|
||||
var hasMaterial = false;
|
||||
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
|
||||
{
|
||||
var entityBox = entity.BoundingBox;
|
||||
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
|
||||
if (!SpecialLayers.IsMaterial(entity.Layer))
|
||||
continue;
|
||||
entityBox = entityBox.Translate(part.Location);
|
||||
// Conversion can refit arc centers beyond the raw-program/cached bounds.
|
||||
// CutOff's broad phase uses cached bounds without tolerance: even a smaller
|
||||
// protrusion can be crossed by a repeated line, so require strict containment.
|
||||
Require(entityBox.Left >= cached.Left && entityBox.Right <= cached.Right &&
|
||||
entityBox.Bottom >= cached.Bottom && entityBox.Top <= cached.Top,
|
||||
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
|
||||
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
|
||||
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
|
||||
}
|
||||
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
|
||||
return box;
|
||||
}
|
||||
|
||||
private static void ValidateProgram(Program program, HashSet<Program> path)
|
||||
{
|
||||
Require(program?.Codes != null && path.Count < 64 && path.Add(program),
|
||||
"Part program is missing, recursive, or nested too deeply.", "plate");
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
Require(code != null, "Part program contains a missing instruction.", "plate");
|
||||
if (code is Motion motion)
|
||||
Require(Finite(motion.EndPoint), "Part motion coordinates must be finite.", "plate");
|
||||
if (code is ArcMove arc)
|
||||
Require(Finite(arc.CenterPoint) && Enum.IsDefined(arc.Rotation),
|
||||
"Part arc geometry must be finite with a valid direction.", "plate");
|
||||
if (code is SubProgramCall call)
|
||||
{
|
||||
Require(Finite(call.Offset) && double.IsFinite(call.Rotation),
|
||||
"Part sub-program pose must be finite.", "plate");
|
||||
ValidateProgram(call.Program, path);
|
||||
}
|
||||
}
|
||||
path.Remove(program);
|
||||
}
|
||||
|
||||
private static void ValidateExisting(Plate plate, Box bounds, CutOffSettings settings)
|
||||
{
|
||||
foreach (var cut in plate.CutOffs)
|
||||
{
|
||||
Require(cut != null && Enum.IsDefined(cut.Axis) && Finite(cut.Position) &&
|
||||
(!cut.StartLimit.HasValue || double.IsFinite(cut.StartLimit.Value)) &&
|
||||
(!cut.EndLimit.HasValue || double.IsFinite(cut.EndLimit.Value)),
|
||||
"Existing cut-off definitions must be finite with a valid axis.", nameof(plate));
|
||||
var start = cut.StartLimit ?? (cut.Axis == CutOffAxis.Vertical ? bounds.Bottom : bounds.Left);
|
||||
var end = cut.EndLimit ?? ((cut.Axis == CutOffAxis.Vertical ? bounds.Top : bounds.Right)
|
||||
+ settings.Overtravel);
|
||||
Require(double.IsFinite(end) && start < end,
|
||||
"Existing cut-off limits must be finite and ordered.", nameof(plate));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
|
||||
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
|
||||
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
|
||||
|
||||
private static bool HasUsableSegments(Program program)
|
||||
{
|
||||
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
|
||||
return false;
|
||||
for (var i = 0; i < program.Codes.Count; i += 2)
|
||||
{
|
||||
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
|
||||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
|
||||
!Near(from.EndPoint.X, to.EndPoint.X) ||
|
||||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFullSpanProgram(Program program, Box bounds)
|
||||
{
|
||||
// No gaps, bridges, or filtered middle segments can separate the tail.
|
||||
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
|
||||
program.Codes[1] is not LinearMove to)
|
||||
return false;
|
||||
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
|
||||
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
|
||||
}
|
||||
|
||||
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
|
||||
Nonnegative(box.Length) && Nonnegative(box.Width) &&
|
||||
double.IsFinite(box.Right) && double.IsFinite(box.Top);
|
||||
|
||||
private static bool Inside(Box box, Box sheet) =>
|
||||
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
|
||||
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
|
||||
|
||||
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
||||
private static bool Near(double a, double b)
|
||||
{
|
||||
// An exact tolerance-sized offset can round just above epsilon after subtraction.
|
||||
// Allow two representational steps, not a geometry-scale relative tolerance.
|
||||
var magnitude = System.Math.Max(System.Math.Abs(a), System.Math.Abs(b));
|
||||
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
||||
return System.Math.Abs(a - b) <= GeometryTolerance + (double.IsFinite(step) ? 2 * step : 0);
|
||||
}
|
||||
|
||||
private static void Require(bool valid, string message, string parameter)
|
||||
{
|
||||
if (!valid)
|
||||
throw new ArgumentException(message, parameter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests.CutOffs;
|
||||
|
||||
public class AutomaticCutOffLifecycleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void Apply_AppendsPlannedDefinitions_AfterExistingMixedSequence(int quadrant)
|
||||
{
|
||||
var settings = new CutOffSettings { PartClearance = 0.75, Overtravel = 2 };
|
||||
var nest = MakeNest(quadrant, settings);
|
||||
var plate = Assert.Single(nest.Plates);
|
||||
var realParts = CaptureRealParts(plate);
|
||||
var manual = Assert.Single(plate.CutOffs);
|
||||
var manualDefinition = Definition(manual);
|
||||
var originalSequence = plate.Parts.ToArray();
|
||||
|
||||
var plan = CreatePlan(plate, settings);
|
||||
|
||||
Assert.Equal(originalSequence, plate.Parts.ToArray());
|
||||
Assert.Same(manual, Assert.Single(plate.CutOffs));
|
||||
Assert.Equal(3, plan.PreviewParts.Count);
|
||||
Assert.All(plan.PreviewParts, preview => Assert.DoesNotContain(preview, plate.Parts));
|
||||
var previewPrograms = plan.PreviewParts.Select(p => NestWriter.GetProgramText(p.Program)).ToArray();
|
||||
|
||||
Apply(plate, plan, settings);
|
||||
|
||||
Assert.Equal(new[] { manual }.Concat(plan.Definitions), plate.CutOffs);
|
||||
Assert.Equal(manualDefinition, Definition(manual));
|
||||
AssertSequence(plate, realParts[0].Part, realParts[1].Part);
|
||||
AssertOnePartPerDefinition(plate);
|
||||
AssertRealPartsUnchanged(plate, realParts);
|
||||
for (var i = 0; i < plan.Definitions.Count; i++)
|
||||
{
|
||||
var materialized = plate.Parts[i + 3];
|
||||
Assert.Same(plan.Definitions[i].Drawing, materialized.BaseDrawing);
|
||||
Assert.NotSame(plan.PreviewParts[i], materialized);
|
||||
Assert.Equal(previewPrograms[i], NestWriter.GetProgramText(materialized.Program));
|
||||
}
|
||||
|
||||
var repeated = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings);
|
||||
Assert.Empty(repeated.Definitions);
|
||||
Assert.Empty(repeated.PreviewParts);
|
||||
Assert.False(repeated.HasBlockingDiagnostics);
|
||||
Assert.True(repeated.HasSeparatedTail);
|
||||
Assert.Equal(plan.TailSeparatorX, repeated.TailSeparatorX);
|
||||
Assert.Equal(plan.UsedSpan, repeated.UsedSpan);
|
||||
Assert.Equal(plan.TailLength, repeated.TailLength);
|
||||
Assert.Equal(3, repeated.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff));
|
||||
|
||||
// Accepting the empty repeat and regenerating must not duplicate or resequence anything.
|
||||
Apply(plate, repeated, settings);
|
||||
Assert.Equal(new[] { manual }.Concat(plan.Definitions), plate.CutOffs);
|
||||
AssertSequence(plate, realParts[0].Part, realParts[1].Part);
|
||||
AssertOnePartPerDefinition(plate);
|
||||
AssertRealPartsUnchanged(plate, realParts);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void SaveAndReload_PreservesDefinitionsLimitsAndExactMixedSequence(int quadrant)
|
||||
{
|
||||
// NestReader uses default CutOffSettings; nondefault toolpath fidelity is not this contract.
|
||||
var settings = new CutOffSettings();
|
||||
var nest = MakeNest(quadrant, settings);
|
||||
var plate = Assert.Single(nest.Plates);
|
||||
var realParts = CaptureRealParts(plate);
|
||||
Apply(plate, CreatePlan(plate, settings), settings);
|
||||
var definitions = plate.CutOffs.Select(Definition).ToArray();
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
Assert.True(new NestWriter(nest).Write(stream));
|
||||
AssertArchiveContainsOnlyRealParts(stream, plate);
|
||||
stream.Position = 0;
|
||||
var loaded = new NestReader(stream).Read();
|
||||
var loadedPlate = Assert.Single(loaded.Plates);
|
||||
var loadedRealParts = loadedPlate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray();
|
||||
|
||||
Assert.Equal(quadrant, loadedPlate.Quadrant);
|
||||
Assert.Equal(plate.Size, loadedPlate.Size);
|
||||
Assert.Equal(plate.PartSpacing, loadedPlate.PartSpacing);
|
||||
Assert.Equal(plate.Quantity, loadedPlate.Quantity);
|
||||
Assert.Equal(definitions, loadedPlate.CutOffs.Select(Definition));
|
||||
Assert.Equal(realParts.Select(p => p.Location), loadedRealParts.Select(p => p.Location));
|
||||
Assert.Equal(realParts.Select(p => p.ProgramText), loadedRealParts.Select(p => NestWriter.GetProgramText(p.Program)));
|
||||
Assert.Equal(realParts.Select(p => p.Rotation), loadedRealParts.Select(p => p.Rotation));
|
||||
AssertSequence(loadedPlate, loadedRealParts[0], loadedRealParts[1]);
|
||||
AssertOnePartPerDefinition(loadedPlate);
|
||||
var loadedDrawing = Assert.Single(loaded.Drawings);
|
||||
Assert.False(loadedDrawing.IsCutOff);
|
||||
Assert.All(loadedRealParts, p => Assert.Same(loadedDrawing, p.BaseDrawing));
|
||||
Assert.Equal(realParts[0].Required, loadedDrawing.Quantity.Required);
|
||||
Assert.Equal(realParts[0].Nested, loadedDrawing.Quantity.Nested);
|
||||
AssertRealPartsUnchanged(plate, realParts);
|
||||
|
||||
var loadedState = CaptureRealParts(loadedPlate);
|
||||
loadedPlate.RegenerateCutOffs(settings);
|
||||
Assert.Equal(definitions, loadedPlate.CutOffs.Select(Definition));
|
||||
AssertSequence(loadedPlate, loadedRealParts[0], loadedRealParts[1]);
|
||||
AssertOnePartPerDefinition(loadedPlate);
|
||||
AssertRealPartsUnchanged(loadedPlate, loadedState);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 5)]
|
||||
[InlineData(2, 5)]
|
||||
[InlineData(3, 5)]
|
||||
[InlineData(4, 5)]
|
||||
[InlineData(1, 10)]
|
||||
[InlineData(2, 10)]
|
||||
[InlineData(3, 10)]
|
||||
[InlineData(4, 10)]
|
||||
public void MovePartAfterReload_RegeneratesSafeSegments_WithoutMovingDefinitions(int quadrant, double offsetX)
|
||||
{
|
||||
var settings = new CutOffSettings();
|
||||
var nest = MakeNest(quadrant, settings);
|
||||
var source = Assert.Single(nest.Plates);
|
||||
Apply(source, CreatePlan(source, settings), settings);
|
||||
using var stream = new MemoryStream();
|
||||
Assert.True(new NestWriter(nest).Write(stream));
|
||||
stream.Position = 0;
|
||||
var loaded = new NestReader(stream).Read();
|
||||
var plate = Assert.Single(loaded.Plates);
|
||||
var definitions = plate.CutOffs.ToArray();
|
||||
var definitionValues = definitions.Select(Definition).ToArray();
|
||||
var realParts = CaptureRealParts(plate);
|
||||
var moved = realParts[1].Part;
|
||||
var separator = definitions[^1];
|
||||
var separatorPart = plate.Parts[^1];
|
||||
var before = Assert.Single(Segments(separatorPart));
|
||||
var bounds = plate.BoundingBox(false);
|
||||
Assert.Equal(bounds.Bottom, before.From.Y);
|
||||
Assert.Equal(bounds.Top, before.To.Y);
|
||||
Assert.Equal(separator.Position.X, before.From.X);
|
||||
Assert.Equal(separator.Position.X, before.To.X);
|
||||
AssertCutsAvoidRectangles(plate, settings.PartClearance);
|
||||
|
||||
// Move the outer part across the old separator: its fixed line must now be trimmed,
|
||||
// not shifted to a new tail or left as a stale full-width cut through the part.
|
||||
var offset = new Vector(quadrant is 2 or 3 ? -offsetX : offsetX, quadrant is 3 or 4 ? -10 : 10);
|
||||
moved.Offset(offset);
|
||||
Assert.Equal(realParts[1].Location + offset, moved.Location);
|
||||
var movedState = CaptureRealParts(plate);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
|
||||
Assert.Equal(definitions, plate.CutOffs.ToArray());
|
||||
Assert.Equal(definitionValues, plate.CutOffs.Select(Definition));
|
||||
AssertSequence(plate, realParts[0].Part, moved);
|
||||
AssertOnePartPerDefinition(plate);
|
||||
AssertRealPartsUnchanged(plate, movedState);
|
||||
AssertRealPartsUnchanged(plate, new[] { realParts[0], realParts[1] with { Location = moved.Location } });
|
||||
Assert.NotSame(separatorPart, plate.Parts[^1]);
|
||||
Assert.DoesNotContain(separatorPart, plate.Parts);
|
||||
var after = Segments(plate.Parts[^1]);
|
||||
Assert.Equal(2, after.Length);
|
||||
Assert.All(after, s =>
|
||||
{
|
||||
Assert.Equal(separator.Position.X, s.From.X);
|
||||
Assert.Equal(separator.Position.X, s.To.X);
|
||||
});
|
||||
AssertCutsAvoidRectangles(plate, settings.PartClearance);
|
||||
|
||||
var regeneratedPrograms = plate.Parts.Where(p => p.BaseDrawing.IsCutOff)
|
||||
.Select(p => NestWriter.GetProgramText(p.Program)).ToArray();
|
||||
plate.RegenerateCutOffs(settings);
|
||||
Assert.Equal(definitionValues, plate.CutOffs.Select(Definition));
|
||||
Assert.Equal(regeneratedPrograms, plate.Parts.Where(p => p.BaseDrawing.IsCutOff)
|
||||
.Select(p => NestWriter.GetProgramText(p.Program)));
|
||||
AssertSequence(plate, realParts[0].Part, moved);
|
||||
AssertOnePartPerDefinition(plate);
|
||||
AssertRealPartsUnchanged(plate, movedState);
|
||||
AssertCutsAvoidRectangles(plate, settings.PartClearance);
|
||||
}
|
||||
|
||||
private static Nest MakeNest(int quadrant, CutOffSettings settings)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(0, 0));
|
||||
program.Codes.Add(new LinearMove(20, 0));
|
||||
program.Codes.Add(new LinearMove(20, 20));
|
||||
program.Codes.Add(new LinearMove(0, 20));
|
||||
program.Codes.Add(new LinearMove(0, 0));
|
||||
Assert.True(Assert.Single(ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(program))).IsClosed());
|
||||
var drawing = new Drawing("square", program);
|
||||
Assert.Equal(400, drawing.Area);
|
||||
drawing.Quantity.Required = 10;
|
||||
var nest = new Nest("automatic-cutoff-lifecycle");
|
||||
nest.Drawings.Add(drawing);
|
||||
var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 };
|
||||
var negativeX = quadrant is 2 or 3;
|
||||
var negativeY = quadrant is 3 or 4;
|
||||
var first = new Part(drawing, new Vector(negativeX ? -30 : 10, negativeY ? -40 : 20));
|
||||
var second = new Part(drawing, new Vector(negativeX ? -80 : 60, negativeY ? -40 : 20));
|
||||
plate.Parts.Add(first);
|
||||
plate.Parts.Add(second);
|
||||
var manual = new CutOff(new Vector(negativeX ? -15 : 15, negativeY ? -10 : 10), CutOffAxis.Horizontal)
|
||||
{
|
||||
StartLimit = negativeX ? -25 : 5,
|
||||
EndLimit = negativeX ? -5 : 25,
|
||||
};
|
||||
plate.CutOffs.Add(manual);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
var manualPart = plate.Parts[^1];
|
||||
plate.Parts.Remove(manualPart);
|
||||
plate.Parts.Insert(1, manualPart);
|
||||
Assert.Equal(new[] { first, manualPart, second }, plate.Parts);
|
||||
Assert.Equal(4, drawing.Quantity.Nested);
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
|
||||
private static AutomaticCutOffPlan CreatePlan(Plate plate, CutOffSettings settings)
|
||||
{
|
||||
var plan = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings);
|
||||
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
|
||||
var separator = 80 + System.Math.Max(plate.PartSpacing, settings.PartClearance) + Tolerance.Epsilon;
|
||||
Assert.False(plan.HasBlockingDiagnostics);
|
||||
Assert.True(plan.HasSeparatedTail);
|
||||
Assert.Equal(80, plan.OccupiedSpan);
|
||||
Assert.Equal(separator, plan.UsedSpan);
|
||||
Assert.Equal(120 - separator, plan.TailLength);
|
||||
Assert.Equal(sign * separator, plan.TailSeparatorX);
|
||||
Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * separator }, plan.Definitions.Select(c => c.Position.X));
|
||||
Assert.All(plan.Definitions, c =>
|
||||
{
|
||||
Assert.Equal(CutOffAxis.Vertical, c.Axis);
|
||||
Assert.Null(c.StartLimit);
|
||||
Assert.Null(c.EndLimit);
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static void Apply(Plate plate, AutomaticCutOffPlan plan, CutOffSettings settings)
|
||||
{
|
||||
Assert.False(plan.HasBlockingDiagnostics);
|
||||
foreach (var definition in plan.Definitions)
|
||||
plate.CutOffs.Add(definition);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
}
|
||||
|
||||
private static void AssertSequence(Plate plate, Part first, Part second) =>
|
||||
Assert.Collection(plate.Parts,
|
||||
p => Assert.Same(first, p),
|
||||
p => Assert.Same(plate.CutOffs[0].Drawing, p.BaseDrawing),
|
||||
p => Assert.Same(second, p),
|
||||
p => Assert.Same(plate.CutOffs[1].Drawing, p.BaseDrawing),
|
||||
p => Assert.Same(plate.CutOffs[2].Drawing, p.BaseDrawing),
|
||||
p => Assert.Same(plate.CutOffs[3].Drawing, p.BaseDrawing));
|
||||
|
||||
private static void AssertOnePartPerDefinition(Plate plate)
|
||||
{
|
||||
Assert.Equal(4, plate.CutOffs.Count);
|
||||
Assert.Equal(4, plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
|
||||
Assert.All(plate.CutOffs, c =>
|
||||
{
|
||||
Assert.True(c.Drawing.IsCutOff);
|
||||
Assert.Single(plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, c.Drawing)));
|
||||
});
|
||||
}
|
||||
|
||||
private static (double X, double Y, CutOffAxis Axis, double? Start, double? End) Definition(CutOff cut) =>
|
||||
(cut.Position.X, cut.Position.Y, cut.Axis, cut.StartLimit, cut.EndLimit);
|
||||
|
||||
private sealed record RealPartState(Part Part, Program Program, string ProgramText, Vector Location,
|
||||
double Rotation, Program DrawingProgram, string DrawingText, int Required, int Nested);
|
||||
|
||||
private static RealPartState[] CaptureRealParts(Plate plate) => plate.Parts
|
||||
.Where(p => !p.BaseDrawing.IsCutOff)
|
||||
.Select(p => new RealPartState(p, p.Program, NestWriter.GetProgramText(p.Program), p.Location,
|
||||
p.Rotation, p.BaseDrawing.Program, NestWriter.GetProgramText(p.BaseDrawing.Program),
|
||||
p.BaseDrawing.Quantity.Required, p.BaseDrawing.Quantity.Nested)).ToArray();
|
||||
|
||||
private static void AssertRealPartsUnchanged(Plate plate, RealPartState[] expected)
|
||||
{
|
||||
Assert.Equal(expected.Select(s => s.Part), plate.Parts.Where(p => !p.BaseDrawing.IsCutOff));
|
||||
Assert.All(expected, s =>
|
||||
{
|
||||
Assert.Same(s.Program, s.Part.Program);
|
||||
Assert.Equal(s.ProgramText, NestWriter.GetProgramText(s.Part.Program));
|
||||
Assert.Equal(s.Location, s.Part.Location);
|
||||
Assert.Equal(s.Rotation, s.Part.Rotation);
|
||||
Assert.Same(s.DrawingProgram, s.Part.BaseDrawing.Program);
|
||||
Assert.Equal(s.DrawingText, NestWriter.GetProgramText(s.Part.BaseDrawing.Program));
|
||||
Assert.Equal(s.Required, s.Part.BaseDrawing.Quantity.Required);
|
||||
Assert.Equal(s.Nested, s.Part.BaseDrawing.Quantity.Nested);
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertArchiveContainsOnlyRealParts(MemoryStream stream, Plate plate)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
using var reader = new StreamReader(archive.GetEntry("nest.json")!.Open());
|
||||
using var json = JsonDocument.Parse(reader.ReadToEnd());
|
||||
var savedPlate = Assert.Single(json.RootElement.GetProperty("plates").EnumerateArray());
|
||||
var savedParts = savedPlate.GetProperty("parts").EnumerateArray().ToArray();
|
||||
Assert.Equal(2, savedParts.Length);
|
||||
Assert.Single(json.RootElement.GetProperty("drawings").EnumerateArray());
|
||||
Assert.Single(archive.Entries.Where(e => e.FullName.StartsWith("programs/", StringComparison.Ordinal)));
|
||||
Assert.DoesNotContain(archive.Entries, e => e.FullName.StartsWith("parts/", StringComparison.Ordinal));
|
||||
var realParts = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray();
|
||||
for (var i = 0; i < realParts.Length; i++)
|
||||
{
|
||||
Assert.Equal(realParts[i].Location.X, savedParts[i].GetProperty("x").GetDouble());
|
||||
Assert.Equal(realParts[i].Location.Y, savedParts[i].GetProperty("y").GetDouble());
|
||||
}
|
||||
var cuts = savedPlate.GetProperty("cutOffs").EnumerateArray().ToArray();
|
||||
Assert.Equal(new[] { 1, 3, 4, 5 }, cuts.Select(c => c.GetProperty("sequence").GetInt32()));
|
||||
}
|
||||
|
||||
private static (Vector From, Vector To)[] Segments(Part part)
|
||||
{
|
||||
Assert.NotEmpty(part.Program.Codes);
|
||||
Assert.Equal(0, part.Program.Codes.Count % 2);
|
||||
return Enumerable.Range(0, part.Program.Codes.Count / 2).Select(i => (
|
||||
Assert.IsType<RapidMove>(part.Program.Codes[2 * i]).EndPoint + part.Location,
|
||||
Assert.IsType<LinearMove>(part.Program.Codes[2 * i + 1]).EndPoint + part.Location)).ToArray();
|
||||
}
|
||||
|
||||
private static void AssertCutsAvoidRectangles(Plate plate, double clearance)
|
||||
{
|
||||
// Independent axis-aligned rectangle oracle: inspect every emitted cutting move,
|
||||
// not rapids, counts, or the same CutOff.ComputeSegments algorithm under test.
|
||||
var rectangles = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).Select(p => p.BoundingBox).ToArray();
|
||||
foreach (var cutPart in plate.Parts.Where(p => p.BaseDrawing.IsCutOff))
|
||||
foreach (var segment in Segments(cutPart))
|
||||
{
|
||||
var minX = System.Math.Min(segment.From.X, segment.To.X);
|
||||
var maxX = System.Math.Max(segment.From.X, segment.To.X);
|
||||
var minY = System.Math.Min(segment.From.Y, segment.To.Y);
|
||||
var maxY = System.Math.Max(segment.From.Y, segment.To.Y);
|
||||
Assert.True(minX == maxX || minY == maxY);
|
||||
Assert.True(minX < maxX || minY < maxY);
|
||||
Assert.All(rectangles, box => Assert.True(
|
||||
maxX <= box.Left - clearance || minX >= box.Right + clearance ||
|
||||
maxY <= box.Bottom - clearance || minY >= box.Top + clearance,
|
||||
$"Cut {segment.From} -> {segment.To} crosses a rectangle or its clearance."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
using System.Reflection;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests.CutOffs;
|
||||
|
||||
public class AutomaticCutOffPlannerTests
|
||||
{
|
||||
private static Plate MakePlate(int quadrant = 1, double occupied = 80)
|
||||
{
|
||||
var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 };
|
||||
var x = quadrant is 2 or 3 ? -occupied : 10;
|
||||
var y = quadrant is 3 or 4 ? -40 : 20;
|
||||
plate.Parts.Add(Rectangle(occupied - 10, 20, new Vector(x, y)));
|
||||
return plate;
|
||||
}
|
||||
|
||||
private static Part Rectangle(double length, double width, Vector location)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(0, 0));
|
||||
program.Codes.Add(new LinearMove(length, 0));
|
||||
program.Codes.Add(new LinearMove(length, width));
|
||||
program.Codes.Add(new LinearMove(0, width));
|
||||
program.Codes.Add(new LinearMove(0, 0));
|
||||
var drawing = new Drawing("rectangle", program);
|
||||
Assert.Equal(length * width, drawing.Area, 8);
|
||||
return new Part(drawing, location);
|
||||
}
|
||||
|
||||
private static AutomaticCutOffPlan Plan(Plate plate, double spacing = 35,
|
||||
CutOffSettings? settings = null) => AutomaticCutOffPlanner.Create(
|
||||
plate, new AutomaticCutOffOptions { Spacing = spacing }, settings ?? new CutOffSettings());
|
||||
|
||||
private static double[] Positions(AutomaticCutOffPlan plan) =>
|
||||
plan.Definitions.Select(c => c.Position.X).ToArray();
|
||||
|
||||
private static (Vector From, Vector To)[] Segments(Program program) =>
|
||||
Enumerable.Range(0, program.Codes.Count / 2).Select(i => (
|
||||
Assert.IsType<RapidMove>(program.Codes[2 * i]).EndPoint,
|
||||
Assert.IsType<LinearMove>(program.Codes[2 * i + 1]).EndPoint)).ToArray();
|
||||
|
||||
[Fact]
|
||||
public void UsedEnvelope_StopsBeforeUnusedTail_AndPreviewsAreDetached()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
var plan = Plan(plate);
|
||||
var separator = 80 + 0.5 + Tolerance.Epsilon;
|
||||
|
||||
Assert.Equal(new[] { 35, 70, separator }, Positions(plan));
|
||||
Assert.All(plan.Definitions, c => Assert.Equal(CutOffAxis.Vertical, c.Axis));
|
||||
Assert.Equal(80, plan.OccupiedSpan);
|
||||
Assert.Equal(separator, plan.UsedSpan);
|
||||
Assert.Equal(120 - separator, plan.TailLength);
|
||||
Assert.Equal(separator, plan.TailSeparatorX);
|
||||
Assert.True(plan.HasSeparatedTail);
|
||||
Assert.False(plan.HasBlockingDiagnostics);
|
||||
Assert.Equal(plan.Definitions.Count, plan.PreviewParts.Count);
|
||||
for (var i = 0; i < plan.Definitions.Count; i++)
|
||||
{
|
||||
Assert.Same(plan.Definitions[i].Drawing, plan.PreviewParts[i].BaseDrawing);
|
||||
Assert.DoesNotContain(plan.PreviewParts[i], plate.Parts);
|
||||
}
|
||||
var segment = Assert.Single(Segments(plan.Definitions[^1].Drawing.Program));
|
||||
Assert.Equal(new Vector(separator, 0), segment.From);
|
||||
Assert.Equal(new Vector(separator, 81), segment.To);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2, 0.5)]
|
||||
[InlineData(0.5, 2)]
|
||||
[InlineData(2, 2)]
|
||||
[InlineData(0, 0)]
|
||||
public void TailUsesGreaterClearance_NotSum(double partSpacing, double clearance)
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.PartSpacing = partSpacing;
|
||||
var plan = Plan(plate, settings: new CutOffSettings { PartClearance = clearance });
|
||||
Assert.Equal(80 + System.Math.Max(partSpacing, clearance) + Tolerance.Epsilon,
|
||||
plan.TailSeparatorX);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(2, -1)]
|
||||
[InlineData(3, -1)]
|
||||
[InlineData(4, 1)]
|
||||
public void AllQuadrants_AdvanceXFromOrigin_AcrossPhysicalWidth(int quadrant, int sign)
|
||||
{
|
||||
var plate = MakePlate(quadrant);
|
||||
plate.EdgeSpacing = new Spacing(10, 10);
|
||||
var settings = new CutOffSettings { Overtravel = 3 };
|
||||
var plan = Plan(plate, settings: settings);
|
||||
Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * (80.5 + Tolerance.Epsilon) },
|
||||
Positions(plan));
|
||||
var bounds = plate.BoundingBox(false);
|
||||
var segment = Assert.Single(Segments(plan.Definitions[^1].Drawing.Program));
|
||||
Assert.Equal(bounds.Bottom, segment.From.Y);
|
||||
Assert.Equal(bounds.Top + 3, segment.To.Y);
|
||||
Assert.Equal(80.5 + Tolerance.Epsilon, plan.UsedSpan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Planning_DoesNotMutateAnyLiveObjectsOrSequence()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
var drawing = plate.Parts[0].BaseDrawing;
|
||||
var nest = new Nest("test");
|
||||
nest.Drawings.Add(drawing);
|
||||
nest.Plates.Add(plate);
|
||||
var manual = new CutOff(new Vector(25, 0), CutOffAxis.Vertical)
|
||||
{ StartLimit = 5, EndLimit = 12 };
|
||||
plate.CutOffs.Add(manual);
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
var cutoffPart = plate.Parts[^1];
|
||||
plate.Parts.Remove(cutoffPart);
|
||||
plate.Parts.Insert(0, cutoffPart);
|
||||
var parts = plate.Parts.ToArray();
|
||||
var definitions = plate.CutOffs.ToArray();
|
||||
var programs = parts.Select(p => p.Program).ToArray();
|
||||
var text = programs.Select(p => p.ToString()).ToArray();
|
||||
var locations = parts.Select(p => p.Location).ToArray();
|
||||
var bounds = parts.Select(p => p.BoundingBox).ToArray();
|
||||
var drawingProgram = drawing.Program;
|
||||
var cutoffProgram = manual.Drawing.Program;
|
||||
var nested = drawing.Quantity.Nested;
|
||||
var changes = 0;
|
||||
plate.PartAdded += (_, _) => changes++;
|
||||
plate.PartRemoved += (_, _) => changes++;
|
||||
plate.PartChanged += (_, _) => changes++;
|
||||
|
||||
var plan = Plan(plate);
|
||||
|
||||
Assert.NotEmpty(plan.Definitions);
|
||||
Assert.Equal(parts, plate.Parts.ToArray());
|
||||
Assert.Equal(definitions, plate.CutOffs.ToArray());
|
||||
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
|
||||
Assert.Equal(text, plate.Parts.Select(p => p.Program.ToString()));
|
||||
Assert.Equal(locations, plate.Parts.Select(p => p.Location));
|
||||
Assert.Equal(bounds, plate.Parts.Select(p => p.BoundingBox));
|
||||
Assert.Same(drawingProgram, drawing.Program);
|
||||
Assert.Same(cutoffProgram, manual.Drawing.Program);
|
||||
Assert.Equal(nested, drawing.Quantity.Nested);
|
||||
Assert.Same(drawing, Assert.Single(nest.Drawings));
|
||||
Assert.Equal(5, manual.StartLimit);
|
||||
Assert.Equal(12, manual.EndLimit);
|
||||
Assert.Equal(0, changes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CutDirection.AwayFromOrigin)]
|
||||
[InlineData(CutDirection.TowardOrigin)]
|
||||
public void RepeatedLines_UseCurrentManualSegmentation_WithoutIncreasingClearance(CutDirection direction)
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.PartSpacing = 8;
|
||||
var settings = new CutOffSettings
|
||||
{ PartClearance = 1, Overtravel = 2, MinSegmentLength = 0.5, CutDirection = direction };
|
||||
var plan = Plan(plate, settings: settings);
|
||||
var automatic = plan.Definitions[0];
|
||||
var manual = new CutOff(automatic.Position, automatic.Axis);
|
||||
manual.Regenerate(plate, settings, Plate.BuildPerimeterCache(plate));
|
||||
Assert.Equal(manual.Drawing.Program.ToString(), automatic.Drawing.Program.ToString());
|
||||
var segments = Segments(automatic.Drawing.Program);
|
||||
Assert.Equal(2, segments.Length);
|
||||
Assert.All(segments, s => Assert.True(
|
||||
System.Math.Max(s.From.Y, s.To.Y) <= 19 ||
|
||||
System.Math.Min(s.From.Y, s.To.Y) >= 41));
|
||||
Assert.Contains(segments, s => System.Math.Abs(s.To.Y - 19) < 0.01 ||
|
||||
System.Math.Abs(s.From.Y - 19) < 0.01);
|
||||
Assert.Equal(1, settings.PartClearance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyPlate_AndCutOffOnlyPlate_AreNeverChopped()
|
||||
{
|
||||
var plate = new Plate(81, 120);
|
||||
Assert.Empty(Plan(plate).Definitions);
|
||||
var old = new CutOff(new Vector(110, 0), CutOffAxis.Vertical);
|
||||
plate.CutOffs.Add(old);
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
var plan = Plan(plate);
|
||||
Assert.Empty(plan.Definitions);
|
||||
Assert.Empty(plan.PreviewParts);
|
||||
Assert.Equal(0, plan.OccupiedSpan);
|
||||
Assert.Equal(0, plan.UsedSpan);
|
||||
Assert.Equal(0, plan.TailLength);
|
||||
Assert.False(plan.HasSeparatedTail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldCutOffParts_DoNotExtendTheEnvelope()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.CutOffs.Add(new CutOff(new Vector(115, 0), CutOffAxis.Vertical));
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
Assert.Equal(new[] { 35, 70, 80.5 + Tolerance.Epsilon }, Positions(Plan(plate)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UsedSpanSmallerThanPitch_OnlyGeneratesSeparator()
|
||||
{
|
||||
var plan = Plan(MakePlate(occupied: 20));
|
||||
Assert.Equal(new[] { 20.5 + Tolerance.Epsilon }, Positions(plan));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactPitchAtSeparator_DoesNotGenerateBoundaryTwice()
|
||||
{
|
||||
var plate = MakePlate(occupied: 69.5 - Tolerance.Epsilon);
|
||||
Assert.Equal(new[] { 35.0, 70.0 }, Positions(Plan(plate)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(119.5)]
|
||||
[InlineData(120)]
|
||||
public void NoRoomForSeparator_UsesFullSheet_NoEdgeDuplicate(double occupied)
|
||||
{
|
||||
var plan = Plan(MakePlate(occupied: occupied), spacing: 40);
|
||||
Assert.Equal(new[] { 40.0, 80.0 }, Positions(plan));
|
||||
Assert.Equal(120, plan.UsedSpan);
|
||||
Assert.False(plan.HasSeparatedTail);
|
||||
Assert.Null(plan.TailSeparatorX);
|
||||
Assert.Equal(0, plan.TailLength);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
[InlineData(double.NegativeInfinity)]
|
||||
[InlineData(double.Epsilon)]
|
||||
[InlineData(0.000001)]
|
||||
[InlineData(0.00001)]
|
||||
[InlineData(0.001)]
|
||||
public void InvalidOrExcessivePitch_IsRejected(double pitch)
|
||||
{
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(MakePlate(), pitch));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
[InlineData(double.NegativeInfinity)]
|
||||
public void InvalidSheetDimensions_AreRejected(double value)
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.Size = new Size(value, 120);
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
plate.Size = new Size(81, value);
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
[InlineData(double.NegativeInfinity)]
|
||||
public void InvalidPartSpacingAndSettings_AreRejected(double value)
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.PartSpacing = value;
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
plate.PartSpacing = 0;
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
|
||||
settings: new CutOffSettings { PartClearance = value }));
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
|
||||
settings: new CutOffSettings { Overtravel = value }));
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
|
||||
settings: new CutOffSettings { MinSegmentLength = value }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidQuadrantOrDirection_IsRejected()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
|
||||
settings: new CutOffSettings { CutDirection = (CutDirection)42 }));
|
||||
// The public setter coerces bad quadrants; simulate damaged state explicitly.
|
||||
typeof(Plate).GetField("quadrant", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.SetValue(plate, 0);
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullArguments_AreRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => AutomaticCutOffPlanner.Create(null!, new(), new()));
|
||||
Assert.Throws<ArgumentNullException>(() => AutomaticCutOffPlanner.Create(MakePlate(), null!, new()));
|
||||
Assert.Throws<ArgumentNullException>(() => AutomaticCutOffPlanner.Create(MakePlate(), new(), null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-11, 0)]
|
||||
[InlineData(50, 0)]
|
||||
[InlineData(0, -21)]
|
||||
[InlineData(0, 42)]
|
||||
[InlineData(double.NaN, 0)]
|
||||
[InlineData(double.PositiveInfinity, 0)]
|
||||
public void InvalidOrOutOfSheetGeometry_IsRejected(double dx, double dy)
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.Parts[0].Offset(dx, dy);
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyOrNonfiniteProgramGeometry_IsRejected_EvenWithOldBounds()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.Parts[0].Program.Codes.Clear();
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
plate = MakePlate();
|
||||
((Motion)plate.Parts[0].Program.Codes[2]).EndPoint = new Vector(double.NaN, 2);
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyRepeatedCuts_AreDiagnostic_NotAcceptedPartitions()
|
||||
{
|
||||
var plate = new Plate(81, 120);
|
||||
plate.Parts.Add(Rectangle(80, 81, new Vector()));
|
||||
var plan = Plan(plate);
|
||||
Assert.Single(plan.Definitions);
|
||||
Assert.True(plan.HasSeparatedTail);
|
||||
Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.EmptyCut));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilteredSeparator_DoesNotClaimSeparatedTail()
|
||||
{
|
||||
var plan = Plan(MakePlate(), settings: new CutOffSettings { MinSegmentLength = 100 });
|
||||
Assert.Empty(plan.Definitions);
|
||||
Assert.False(plan.HasSeparatedTail);
|
||||
Assert.Equal(0, plan.TailLength);
|
||||
Assert.Null(plan.TailSeparatorX);
|
||||
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyingDefinitions_ThenRerunning_PreservesPartsProgramsQuantitiesAndSequence()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
var real = plate.Parts[0];
|
||||
var secondReal = new Part(real.BaseDrawing, new Vector(0, 60));
|
||||
plate.Parts.Add(secondReal);
|
||||
var secondProgram = secondReal.Program;
|
||||
var secondLocation = secondReal.Location;
|
||||
var program = real.Program;
|
||||
var text = program.ToString();
|
||||
var location = real.Location;
|
||||
var nested = real.BaseDrawing.Quantity.Nested;
|
||||
var settings = new CutOffSettings { Overtravel = 2, PartClearance = 0.75 };
|
||||
var manual = new CutOff(new Vector(15, 0), CutOffAxis.Horizontal)
|
||||
{ StartLimit = 5, EndLimit = 15 };
|
||||
plate.CutOffs.Add(manual);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
var manualPart = plate.Parts[^1];
|
||||
plate.Parts.Remove(manualPart);
|
||||
plate.Parts.Insert(0, manualPart);
|
||||
var first = Plan(plate, settings: settings);
|
||||
foreach (var definition in first.Definitions)
|
||||
plate.CutOffs.Add(definition);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
var second = Plan(plate, settings: settings);
|
||||
Assert.Empty(second.Definitions);
|
||||
Assert.True(second.HasSeparatedTail);
|
||||
Assert.Equal(first.UsedSpan, second.UsedSpan);
|
||||
Assert.Equal(3, second.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff));
|
||||
plate.RegenerateCutOffs(settings);
|
||||
Assert.Equal(4, plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
|
||||
Assert.All(plate.CutOffs, c => Assert.Single(plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, c.Drawing))));
|
||||
Assert.Same(manual.Drawing, plate.Parts[0].BaseDrawing);
|
||||
Assert.Same(real, plate.Parts[1]);
|
||||
Assert.Same(secondReal, plate.Parts[2]);
|
||||
Assert.Same(secondProgram, secondReal.Program);
|
||||
Assert.Equal(secondLocation, secondReal.Location);
|
||||
Assert.Same(program, real.Program);
|
||||
Assert.Equal(text, real.Program.ToString());
|
||||
Assert.Equal(location, real.Location);
|
||||
Assert.Equal(nested, real.BaseDrawing.Quantity.Nested);
|
||||
Assert.Equal(5, manual.StartLimit);
|
||||
Assert.Equal(15, manual.EndLimit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void EquivalentFullSpanLimits_AndCoordinateTolerance_SuppressDuplicates(bool explicitLimits)
|
||||
{
|
||||
var plate = MakePlate();
|
||||
var existing = new CutOff(new Vector(35 + Tolerance.Epsilon / 2, 999), CutOffAxis.Vertical);
|
||||
if (explicitLimits)
|
||||
{
|
||||
existing.StartLimit = 0;
|
||||
existing.EndLimit = 83;
|
||||
}
|
||||
plate.CutOffs.Add(existing);
|
||||
var plan = Plan(plate, settings: new CutOffSettings { Overtravel = 2 });
|
||||
Assert.Equal(new[] { 70, 80.5 + Tolerance.Epsilon }, Positions(plan));
|
||||
Assert.False(plan.HasBlockingDiagnostics);
|
||||
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentAxisOrDifferentCoordinate_DoesNotSuppress()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.CutOffs.Add(new CutOff(new Vector(35, 35), CutOffAxis.Horizontal));
|
||||
plate.CutOffs.Add(new CutOff(new Vector(35.01, 0), CutOffAxis.Vertical));
|
||||
Assert.Equal(3, Plan(plate).Definitions.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(35)]
|
||||
[InlineData(80.50001)]
|
||||
public void SameLineLimitedManualCut_BlocksWholePlan_WithoutMutatingManual(double x)
|
||||
{
|
||||
var plate = MakePlate();
|
||||
var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
|
||||
{ StartLimit = 10, EndLimit = 60 };
|
||||
plate.CutOffs.Add(manual);
|
||||
var program = manual.Drawing.Program;
|
||||
var plan = Plan(plate);
|
||||
Assert.True(plan.HasBlockingDiagnostics);
|
||||
Assert.Empty(plan.Definitions);
|
||||
Assert.Empty(plan.PreviewParts);
|
||||
Assert.False(plan.HasSeparatedTail);
|
||||
Assert.Contains(plan.Diagnostics, d => d.IsBlocking &&
|
||||
d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict);
|
||||
Assert.Same(program, manual.Drawing.Program);
|
||||
Assert.Equal(10, manual.StartLimit);
|
||||
Assert.Equal(60, manual.EndLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExistingFullSpanAlongsideLimitedConflict_DoesNotHideConflict()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical));
|
||||
plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 40 });
|
||||
Assert.True(Plan(plate).HasBlockingDiagnostics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExistingSeparator_MetadataUsesItsActualToleranceCloseCoordinate()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
var x = 80.5 + Tolerance.Epsilon * 1.5;
|
||||
plate.CutOffs.Add(new CutOff(new Vector(x, 0), CutOffAxis.Vertical));
|
||||
var plan = Plan(plate);
|
||||
Assert.True(plan.HasSeparatedTail);
|
||||
Assert.Equal(x, plan.TailSeparatorX);
|
||||
Assert.Equal(x, plan.UsedSpan);
|
||||
Assert.Equal(120 - x, plan.TailLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToleranceCloseExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.PartSpacing = 0;
|
||||
plate.CutOffs.Add(new CutOff(new Vector(80, 0), CutOffAxis.Vertical));
|
||||
var plan = Plan(plate, settings: new CutOffSettings { PartClearance = 0 });
|
||||
Assert.False(plan.HasSeparatedTail);
|
||||
Assert.Equal(0, plan.TailLength);
|
||||
Assert.DoesNotContain(plan.Definitions, c => c.Position.X > 70);
|
||||
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FractionalPitch_UsesIntegerMultiplicationWithoutCumulativeDrift()
|
||||
{
|
||||
var plate = MakePlate(occupied: 11);
|
||||
var plan = Plan(plate, spacing: 0.7);
|
||||
var repeated = plan.Definitions.Take(plan.Definitions.Count - 1).ToArray();
|
||||
Assert.NotEmpty(repeated);
|
||||
for (var i = 0; i < repeated.Length; i++)
|
||||
Assert.Equal((i + 1) * 0.7, repeated[i].Position.X);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcaveOrSlopedPart_UsesPerimeterNotJustItsBoundingBox()
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(0, 0));
|
||||
program.Codes.Add(new LinearMove(30, 0));
|
||||
program.Codes.Add(new LinearMove(0, 30));
|
||||
program.Codes.Add(new LinearMove(0, 0));
|
||||
var plate = new Plate(81, 120);
|
||||
plate.Parts.Add(new Part(new Drawing("triangle", program), new Vector(10, 10)));
|
||||
var plan = Plan(plate, spacing: 20, settings: new CutOffSettings { PartClearance = 0 });
|
||||
var segments = Segments(plan.Definitions[0].Drawing.Program);
|
||||
Assert.Equal(2, segments.Length);
|
||||
Assert.Equal(30, segments[1].From.Y, 8); // Bounding-box fallback would start at 40.
|
||||
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.SegmentedCut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidButStalePartBounds_AreRejectedRatherThanUsingUnsafeBroadphase()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
((Motion)plate.Parts[0].Program.Codes[2]).EndPoint = new Vector(90, 20);
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
}
|
||||
|
||||
private static Plate MakeArcPlate(double centerError)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(0, 0));
|
||||
program.Codes.Add(new LinearMove(10, 0));
|
||||
program.Codes.Add(new ArcMove(10, 20, 10, 10 + centerError, RotationType.CCW));
|
||||
program.Codes.Add(new LinearMove(0, 20));
|
||||
program.Codes.Add(new LinearMove(0, 0));
|
||||
var material = ConvertProgram.ToGeometry(program).Where(e => SpecialLayers.IsMaterial(e.Layer));
|
||||
Assert.True(Assert.Single(ShapeBuilder.GetShapes(material)).IsClosed());
|
||||
var drawing = new Drawing("D shape", program);
|
||||
Assert.Equal(200 + 50 * System.Math.PI, drawing.Area, 2); // Drawing area tessellates arcs.
|
||||
var plate = new Plate(81, 120) { PartSpacing = 0 };
|
||||
plate.Parts.Add(new Part(drawing, new Vector(10, 10)));
|
||||
return plate;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0001, false)]
|
||||
[InlineData(0.0000075, true)]
|
||||
public void ConvertedArcOutsideCachedBounds_IsRejectedBeforeAcceptance(double centerError, bool repeatedCut)
|
||||
{
|
||||
var plate = MakeArcPlate(centerError);
|
||||
var real = plate.Parts[0];
|
||||
var cached = real.BoundingBox;
|
||||
var raw = real.Program.BoundingBox().Translate(real.Location);
|
||||
Assert.Equal(raw.Right, cached.Right); // Neither cache refresh nor raw-program bounds reveal the error.
|
||||
Assert.Equal(30 - centerError, cached.Right, 10);
|
||||
var arc = Assert.Single(ConvertProgram.ToGeometry(real.Program).OfType<Arc>());
|
||||
arc.Offset(real.Location);
|
||||
Assert.Equal(30, arc.BoundingBox.Right);
|
||||
var protrusion = arc.BoundingBox.Right - cached.Right;
|
||||
Assert.True(protrusion > 0);
|
||||
Assert.Equal(repeatedCut, protrusion < AutomaticCutOffPlanner.GeometryTolerance);
|
||||
|
||||
// A separator crosses the larger protrusion. Even sub-tolerance protrusions are
|
||||
// unsafe when a repeated line falls between the cached and converted bounds.
|
||||
var unsafeX = cached.Right + (repeatedCut ? protrusion / 2 : AutomaticCutOffPlanner.GeometryTolerance);
|
||||
var unsafeLine = new Line(new Vector(unsafeX, 0), new Vector(unsafeX, 81));
|
||||
Assert.True(arc.Intersects(unsafeLine, out var intersections));
|
||||
Assert.Equal(2, intersections.Count);
|
||||
if (repeatedCut)
|
||||
plate.Parts.Add(Rectangle(10, 10, new Vector(60, 50)));
|
||||
var settings = new CutOffSettings { PartClearance = 0 };
|
||||
var manual = new CutOff(new Vector(100, 0), CutOffAxis.Vertical);
|
||||
plate.CutOffs.Add(manual);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
var parts = plate.Parts.ToArray();
|
||||
var definitions = plate.CutOffs.ToArray();
|
||||
var programs = parts.Select(p => p.Program).ToArray();
|
||||
var text = programs.Select(p => p.ToString()).ToArray();
|
||||
var locations = parts.Select(p => p.Location).ToArray();
|
||||
var bounds = parts.Select(p => p.BoundingBox).ToArray();
|
||||
var manualProgram = manual.Drawing.Program;
|
||||
var originalCenter = Assert.IsType<ArcMove>(real.Program.Codes[2]).CenterPoint;
|
||||
var changes = 0;
|
||||
plate.PartAdded += (_, _) => changes++;
|
||||
plate.PartRemoved += (_, _) => changes++;
|
||||
plate.PartChanged += (_, _) => changes++;
|
||||
AutomaticCutOffPlan? plan = null;
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
{
|
||||
plan = Plan(plate, spacing: repeatedCut ? unsafeX : 35, settings: settings);
|
||||
foreach (var definition in plan.Definitions)
|
||||
plate.CutOffs.Add(definition);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
});
|
||||
|
||||
Assert.Null(plan); // No unsafe proposal can escape to the normal acceptance path.
|
||||
Assert.Equal(parts, plate.Parts.ToArray());
|
||||
Assert.Equal(definitions, plate.CutOffs.ToArray());
|
||||
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
|
||||
Assert.Equal(text, plate.Parts.Select(p => p.Program.ToString()));
|
||||
Assert.Equal(locations, plate.Parts.Select(p => p.Location));
|
||||
Assert.Equal(bounds, plate.Parts.Select(p => p.BoundingBox));
|
||||
Assert.Same(manualProgram, manual.Drawing.Program);
|
||||
Assert.Equal(originalCenter.X, Assert.IsType<ArcMove>(real.Program.Codes[2]).CenterPoint.X);
|
||||
Assert.Equal(originalCenter.Y, Assert.IsType<ArcMove>(real.Program.Codes[2]).CenterPoint.Y);
|
||||
Assert.Equal(0, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactArcWithinCachedBounds_PlansAndMaterializesSafeCuts()
|
||||
{
|
||||
var plate = MakeArcPlate(0);
|
||||
var real = plate.Parts[0];
|
||||
var program = real.Program;
|
||||
var settings = new CutOffSettings { PartClearance = 0 };
|
||||
var plan = Plan(plate, spacing: 25, settings: settings);
|
||||
Assert.True(plan.HasSeparatedTail);
|
||||
Assert.False(plan.HasBlockingDiagnostics);
|
||||
Assert.Equal(30, plan.OccupiedSpan);
|
||||
Assert.Equal(new[] { 25, 30 + AutomaticCutOffPlanner.GeometryTolerance }, Positions(plan));
|
||||
Assert.Same(real, Assert.Single(plate.Parts));
|
||||
Assert.Empty(plate.CutOffs);
|
||||
var previewSegments = plan.PreviewParts.Select(p => Segments(p.Program)).ToArray();
|
||||
|
||||
foreach (var definition in plan.Definitions)
|
||||
plate.CutOffs.Add(definition);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
|
||||
var cuts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray();
|
||||
Assert.Equal(plan.Definitions.Count, cuts.Length);
|
||||
for (var i = 0; i < cuts.Length; i++)
|
||||
Assert.Equal(previewSegments[i], Segments(cuts[i].Program));
|
||||
var repeated = Segments(cuts[0].Program);
|
||||
Assert.Equal(2, repeated.Length);
|
||||
Assert.Equal(20 - System.Math.Sqrt(75), repeated[0].To.Y, 8);
|
||||
Assert.Equal(20 + System.Math.Sqrt(75), repeated[1].From.Y, 8);
|
||||
var separator = Assert.Single(Segments(cuts[1].Program));
|
||||
Assert.Equal(new Vector(plan.TailSeparatorX!.Value, 0), separator.From);
|
||||
Assert.Equal(new Vector(plan.TailSeparatorX.Value, 81), separator.To);
|
||||
var arc = Assert.Single(ConvertProgram.ToGeometry(real.Program).OfType<Arc>());
|
||||
arc.Offset(real.Location);
|
||||
Assert.False(arc.Intersects(new Line(separator.From, separator.To), out _));
|
||||
Assert.Same(real, plate.Parts[0]);
|
||||
Assert.Same(program, real.Program);
|
||||
var repeatedPlan = Plan(plate, spacing: 25, settings: settings);
|
||||
Assert.Empty(repeatedPlan.Definitions);
|
||||
Assert.True(repeatedPlan.HasSeparatedTail);
|
||||
Assert.Equal(plan.TailSeparatorX, repeatedPlan.TailSeparatorX);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidArcCenterAndRecursiveProgram_AreRejectedBeforeGeometryConversion()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
plate.Parts[0].Program.Codes.Add(new ArcMove(new Vector(), new Vector(double.NaN, 0)));
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
plate = MakePlate();
|
||||
var program = plate.Parts[0].Program;
|
||||
program.Codes.Add(new SubProgramCall { Program = program });
|
||||
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void ApplyAndRepeat_IsIdempotentInEveryQuadrant(int quadrant)
|
||||
{
|
||||
var plate = MakePlate(quadrant);
|
||||
var settings = new CutOffSettings();
|
||||
var first = Plan(plate, settings: settings);
|
||||
foreach (var definition in first.Definitions)
|
||||
plate.CutOffs.Add(definition);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
var second = Plan(plate, settings: settings);
|
||||
Assert.Empty(second.Definitions);
|
||||
Assert.True(second.HasSeparatedTail);
|
||||
Assert.Equal(first.TailSeparatorX, second.TailSeparatorX);
|
||||
Assert.Equal(first.TailLength, second.TailLength);
|
||||
Assert.Equal(3, plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExistingSeparator_IsVerifiedUnderCurrentSettings_NotStaleProgram()
|
||||
{
|
||||
var plate = MakePlate();
|
||||
var existing = new CutOff(new Vector(80.5 + Tolerance.Epsilon, 0), CutOffAxis.Vertical);
|
||||
existing.Regenerate(plate, new CutOffSettings());
|
||||
Assert.NotEmpty(existing.Drawing.Program.Codes);
|
||||
plate.CutOffs.Add(existing);
|
||||
var program = existing.Drawing.Program;
|
||||
var plan = Plan(plate, settings: new CutOffSettings { MinSegmentLength = 100 });
|
||||
Assert.False(plan.HasSeparatedTail);
|
||||
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
|
||||
Assert.Same(program, existing.Drawing.Program);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
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)
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var view = CreateView();
|
||||
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($"36 {suffix} width", Control<Label>(form, "sheetLabel").Text);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviewIsDetachedAndDisposeClearsIt()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var view = CreateView();
|
||||
var originalParts = view.Plate.Parts.ToArray();
|
||||
using (var form = new AutomaticCutOffForm(view, Units.Inches))
|
||||
{
|
||||
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
|
||||
Assert.NotEmpty(PreviewParts(view));
|
||||
Assert.Empty(view.Plate.CutOffs);
|
||||
Assert.Equal(originalParts, view.Plate.Parts.ToArray());
|
||||
Assert.Contains("36 in width", Control<Label>(form, "tailLabel").Text);
|
||||
Assert.True(Control<Button>(form, "applyButton").Enabled);
|
||||
}
|
||||
Assert.Empty(PreviewParts(view));
|
||||
Assert.Equal(originalParts, view.Plate.Parts.ToArray());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpacingChangeAndInvalidPreviewClearOldOverlay()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var view = CreateView();
|
||||
using var form = new AutomaticCutOffForm(view, Units.Inches);
|
||||
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
|
||||
Assert.NotEmpty(PreviewParts(view));
|
||||
Control<TextBox>(form, "spacingBox").Text = "0";
|
||||
Assert.Empty(PreviewParts(view));
|
||||
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
|
||||
Assert.False(Control<Button>(form, "applyButton").Enabled);
|
||||
Assert.Contains("Spacing must be", Control<TextBox>(form, "diagnosticsBox").Text);
|
||||
Assert.Empty(view.Plate.CutOffs);
|
||||
Assert.Single(view.Plate.Parts);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseCallbackClearsPreviewWithoutApplying()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var view = CreateView();
|
||||
using var form = new AutomaticCutOffForm(view, Units.Inches);
|
||||
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
|
||||
Invoke(form, "OnFormClosed", new FormClosedEventArgs(CloseReason.UserClosing));
|
||||
Assert.Empty(PreviewParts(view));
|
||||
Assert.Empty(view.Plate.CutOffs);
|
||||
Assert.Single(view.Plate.Parts);
|
||||
Assert.Equal(DialogResult.Cancel, Control<Button>(form, "cancelButton").DialogResult);
|
||||
Assert.Same(Control<Button>(form, "cancelButton"), form.CancelButton);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyRecomputesWithCurrentPartsAndSettingsAndRegeneratesOnce()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var view = CreateView();
|
||||
var existing = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
|
||||
view.Plate.CutOffs.Add(existing);
|
||||
view.Plate.RegenerateCutOffs(view.CutOffSettings);
|
||||
using var form = new AutomaticCutOffForm(view, Units.Inches);
|
||||
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
|
||||
var preview = PreviewParts(view).Select(p => p.BasePart).ToArray();
|
||||
|
||||
// Simulate stale preview inputs. Apply must not trust the displayed proposal.
|
||||
view.Plate.Parts[0].Offset(5, 0);
|
||||
view.CutOffSettings.PartClearance = 2;
|
||||
var expected = AutomaticCutOffPlanner.Create(view.Plate,
|
||||
new AutomaticCutOffOptions { Spacing = 35 }, view.CutOffSettings);
|
||||
var removals = 0;
|
||||
view.Plate.PartRemoved += (_, e) =>
|
||||
{
|
||||
if (e.Item.BaseDrawing.IsCutOff)
|
||||
removals++;
|
||||
};
|
||||
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
|
||||
|
||||
Assert.Equal(DialogResult.OK, form.DialogResult);
|
||||
Assert.Equal(1, removals);
|
||||
Assert.Same(existing, view.Plate.CutOffs[0]);
|
||||
Assert.Equal(expected.Definitions.Select(c => c.Position.X),
|
||||
view.Plate.CutOffs.Skip(1).Select(c => c.Position.X));
|
||||
Assert.Equal(view.Plate.CutOffs.Count, view.Plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
|
||||
Assert.All(preview, p => Assert.DoesNotContain(p, view.Plate.Parts));
|
||||
Assert.Empty(PreviewParts(view));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyRefusesNewBlockingConflictSincePreview()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var view = CreateView();
|
||||
using var form = new AutomaticCutOffForm(view, Units.Inches);
|
||||
Invoke(form, "PreviewButton_Click", null, EventArgs.Empty);
|
||||
var limited = new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 };
|
||||
view.Plate.CutOffs.Add(limited);
|
||||
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
|
||||
Assert.NotEqual(DialogResult.OK, form.DialogResult);
|
||||
Assert.Same(limited, Assert.Single(view.Plate.CutOffs));
|
||||
Assert.Single(view.Plate.Parts);
|
||||
Assert.Empty(PreviewParts(view));
|
||||
Assert.False(Control<Button>(form, "applyButton").Enabled);
|
||||
Assert.Contains("BLOCKING", Control<TextBox>(form, "diagnosticsBox").Text);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyRefusesEmptyPlate()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var view = CreateView();
|
||||
view.Plate.Parts.Clear();
|
||||
using var form = new AutomaticCutOffForm(view, Units.Inches);
|
||||
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
|
||||
Assert.NotEqual(DialogResult.OK, form.DialogResult);
|
||||
Assert.Empty(view.Plate.CutOffs);
|
||||
Assert.Empty(view.Plate.Parts);
|
||||
Assert.Contains("No new usable", Control<TextBox>(form, "diagnosticsBox").Text);
|
||||
Assert.False(Control<Button>(form, "applyButton").Enabled);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedRegenerationRestoresDefinitionsProgramsAndPartSequence()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var view = CreateView();
|
||||
var existing = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
|
||||
view.Plate.CutOffs.Add(existing);
|
||||
view.Plate.RegenerateCutOffs(view.CutOffSettings);
|
||||
var cutoffPart = view.Plate.Parts[1];
|
||||
view.Plate.Parts.Remove(cutoffPart);
|
||||
view.Plate.Parts.Insert(0, cutoffPart);
|
||||
var beforeParts = view.Plate.Parts.ToArray();
|
||||
var beforeProgram = existing.Drawing.Program;
|
||||
var beforeQuantity = beforeParts[1].BaseDrawing.Quantity.Nested;
|
||||
var failOnce = true;
|
||||
view.Plate.PartAdded += (_, e) =>
|
||||
{
|
||||
if (failOnce && e.Item.BaseDrawing.IsCutOff)
|
||||
{
|
||||
failOnce = false;
|
||||
throw new InvalidOperationException("Injected regeneration failure");
|
||||
}
|
||||
};
|
||||
using var form = new AutomaticCutOffForm(view, Units.Inches);
|
||||
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
|
||||
|
||||
Assert.False(failOnce);
|
||||
Assert.NotEqual(DialogResult.OK, form.DialogResult);
|
||||
Assert.Same(existing, Assert.Single(view.Plate.CutOffs));
|
||||
Assert.Same(beforeProgram, existing.Drawing.Program);
|
||||
Assert.Equal(beforeParts, view.Plate.Parts.ToArray());
|
||||
Assert.Equal(beforeQuantity, beforeParts[1].BaseDrawing.Quantity.Nested);
|
||||
Assert.Empty(PreviewParts(view));
|
||||
Assert.Contains("original cut-offs were restored", Control<TextBox>(form, "diagnosticsBox").Text);
|
||||
});
|
||||
}
|
||||
|
||||
private static PlateView CreateView()
|
||||
{
|
||||
var program = new CNC.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(36, 120) { PartSpacing = 0.5, Quantity = 2 };
|
||||
plate.Parts.Add(new Part(new Drawing("rectangle", program), new Vector(10, 8)));
|
||||
return new PlateView { Plate = plate };
|
||||
}
|
||||
|
||||
private static T Control<T>(AutomaticCutOffForm form, string name) where T : Control =>
|
||||
Assert.IsType<T>(form.Controls.Find(name, true).Single());
|
||||
|
||||
private static IReadOnlyList<LayoutPart> PreviewParts(PlateView view) =>
|
||||
(IReadOnlyList<LayoutPart>)typeof(PlateView)
|
||||
.GetProperty("PreviewParts", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(view)!;
|
||||
|
||||
private static void Invoke(AutomaticCutOffForm form, string method, params object?[] arguments) =>
|
||||
typeof(AutomaticCutOffForm).GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.Invoke(form, arguments);
|
||||
|
||||
private static void RunSta(System.Action action)
|
||||
{
|
||||
Exception? failure = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ex;
|
||||
}
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
if (failure != null)
|
||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
[CollectionDefinition("Fill operation lifetime", DisableParallelization = true)]
|
||||
public class FillOperationLifetimeCollection;
|
||||
|
||||
[Collection("Fill operation lifetime")]
|
||||
public class PlateViewFillLifetimeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ClosedProgressWindowStaysBusyThroughCommitAndCleanup(bool accept)
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var run = new FillRun();
|
||||
run.Start();
|
||||
var progressForm = Assert.Single(Application.OpenForms.OfType<NestProgressForm>());
|
||||
if (accept)
|
||||
Invoke(progressForm, "AcceptButton_Click", null, EventArgs.Empty);
|
||||
progressForm.Close();
|
||||
|
||||
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
|
||||
Assert.True(run.View.IsFillInProgress);
|
||||
Assert.True(run.Context.Token.IsCancellationRequested);
|
||||
|
||||
// Closing the modeless window must neither release the guard nor allow another fill.
|
||||
run.View.FillWithProgress(run.Group, run.WorkArea);
|
||||
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
|
||||
Assert.Equal(1, run.Calls);
|
||||
|
||||
using (var dialog = new AutomaticCutOffForm(run.View, Units.Inches))
|
||||
{
|
||||
Invoke(dialog, "PreviewButton_Click", null, EventArgs.Empty);
|
||||
Assert.Empty(PreviewParts(run.View));
|
||||
Assert.False(dialog.Controls.Find("applyButton", true).Single().Enabled);
|
||||
Invoke(dialog, "ApplyButton_Click", null, EventArgs.Empty);
|
||||
Assert.NotEqual(DialogResult.OK, dialog.DialogResult);
|
||||
Assert.Empty(run.View.Plate.CutOffs);
|
||||
}
|
||||
|
||||
var busyDuringCommit = false;
|
||||
run.View.Plate.PartAdded += (_, _) => busyDuringCommit = run.View.IsFillInProgress;
|
||||
run.Complete();
|
||||
|
||||
Assert.Equal(accept, busyDuringCommit);
|
||||
Assert.Equal(accept ? 2 : 1, run.View.Plate.Parts.Count);
|
||||
Assert.False(run.View.IsFillInProgress);
|
||||
Assert.Null(run.View.ActiveWorkArea);
|
||||
Assert.Empty(PreviewParts(run.View));
|
||||
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void QueuedProgressCannotOverwriteNewPreviewEvenDuringAnotherFill(bool startAnotherFill)
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var run = new FillRun();
|
||||
run.Start();
|
||||
run.Complete();
|
||||
var lateCallbacks = run.Dispatcher.TakeProgress();
|
||||
Assert.NotEmpty(lateCallbacks);
|
||||
|
||||
using var dialog = new AutomaticCutOffForm(run.View, Units.Inches);
|
||||
Invoke(dialog, "PreviewButton_Click", null, EventArgs.Empty);
|
||||
var preview = PreviewParts(run.View).ToArray();
|
||||
Assert.NotEmpty(preview);
|
||||
|
||||
if (startAnotherFill)
|
||||
run.Start();
|
||||
|
||||
foreach (var callback in lateCallbacks)
|
||||
callback();
|
||||
|
||||
Assert.Equal(preview, PreviewParts(run.View).ToArray());
|
||||
Assert.Null(run.View.ActiveWorkArea);
|
||||
Assert.Equal(startAnotherFill, run.View.IsFillInProgress);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetupFailureReleasesGuardAndClosesProgressWindow()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var run = new FillRun();
|
||||
var plateField = typeof(PlateView).GetField("plate", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
var plate = run.View.Plate;
|
||||
// Inject an unavailable input without invoking SetPlate's non-null contract.
|
||||
plateField.SetValue(run.View, null);
|
||||
try
|
||||
{
|
||||
run.View.FillWithProgress(run.Group, run.WorkArea);
|
||||
Assert.False(run.View.IsFillInProgress);
|
||||
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
plateField.SetValue(run.View, plate);
|
||||
}
|
||||
|
||||
run.Start();
|
||||
run.Complete();
|
||||
Assert.False(run.View.IsFillInProgress);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkerFailureReleasesGuardAndDiscardsPreview()
|
||||
{
|
||||
RunSta(() =>
|
||||
{
|
||||
using var run = new FillRun { Fail = true };
|
||||
run.Start();
|
||||
run.Complete();
|
||||
Assert.False(run.View.IsFillInProgress);
|
||||
Assert.Empty(PreviewParts(run.View));
|
||||
Assert.Single(run.View.Plate.Parts);
|
||||
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
|
||||
});
|
||||
}
|
||||
|
||||
// Use the real strategy registry rather than a production-only injection hook.
|
||||
public sealed class BlockingFillStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "WinForms lifetime regression";
|
||||
public NestPhase Phase => NestPhase.Linear;
|
||||
public int Order => 0;
|
||||
public Func<FillContext, List<Part>>? Run { get; set; }
|
||||
public List<Part> Fill(FillContext context) => Run!(context);
|
||||
}
|
||||
|
||||
private sealed class FillRun : IDisposable
|
||||
{
|
||||
private readonly ManualResetEventSlim entered = new();
|
||||
private readonly ManualResetEventSlim release = new();
|
||||
private readonly BlockingFillStrategy strategy;
|
||||
private readonly string[] priorStrategies;
|
||||
private readonly string priorEngine;
|
||||
private readonly SynchronizationContext? priorContext;
|
||||
|
||||
public PlateView View { get; }
|
||||
public List<Part> Group { get; }
|
||||
public Box WorkArea { get; }
|
||||
public ControlledDispatcher Dispatcher { get; } = new();
|
||||
public FillContext Context { get; private set; } = null!;
|
||||
public bool Fail { get; init; }
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public FillRun()
|
||||
{
|
||||
priorStrategies = FillStrategyRegistry.Strategies.Select(s => s.Name).ToArray();
|
||||
priorEngine = EngineSelection.EngineName;
|
||||
FillStrategyRegistry.LoadFrom(typeof(BlockingFillStrategy).Assembly);
|
||||
strategy = Assert.Single(FillStrategyRegistry.AllStrategies.OfType<BlockingFillStrategy>());
|
||||
FillStrategyRegistry.Disable(strategy.Name);
|
||||
FillStrategyRegistry.SetEnabled(strategy.Name);
|
||||
EngineSelection.EngineName = EngineSelection.DefaultEngineName;
|
||||
strategy.Run = Fill;
|
||||
|
||||
var program = new CNC.Program();
|
||||
program.Codes.Add(new RapidMove(0, 0));
|
||||
program.Codes.Add(new LinearMove(10, 0));
|
||||
program.Codes.Add(new LinearMove(10, 5));
|
||||
program.Codes.Add(new LinearMove(0, 5));
|
||||
program.Codes.Add(new LinearMove(0, 0));
|
||||
var drawing = new Drawing("fill lifetime rectangle", program);
|
||||
var plate = new Plate(36, 120) { PartSpacing = 0.5 };
|
||||
plate.Parts.Add(new Part(drawing, new Vector(70, 10)));
|
||||
View = new PlateView { Plate = plate };
|
||||
Group = [new Part(drawing)];
|
||||
WorkArea = new Box(0, 0, 60, 36);
|
||||
priorContext = SynchronizationContext.Current;
|
||||
SynchronizationContext.SetSynchronizationContext(Dispatcher);
|
||||
}
|
||||
|
||||
private List<Part> Fill(FillContext context)
|
||||
{
|
||||
Calls++;
|
||||
Context = context;
|
||||
var parts = new List<Part> { new(context.Item.Drawing, new Vector(1, 1)) };
|
||||
context.ReportProgress(parts, "Blocked until the test releases the worker");
|
||||
entered.Set();
|
||||
if (!release.Wait(TimeSpan.FromSeconds(15)))
|
||||
throw new TimeoutException("The test did not release the fill worker.");
|
||||
if (Fail)
|
||||
throw new InvalidOperationException("Injected worker failure");
|
||||
return parts;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
entered.Reset();
|
||||
release.Reset();
|
||||
View.FillWithProgress(Group, WorkArea);
|
||||
Assert.True(entered.Wait(TimeSpan.FromSeconds(15)), "The fill strategy was not entered.");
|
||||
Assert.True(View.IsFillInProgress);
|
||||
}
|
||||
|
||||
public void Complete()
|
||||
{
|
||||
release.Set();
|
||||
Dispatcher.CompleteOperations();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Complete();
|
||||
}
|
||||
finally
|
||||
{
|
||||
strategy.Run = null;
|
||||
FillStrategyRegistry.SetEnabled(priorStrategies);
|
||||
EngineSelection.EngineName = priorEngine;
|
||||
SynchronizationContext.SetSynchronizationContext(priorContext);
|
||||
View.Dispose();
|
||||
entered.Dispose();
|
||||
release.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hold Progress<T> deliveries but execute await continuations on the STA. No sleeps,
|
||||
// message-loop polling, or dependence on which thread wins the completion/progress race.
|
||||
private sealed class ControlledDispatcher : SynchronizationContext
|
||||
{
|
||||
private readonly BlockingCollection<System.Action> continuations = new();
|
||||
private readonly ConcurrentQueue<System.Action> progress = new();
|
||||
private int operations;
|
||||
|
||||
public override void OperationStarted() => operations++;
|
||||
public override void OperationCompleted() => operations--;
|
||||
|
||||
public override void Post(SendOrPostCallback callback, object? state)
|
||||
{
|
||||
if (state is NestProgress)
|
||||
progress.Enqueue(() => callback(state));
|
||||
else
|
||||
continuations.Add(() => callback(state));
|
||||
}
|
||||
|
||||
public System.Action[] TakeProgress()
|
||||
{
|
||||
var callbacks = new List<System.Action>();
|
||||
while (progress.TryDequeue(out var callback))
|
||||
callbacks.Add(callback);
|
||||
return callbacks.ToArray();
|
||||
}
|
||||
|
||||
public void CompleteOperations()
|
||||
{
|
||||
while (operations > 0)
|
||||
{
|
||||
Assert.True(continuations.TryTake(out var callback, TimeSpan.FromSeconds(15)),
|
||||
"The fill did not complete.");
|
||||
callback!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<LayoutPart> PreviewParts(PlateView view) =>
|
||||
(IReadOnlyList<LayoutPart>)typeof(PlateView)
|
||||
.GetProperty("PreviewParts", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(view)!;
|
||||
|
||||
private static void Invoke(Form form, string method, params object?[] arguments) =>
|
||||
form.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.Invoke(form, arguments);
|
||||
|
||||
private static void RunSta(System.Action action)
|
||||
{
|
||||
Exception? failure = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ex;
|
||||
}
|
||||
})
|
||||
{ IsBackground = true };
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
|
||||
if (failure != null)
|
||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||
}
|
||||
}
|
||||
@@ -569,59 +569,80 @@ namespace OpenNest.Controls
|
||||
public void AcceptPreviewParts(List<Part> parts) =>
|
||||
previewManager.AcceptPreviewParts(parts);
|
||||
|
||||
// UI-thread state: closing the progress window only requests cancellation.
|
||||
public bool IsFillInProgress { get; private set; }
|
||||
|
||||
public async void FillWithProgress(List<Part> groupParts, Box workArea)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var cts = new CancellationTokenSource();
|
||||
var progressForm = new NestProgressForm(cts, showPlateRow: false);
|
||||
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (p.IsOverallBest)
|
||||
SetActiveParts(p.BestParts);
|
||||
|
||||
ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
|
||||
progressForm.Show(FindForm());
|
||||
if (IsFillInProgress)
|
||||
return;
|
||||
|
||||
IsFillInProgress = true;
|
||||
var operationActive = true;
|
||||
try
|
||||
{
|
||||
var strategy = EngineSelection.FillStrategy;
|
||||
var spacing = Plate.PartSpacing;
|
||||
var parts = await Task.Run(() =>
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var progressForm = new NestProgressForm(cts, showPlateRow: false);
|
||||
try
|
||||
{
|
||||
var result = PlateFillService.FillGroup(
|
||||
strategy,
|
||||
Plate,
|
||||
groupParts,
|
||||
workArea,
|
||||
progress,
|
||||
cts.Token
|
||||
);
|
||||
Compactor.Settle(result, workArea, spacing);
|
||||
return result;
|
||||
});
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
// This callback can outlive its operation, even into the next fill.
|
||||
if (!operationActive || IsDisposed || progressForm.IsDisposed)
|
||||
return;
|
||||
|
||||
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
|
||||
{
|
||||
SetActiveParts(parts);
|
||||
AcceptPreviewParts(parts);
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (Plate.CutOffs.Count > 0)
|
||||
Plate.RegenerateCutOffs(cutOffSettings);
|
||||
if (p.IsOverallBest)
|
||||
SetActiveParts(p.BestParts);
|
||||
|
||||
sw.Stop();
|
||||
Status = $"Fill: {parts.Count} parts in {sw.ElapsedMilliseconds} ms";
|
||||
ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
|
||||
progressForm.Show(FindForm());
|
||||
var strategy = EngineSelection.FillStrategy;
|
||||
var spacing = Plate.PartSpacing;
|
||||
var parts = await Task.Run(() =>
|
||||
{
|
||||
var result = PlateFillService.FillGroup(
|
||||
strategy,
|
||||
Plate,
|
||||
groupParts,
|
||||
workArea,
|
||||
progress,
|
||||
cts.Token
|
||||
);
|
||||
Compactor.Settle(result, workArea, spacing);
|
||||
return result;
|
||||
});
|
||||
operationActive = false;
|
||||
|
||||
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
|
||||
{
|
||||
SetActiveParts(parts);
|
||||
AcceptPreviewParts(parts);
|
||||
|
||||
if (Plate.CutOffs.Count > 0)
|
||||
Plate.RegenerateCutOffs(cutOffSettings);
|
||||
|
||||
sw.Stop();
|
||||
Status = $"Fill: {parts.Count} parts in {sw.ElapsedMilliseconds} ms";
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearPreviewParts();
|
||||
}
|
||||
|
||||
progressForm.ShowCompleted();
|
||||
}
|
||||
else
|
||||
finally
|
||||
{
|
||||
ClearPreviewParts();
|
||||
operationActive = false;
|
||||
ActiveWorkArea = null;
|
||||
progressForm.Close();
|
||||
Focus();
|
||||
}
|
||||
|
||||
progressForm.ShowCompleted();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -629,10 +650,8 @@ namespace OpenNest.Controls
|
||||
}
|
||||
finally
|
||||
{
|
||||
ActiveWorkArea = null;
|
||||
progressForm.Close();
|
||||
cts.Dispose();
|
||||
Focus();
|
||||
operationActive = false;
|
||||
IsFillInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
partial class AutomaticCutOffForm
|
||||
{
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
ClearPreview();
|
||||
components?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
layout = new TableLayoutPanel();
|
||||
sheetLabel = new Label();
|
||||
spacingPanel = new TableLayoutPanel();
|
||||
spacingLabel = new Label();
|
||||
spacingBox = new TextBox();
|
||||
warningLabel = new Label();
|
||||
occupiedLabel = new Label();
|
||||
usedLabel = new Label();
|
||||
separatorLabel = new Label();
|
||||
tailLabel = new Label();
|
||||
diagnosticsLabel = new Label();
|
||||
diagnosticsBox = new TextBox();
|
||||
buttonsPanel = new FlowLayoutPanel();
|
||||
previewButton = new Button();
|
||||
applyButton = new Button();
|
||||
cancelButton = new Button();
|
||||
layout.SuspendLayout();
|
||||
spacingPanel.SuspendLayout();
|
||||
buttonsPanel.SuspendLayout();
|
||||
SuspendLayout();
|
||||
//
|
||||
// layout
|
||||
//
|
||||
layout.ColumnCount = 1;
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||
layout.Controls.Add(sheetLabel, 0, 0);
|
||||
layout.Controls.Add(spacingPanel, 0, 1);
|
||||
layout.Controls.Add(warningLabel, 0, 2);
|
||||
layout.Controls.Add(occupiedLabel, 0, 3);
|
||||
layout.Controls.Add(usedLabel, 0, 4);
|
||||
layout.Controls.Add(separatorLabel, 0, 5);
|
||||
layout.Controls.Add(tailLabel, 0, 6);
|
||||
layout.Controls.Add(diagnosticsLabel, 0, 7);
|
||||
layout.Controls.Add(diagnosticsBox, 0, 8);
|
||||
layout.Controls.Add(buttonsPanel, 0, 9);
|
||||
layout.Dock = DockStyle.Fill;
|
||||
layout.Name = "layout";
|
||||
layout.RowCount = 10;
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.TabIndex = 0;
|
||||
//
|
||||
// sheetLabel
|
||||
//
|
||||
sheetLabel.AutoSize = true;
|
||||
sheetLabel.Dock = DockStyle.Fill;
|
||||
sheetLabel.Margin = new Padding(3, 3, 3, 8);
|
||||
sheetLabel.Name = "sheetLabel";
|
||||
sheetLabel.TabIndex = 0;
|
||||
sheetLabel.Text = "Sheet:";
|
||||
//
|
||||
// spacingPanel
|
||||
//
|
||||
spacingPanel.AutoSize = true;
|
||||
spacingPanel.ColumnCount = 2;
|
||||
spacingPanel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
spacingPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||
spacingPanel.Controls.Add(spacingLabel, 0, 0);
|
||||
spacingPanel.Controls.Add(spacingBox, 1, 0);
|
||||
spacingPanel.Dock = DockStyle.Fill;
|
||||
spacingPanel.Margin = new Padding(0, 0, 0, 6);
|
||||
spacingPanel.Name = "spacingPanel";
|
||||
spacingPanel.RowCount = 1;
|
||||
spacingPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
spacingPanel.TabIndex = 1;
|
||||
//
|
||||
// spacingLabel
|
||||
//
|
||||
spacingLabel.Anchor = AnchorStyles.Left;
|
||||
spacingLabel.AutoSize = true;
|
||||
spacingLabel.Name = "spacingLabel";
|
||||
spacingLabel.TabIndex = 0;
|
||||
spacingLabel.Text = "Nominal spacing along sheet length:";
|
||||
//
|
||||
// spacingBox
|
||||
//
|
||||
spacingBox.Dock = DockStyle.Fill;
|
||||
spacingBox.Name = "spacingBox";
|
||||
spacingBox.TabIndex = 1;
|
||||
spacingBox.TextChanged += SpacingBox_TextChanged;
|
||||
//
|
||||
// warningLabel
|
||||
//
|
||||
warningLabel.AutoSize = true;
|
||||
warningLabel.Dock = DockStyle.Fill;
|
||||
warningLabel.Margin = new Padding(3, 3, 3, 10);
|
||||
warningLabel.Name = "warningLabel";
|
||||
warningLabel.TabIndex = 2;
|
||||
warningLabel.Text = "Nominal spacing is not cut-line length. Clearances and omitted segments can leave bridges. "
|
||||
+ "This does not certify disconnected scrap or hopper fit. Tail dimensions describe a proposed program, not a physical separation.";
|
||||
//
|
||||
// occupiedLabel
|
||||
//
|
||||
occupiedLabel.AutoSize = true;
|
||||
occupiedLabel.Dock = DockStyle.Fill;
|
||||
occupiedLabel.Margin = new Padding(3, 3, 3, 5);
|
||||
occupiedLabel.Name = "occupiedLabel";
|
||||
occupiedLabel.TabIndex = 3;
|
||||
occupiedLabel.Text = "Occupied span from origin: —";
|
||||
//
|
||||
// usedLabel
|
||||
//
|
||||
usedLabel.AutoSize = true;
|
||||
usedLabel.Dock = DockStyle.Fill;
|
||||
usedLabel.Margin = new Padding(3, 3, 3, 5);
|
||||
usedLabel.Name = "usedLabel";
|
||||
usedLabel.TabIndex = 4;
|
||||
usedLabel.Text = "Proposed used span from origin: —";
|
||||
//
|
||||
// separatorLabel
|
||||
//
|
||||
separatorLabel.AutoSize = true;
|
||||
separatorLabel.Dock = DockStyle.Fill;
|
||||
separatorLabel.Margin = new Padding(3, 3, 3, 5);
|
||||
separatorLabel.Name = "separatorLabel";
|
||||
separatorLabel.TabIndex = 5;
|
||||
separatorLabel.Text = "Verified separator program: —";
|
||||
//
|
||||
// tailLabel
|
||||
//
|
||||
tailLabel.AutoSize = true;
|
||||
tailLabel.Dock = DockStyle.Fill;
|
||||
tailLabel.Margin = new Padding(3, 3, 3, 8);
|
||||
tailLabel.Name = "tailLabel";
|
||||
tailLabel.TabIndex = 6;
|
||||
tailLabel.Text = "Proposed retained tail: —";
|
||||
//
|
||||
// diagnosticsLabel
|
||||
//
|
||||
diagnosticsLabel.AutoSize = true;
|
||||
diagnosticsLabel.Dock = DockStyle.Fill;
|
||||
diagnosticsLabel.Name = "diagnosticsLabel";
|
||||
diagnosticsLabel.TabIndex = 7;
|
||||
diagnosticsLabel.Text = "Diagnostics / plan status:";
|
||||
//
|
||||
// diagnosticsBox
|
||||
//
|
||||
diagnosticsBox.Dock = DockStyle.Fill;
|
||||
diagnosticsBox.Multiline = true;
|
||||
diagnosticsBox.Name = "diagnosticsBox";
|
||||
diagnosticsBox.ReadOnly = true;
|
||||
diagnosticsBox.ScrollBars = ScrollBars.Vertical;
|
||||
diagnosticsBox.TabIndex = 8;
|
||||
//
|
||||
// buttonsPanel
|
||||
//
|
||||
buttonsPanel.AutoSize = true;
|
||||
buttonsPanel.Controls.Add(cancelButton);
|
||||
buttonsPanel.Controls.Add(applyButton);
|
||||
buttonsPanel.Controls.Add(previewButton);
|
||||
buttonsPanel.Dock = DockStyle.Fill;
|
||||
buttonsPanel.FlowDirection = FlowDirection.RightToLeft;
|
||||
buttonsPanel.Margin = new Padding(0, 8, 0, 0);
|
||||
buttonsPanel.Name = "buttonsPanel";
|
||||
buttonsPanel.TabIndex = 9;
|
||||
buttonsPanel.WrapContents = false;
|
||||
//
|
||||
// previewButton
|
||||
//
|
||||
previewButton.AutoSize = true;
|
||||
previewButton.Name = "previewButton";
|
||||
previewButton.Size = new Size(85, 30);
|
||||
previewButton.TabIndex = 0;
|
||||
previewButton.Text = "&Preview";
|
||||
previewButton.UseVisualStyleBackColor = true;
|
||||
previewButton.Click += PreviewButton_Click;
|
||||
//
|
||||
// applyButton
|
||||
//
|
||||
applyButton.AutoSize = true;
|
||||
applyButton.Enabled = false;
|
||||
applyButton.Name = "applyButton";
|
||||
applyButton.Size = new Size(85, 30);
|
||||
applyButton.TabIndex = 1;
|
||||
applyButton.Text = "&Apply";
|
||||
applyButton.UseVisualStyleBackColor = true;
|
||||
applyButton.Click += ApplyButton_Click;
|
||||
//
|
||||
// cancelButton
|
||||
//
|
||||
cancelButton.AutoSize = true;
|
||||
cancelButton.DialogResult = DialogResult.Cancel;
|
||||
cancelButton.Name = "cancelButton";
|
||||
cancelButton.Size = new Size(85, 30);
|
||||
cancelButton.TabIndex = 2;
|
||||
cancelButton.Text = "Cancel";
|
||||
cancelButton.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// AutomaticCutOffForm
|
||||
//
|
||||
AcceptButton = previewButton;
|
||||
AutoScaleDimensions = new SizeF(7F, 15F);
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
CancelButton = cancelButton;
|
||||
ClientSize = new Size(600, 470);
|
||||
Controls.Add(layout);
|
||||
Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
Name = "AutomaticCutOffForm";
|
||||
Padding = new Padding(12);
|
||||
ShowIcon = false;
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
Text = "Automatic Scrap Cutoffs";
|
||||
layout.ResumeLayout(false);
|
||||
layout.PerformLayout();
|
||||
spacingPanel.ResumeLayout(false);
|
||||
spacingPanel.PerformLayout();
|
||||
buttonsPanel.ResumeLayout(false);
|
||||
buttonsPanel.PerformLayout();
|
||||
ResumeLayout(false);
|
||||
}
|
||||
|
||||
private TableLayoutPanel layout;
|
||||
private Label sheetLabel;
|
||||
private TableLayoutPanel spacingPanel;
|
||||
private Label spacingLabel;
|
||||
private TextBox spacingBox;
|
||||
private Label warningLabel;
|
||||
private Label occupiedLabel;
|
||||
private Label usedLabel;
|
||||
private Label separatorLabel;
|
||||
private Label tailLabel;
|
||||
private Label diagnosticsLabel;
|
||||
private TextBox diagnosticsBox;
|
||||
private FlowLayoutPanel buttonsPanel;
|
||||
private Button previewButton;
|
||||
private Button applyButton;
|
||||
private Button cancelButton;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Controls;
|
||||
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
public partial class AutomaticCutOffForm : Form
|
||||
{
|
||||
private readonly PlateView plateView;
|
||||
private readonly Plate plate;
|
||||
private readonly string unit;
|
||||
|
||||
public AutomaticCutOffForm(PlateView plateView, Units units)
|
||||
{
|
||||
this.plateView = plateView ?? throw new ArgumentNullException(nameof(plateView));
|
||||
plate = plateView.Plate ?? throw new ArgumentException("An active plate is required.", nameof(plateView));
|
||||
unit = UnitsHelper.GetShortString(units);
|
||||
InitializeComponent();
|
||||
spacingLabel.Text = $"Nominal spacing along sheet length ({unit}):";
|
||||
spacingBox.Text = units == Units.Millimeters ? "889" : "35";
|
||||
sheetLabel.Text = $"Sheet: {Measure(plate.Size.Length)} length × {Measure(plate.Size.Width)} width";
|
||||
}
|
||||
|
||||
protected override void OnShown(EventArgs e)
|
||||
{
|
||||
base.OnShown(e);
|
||||
ShowPreview();
|
||||
}
|
||||
|
||||
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||
{
|
||||
ClearPreview();
|
||||
base.OnFormClosed(e);
|
||||
}
|
||||
|
||||
private void ClearPreview()
|
||||
{
|
||||
if (plateView != null && !plateView.IsDisposed)
|
||||
plateView.ClearPreviewParts();
|
||||
}
|
||||
|
||||
private string Measure(double value) => $"{value:0.####} {unit}";
|
||||
|
||||
private string SignedPosition(double value) => $"{value:+0.####;-0.####;0} {unit}";
|
||||
|
||||
private AutomaticCutOffPlan CreatePlan(CutOffSettings settings)
|
||||
{
|
||||
if (plateView.IsFillInProgress)
|
||||
throw new InvalidOperationException("Wait for the current fill 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.");
|
||||
|
||||
if (!double.TryParse(spacingBox.Text, NumberStyles.Float, CultureInfo.CurrentCulture, out var spacing))
|
||||
throw new ArgumentException($"Enter a numeric spacing in {unit}.");
|
||||
|
||||
return AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = spacing }, settings);
|
||||
}
|
||||
|
||||
private void DisplayPlan(AutomaticCutOffPlan plan)
|
||||
{
|
||||
occupiedLabel.Text = $"Occupied span from origin: {Measure(plan.OccupiedSpan)}";
|
||||
usedLabel.Text = $"Proposed used span from origin: {Measure(plan.UsedSpan)}";
|
||||
separatorLabel.Text = plan.HasSeparatedTail
|
||||
? $"Verified separator program at signed X: {SignedPosition(plan.TailSeparatorX.Value)}"
|
||||
: "Verified separator program: none";
|
||||
tailLabel.Text = plan.HasSeparatedTail
|
||||
? $"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;
|
||||
}
|
||||
|
||||
private void ResetSummary()
|
||||
{
|
||||
occupiedLabel.Text = "Occupied span from origin: —";
|
||||
usedLabel.Text = "Proposed used span from origin: —";
|
||||
separatorLabel.Text = "Verified separator program: —";
|
||||
tailLabel.Text = "Proposed retained tail: —";
|
||||
}
|
||||
|
||||
private void ShowError(Exception error)
|
||||
{
|
||||
ClearPreview();
|
||||
ResetSummary();
|
||||
applyButton.Enabled = false;
|
||||
diagnosticsBox.Text = $"Unable to complete the cut-off operation: {error.Message}";
|
||||
}
|
||||
|
||||
private void SpacingBox_TextChanged(object sender, EventArgs e)
|
||||
{
|
||||
ClearPreview();
|
||||
ResetSummary();
|
||||
diagnosticsBox.Text = "Spacing changed. Preview or Apply will calculate a fresh plan.";
|
||||
applyButton.Enabled = true;
|
||||
}
|
||||
|
||||
private void PreviewButton_Click(object sender, EventArgs e) => ShowPreview();
|
||||
|
||||
private void ShowPreview()
|
||||
{
|
||||
ClearPreview();
|
||||
try
|
||||
{
|
||||
var plan = CreatePlan(plateView.CutOffSettings);
|
||||
DisplayPlan(plan);
|
||||
if (!plan.HasBlockingDiagnostics)
|
||||
plateView.SetActiveParts(plan.PreviewParts.ToList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyButton_Click(object sender, EventArgs e)
|
||||
{
|
||||
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)
|
||||
return;
|
||||
|
||||
ApplyDefinitions(plan, settings);
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10
-1
@@ -127,6 +127,7 @@
|
||||
calculateCutTimeToolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem();
|
||||
centerPartsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuPlateCutOff = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuPlateAutomaticCutOff = new System.Windows.Forms.ToolStripMenuItem();
|
||||
toolStripMenuItem21 = new System.Windows.Forms.ToolStripSeparator();
|
||||
mnuPlateAssignLeadIns = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuPlatePlaceLeadIn = new System.Windows.Forms.ToolStripMenuItem();
|
||||
@@ -720,7 +721,7 @@
|
||||
//
|
||||
// mnuPlate
|
||||
//
|
||||
mnuPlate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuPlateEdit, mnuPlateSetAsDefault, toolStripMenuItem18, mnuPlateAdd, mnuPlateRemove, toolStripMenuItem16, mnuPlateCutOff, mnuPlateRotate, mnuResizeToFitParts, toolStripMenuItem13, mnuPlateViewInCad, toolStripMenuItem20, mnuSequenceParts, calculateCutTimeToolStripMenuItem1, centerPartsToolStripMenuItem, toolStripMenuItem21, mnuPlateAssignLeadIns, mnuPlatePlaceLeadIn, mnuPlateRemoveLeadIns });
|
||||
mnuPlate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuPlateEdit, mnuPlateSetAsDefault, toolStripMenuItem18, mnuPlateAdd, mnuPlateRemove, toolStripMenuItem16, mnuPlateCutOff, mnuPlateAutomaticCutOff, mnuPlateRotate, mnuResizeToFitParts, toolStripMenuItem13, mnuPlateViewInCad, toolStripMenuItem20, mnuSequenceParts, calculateCutTimeToolStripMenuItem1, centerPartsToolStripMenuItem, toolStripMenuItem21, mnuPlateAssignLeadIns, mnuPlatePlaceLeadIn, mnuPlateRemoveLeadIns });
|
||||
mnuPlate.Name = "mnuPlate";
|
||||
mnuPlate.Size = new System.Drawing.Size(45, 20);
|
||||
mnuPlate.Text = "&Plate";
|
||||
@@ -772,6 +773,13 @@
|
||||
mnuPlateCutOff.Text = "Sheet Cut-Off";
|
||||
mnuPlateCutOff.Click += CutOff_Click;
|
||||
//
|
||||
// mnuPlateAutomaticCutOff
|
||||
//
|
||||
mnuPlateAutomaticCutOff.Name = "mnuPlateAutomaticCutOff";
|
||||
mnuPlateAutomaticCutOff.Size = new System.Drawing.Size(215, 22);
|
||||
mnuPlateAutomaticCutOff.Text = "Automatic Scrap Cutoffs...";
|
||||
mnuPlateAutomaticCutOff.Click += AutomaticCutOff_Click;
|
||||
//
|
||||
// mnuPlateRotate
|
||||
//
|
||||
mnuPlateRotate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuPlateRotateCw, mnuPlateRotateCcw, toolStripSeparator2, mnuPlateRotate180 });
|
||||
@@ -1345,6 +1353,7 @@
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator5;
|
||||
private System.Windows.Forms.ToolStripButton btnCutOff;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuPlateCutOff;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuPlateAutomaticCutOff;
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem21;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuPlateAssignLeadIns;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuPlatePlaceLeadIn;
|
||||
|
||||
@@ -1638,6 +1638,40 @@ namespace OpenNest.Forms
|
||||
activeForm.PlateView.SetAction(typeof(ActionCutOff));
|
||||
}
|
||||
|
||||
private void AutomaticCutOff_Click(object sender, EventArgs e)
|
||||
{
|
||||
var view = activeForm?.PlateView;
|
||||
if (view?.Plate == null)
|
||||
return;
|
||||
|
||||
// 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)
|
||||
{
|
||||
MessageBox.Show(this, "Finish or cancel the current nesting or plate action first.",
|
||||
"Automatic Scrap Cutoffs", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
view.SetAction(typeof(ActionSelect));
|
||||
using var form = new AutomaticCutOffForm(view, activeForm.Nest.Units);
|
||||
form.ShowDialog(this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, ex.Message, "Automatic Scrap Cutoffs",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!view.IsDisposed)
|
||||
view.ClearPreviewParts();
|
||||
}
|
||||
}
|
||||
|
||||
private void PlateAssignLeadIns_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (activeForm == null)
|
||||
|
||||
@@ -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** — sheet cut-offs, 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, [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.
|
||||
- **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).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Automatic scrap cutoffs
|
||||
|
||||
Finish nesting the active plate, then choose **Plate > Automatic Scrap Cutoffs**. 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.
|
||||
|
||||
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. If converted material extends outside the part's cached bounds (for example, an inconsistent arc center that is corrected during conversion), planning refuses it rather than risking a cut through material; repair the geometry before retrying.
|
||||
|
||||
## Preserving the unused tail
|
||||
|
||||
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.
|
||||
|
||||
If no safe separator fits inside the sheet, there is no retained-tail claim and the nominal cuts may cover the full sheet length. A proposed tail is not reported as separated unless its generated program spans the full sheet width. Obstructed or minimum-length-filtered lines produce diagnostics; they are not proof of a completed partition.
|
||||
|
||||
For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal skeleton lines are at 35 and 70 inches, followed by a separator beyond 80 inches at the required margin. There is no line at 105 inches through the retained tail.
|
||||
|
||||
## Limits and operator review
|
||||
|
||||
- Spacing is nominal. Clearance gaps and suppressed short segments can leave bridges between scrap regions. This command does **not** certify that every connected scrap piece is disconnected or fits a hopper.
|
||||
- The active sheet's physical width is used, not a hard-coded 81 inches. Check the actual width against the hopper; a wider sheet is not automatically hopper-compatible.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
Saving `.nest` retains ordinary cutoff positions, axes, limits, and sequence. Loading regenerates cutoff programs using the reader's default cutoff settings; exact toolpaths made with nondefault cutoff settings are not guaranteed to round-trip. Review regenerated paths before posting.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user