From d3cb23f1179822c43afaee1e09b2eb85b4677c4f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 18 Oct 2026 17:32:38 -0400 Subject: [PATCH] fix: time display-layer cuts, add hole feed and etch skipping The cut simulation treated Display-layer contour moves (what cutting strategies emit) as head-up travel, so plates with assigned lead-ins were timed as rapids. They now cut, matching the verifier and posts, and suppressed moves are left out. Cut time now follows the CI Fiber post: closed clockwise contours and cut-offs take an outside feed, other contours a hole feed, and etch-only contours can be skipped like its SkipScribe default. The settings are shared by Calculate Cut Time and the simulator, and plate/nest times now always come from the simulation. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/CNC/ContourTiming.cs | 193 +++++++++++++++++++++++ OpenNest.Core/CNC/CutSimulation.cs | 82 +++++----- OpenNest.Core/CNC/MotionPlanner.cs | 36 ++--- OpenNest.Core/CutParameters.cs | 6 + OpenNest.Core/Timing.cs | 8 +- OpenNest.Tests/CNC/CutSimulationTests.cs | 84 ++++++++++ OpenNest/Forms/CutParametersForm.cs | 58 ++++--- OpenNest/Forms/CutSimulatorForm.cs | 44 ++++-- OpenNest/Forms/CutTimeMachineBox.cs | 22 ++- OpenNest/Forms/SavedCutParameters.cs | 49 ++++++ OpenNest/Properties/Settings.Designer.cs | 24 +++ OpenNest/Properties/Settings.settings | 6 + OpenNest/app.config | 6 + README.md | 2 +- docs/cut-time-estimates.md | 29 ++-- 15 files changed, 533 insertions(+), 116 deletions(-) create mode 100644 OpenNest.Core/CNC/ContourTiming.cs create mode 100644 OpenNest/Forms/SavedCutParameters.cs diff --git a/OpenNest.Core/CNC/ContourTiming.cs b/OpenNest.Core/CNC/ContourTiming.cs new file mode 100644 index 00000000..1c24ab6a --- /dev/null +++ b/OpenNest.Core/CNC/ContourTiming.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.CNC +{ + /// + /// Turns a plate's recorded moves into a timed timeline: numbers the contours, drops + /// etch-only contours the post would skip, sorts contours into holes and outsides, and + /// times each move at its feed, with acceleration when a motion profile is set. + /// + internal static class ContourTiming + { + private const double ClosedTolerance = 1e-4; + + public static List Finish(List steps, Plate plate, CutParameters parameters) + { + NumberContours(steps); + + if (parameters.SkipScribe && RemoveScribeContours(ref steps)) + NumberContours(steps); + + ClassifyHoles(steps, plate); + + var holeFeed = parameters.HoleFeedrate is > 0 ? parameters.HoleFeedrate.Value : parameters.Feedrate; + foreach (var step in steps) + { + step.NominalSpeed = step.Kind switch + { + CutSimulationStepKind.Rapid => Speed(parameters.RapidTravelRate, parameters.Units), + CutSimulationStepKind.Cut when step.IsHole => Speed(holeFeed, parameters.Units), + CutSimulationStepKind.Pierce => 0, + _ => Speed(parameters.Feedrate, parameters.Units), + }; + step.Duration = step.Kind == CutSimulationStepKind.Pierce + ? parameters.PierceTime + : step.NominalSpeed > 0 && step.Length > 0 + ? TimeSpan.FromSeconds(step.Length / step.NominalSpeed) + : TimeSpan.Zero; + } + + if (parameters.Motion != null) + MotionPlanner.Plan(steps, parameters.Motion); + + var clock = TimeSpan.Zero; + foreach (var step in steps) + { + step.StartTime = clock; + clock += step.Duration; + } + return steps; + } + + // Inch rates are per minute, millimeter rates per second, as Timing.CalculateTime reads them. + private static double Speed(double rate, Units units) => + rate <= 0 ? 0 : units == Units.Millimeters ? rate : rate / 60; + + /// + /// A contour starts where the torch goes down after travel: at a pierce, at a scribe + /// after travel, on a new part, or where cutting switches to scribing or back. + /// + private static void NumberContours(List steps) + { + var contour = -1; + CutSimulationStep previous = null; + foreach (var step in steps) + { + if (step.Kind == CutSimulationStepKind.Rapid) + { + step.ContourIndex = -1; + } + else + { + var starts = previous == null + || previous.Kind == CutSimulationStepKind.Rapid + || step.Kind == CutSimulationStepKind.Pierce + || previous.PartIndex != step.PartIndex + || previous.Kind != CutSimulationStepKind.Pierce && previous.Kind != step.Kind; + if (starts) + contour++; + step.ContourIndex = contour; + } + previous = step; + } + } + + /// + /// Leaves out contours that only scribe, as the CI Fiber post does by default, along with + /// the travel to them; travel is then rejoined around the gap. True when any were removed. + /// + private static bool RemoveScribeContours(ref List steps) + { + var scribeOnly = steps + .Where(s => s.ContourIndex >= 0) + .GroupBy(s => s.ContourIndex) + .Where(g => g.All(s => s.Kind == CutSimulationStepKind.Scribe)) + .Select(g => g.Key) + .ToHashSet(); + if (scribeOnly.Count == 0) + return false; + + var kept = new List(); + foreach (var step in steps) + { + if (!scribeOnly.Contains(step.ContourIndex)) + { + kept.Add(step); + continue; + } + // Drop the travel that led to the skipped contour. + while (kept.Count > 0 && kept[^1].Kind == CutSimulationStepKind.Rapid) + kept.RemoveAt(kept.Count - 1); + } + + var joined = new List(kept.Count); + var at = Vector.Zero; + foreach (var step in kept) + { + if (step.Kind == CutSimulationStepKind.Rapid) + { + if (at.DistanceTo(step.End) > Math.Tolerance.Epsilon) + joined.Add(Travel(at, step.End, step.PartIndex)); + at = step.End; + continue; + } + if (at.DistanceTo(step.Start) > Math.Tolerance.Epsilon) + joined.Add(Travel(at, step.Start, step.PartIndex)); + joined.Add(step); + at = step.End; + } + + steps = joined; + return true; + } + + private static CutSimulationStep Travel(Vector from, Vector to, int partIndex) => + new() + { + Kind = CutSimulationStepKind.Rapid, + PartIndex = partIndex, + Start = from, + End = to, + Length = from.DistanceTo(to), + }; + + /// + /// Matches the CI Fiber post's interior/exterior choice (L2/L4): a cut-off, or a closed + /// contour wound clockwise, is exterior; everything else is a hole. Lead moves are + /// left out of the winding, as the post samples from the lead-in's end. + /// + private static void ClassifyHoles(List steps, Plate plate) + { + foreach (var contour in steps.Where(s => s.ContourIndex >= 0).GroupBy(s => s.ContourIndex)) + { + var moves = contour.Where(s => s.Kind == CutSimulationStepKind.Cut).ToList(); + if (moves.Count == 0) + continue; + + var partIndex = moves[0].PartIndex; + var isCutOff = partIndex < plate.Parts.Count && plate.Parts[partIndex].BaseDrawing?.IsCutOff == true; + var isHole = !isCutOff && !IsClosedClockwise(moves); + foreach (var step in contour) + step.IsHole = isHole && step.Kind != CutSimulationStepKind.Scribe; + } + } + + private static bool IsClosedClockwise(List moves) + { + var points = new List(); + foreach (var move in moves.Where(m => m.Layer is not (LayerType.Leadin or LayerType.Leadout))) + { + if (points.Count == 0) + points.Add(move.Start); + if (move.IsArc) + { + var samples = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Abs(move.Sweep) / (System.Math.PI / 2))); + for (var i = 1; i < samples; i++) + points.Add(move.PointAt((double)i / samples)); + } + points.Add(move.End); + } + + if (points.Count < 3 || points[0].DistanceTo(points[^1]) > ClosedTolerance) + return false; + + var area = 0.0; + for (var i = 0; i < points.Count - 1; i++) + area += points[i].X * points[i + 1].Y - points[i + 1].X * points[i].Y; + return area < 0; + } + } +} diff --git a/OpenNest.Core/CNC/CutSimulation.cs b/OpenNest.Core/CNC/CutSimulation.cs index 43c25d9d..ed56ea4a 100644 --- a/OpenNest.Core/CNC/CutSimulation.cs +++ b/OpenNest.Core/CNC/CutSimulation.cs @@ -36,6 +36,24 @@ namespace OpenNest.CNC public double StartAngle { get; init; } public double Sweep { get; init; } public double Length { get; init; } + + /// The program layer of a cutting or scribing move. + public LayerType Layer { get; init; } + + /// + /// The contour this move or pierce belongs to, numbered in cutting order; -1 for travel. + /// + public int ContourIndex { get; internal set; } = -1; + + /// + /// True when the move is part of an interior contour, timed at the hole feed. Follows the + /// CI Fiber post's rule: only a closed clockwise contour (or a cut-off) is exterior. + /// + public bool IsHole { get; internal set; } + + /// Programmed speed in units per second, before acceleration. + public double NominalSpeed { get; internal set; } + public TimeSpan StartTime { get; internal set; } public TimeSpan Duration { get; internal set; } public TimeSpan EndTime => StartTime + Duration; @@ -130,10 +148,11 @@ namespace OpenNest.CNC } /// - /// A replayable timeline of a plate's tool motion in cutting order, timed with the - /// same feed, rapid and pierce rules as , - /// plus acceleration when set (see ). - /// It does not model jerk, per-motion feed overrides or clamp removal. + /// A replayable timeline of a plate's tool motion in cutting order. Cut and display-layer + /// moves cut (as the posts emit them), at the hole or outside feed. Suppressed moves and, + /// with , etch-only contours are left out. Adds + /// acceleration when set (see ). + /// It does not model jerk, controller feed tables or clamp removal. See docs/cut-time-estimates.md. /// public sealed class CutSimulation { @@ -149,14 +168,12 @@ namespace OpenNest.CNC // A contour (a cutout or a part's perimeter) begins where the torch goes down after travel. var lastPart = -1; - for (var i = 0; i < steps.Count; i++) + var lastContour = -1; + foreach (var step in steps) { - var step = steps[i]; - if (step.Kind == CutSimulationStepKind.Rapid) - continue; - var previous = i > 0 ? steps[i - 1] : null; - if (previous is { Kind: not CutSimulationStepKind.Rapid } && previous.PartIndex == step.PartIndex) + if (step.ContourIndex < 0 || step.ContourIndex == lastContour) continue; + lastContour = step.ContourIndex; contourStarts.Add(step.StartTime); if (step.PartIndex != lastPart) @@ -210,7 +227,7 @@ namespace OpenNest.CNC ArgumentNullException.ThrowIfNull(plate); ArgumentNullException.ThrowIfNull(parameters); - var builder = new Builder(parameters); + var builder = new Builder(); for (var i = 0; i < plate.Parts.Count; i++) { @@ -224,10 +241,8 @@ namespace OpenNest.CNC builder.AddProgram(part.Program, part.Location, ref mode, ref pos); } - if (parameters.Motion != null) - MotionPlanner.Plan(builder.Steps, parameters); - - return new CutSimulation(builder.Steps, plate.Parts.Count); + var steps = ContourTiming.Finish(builder.Steps, plate, parameters); + return new CutSimulation(steps, plate.Parts.Count); } /// @@ -279,12 +294,9 @@ namespace OpenNest.CNC private sealed class Builder { - private readonly CutParameters parameters; - private TimeSpan clock; private Vector tool = Vector.Zero; private bool torchOn; - public Builder(CutParameters parameters) => this.parameters = parameters; public List Steps { get; } = new(); public int PartIndex { get; set; } @@ -313,6 +325,11 @@ namespace OpenNest.CNC pos = rapidEnd; break; + case Motion { Suppressed: true } suppressed: + // Posts leave suppressed moves out; the next move starts from here. + pos = Resolve(suppressed.EndPoint, mode, pos, frameOrigin); + break; + case LinearMove linear: var lineEnd = Resolve(linear.EndPoint, mode, pos, frameOrigin); AddMotion(linear.Layer, pos, lineEnd, isArc: false, default, default); @@ -335,9 +352,11 @@ namespace OpenNest.CNC private static CutSimulationStepKind? KindOf(LayerType layer) => layer switch { - LayerType.Cut or LayerType.Leadin or LayerType.Leadout or LayerType.Break => CutSimulationStepKind.Cut, + // Cutting strategies emit contour moves on Display; the posts cut them. + LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break + => CutSimulationStepKind.Cut, LayerType.Scribe => CutSimulationStepKind.Scribe, - _ => null, // Display and Tab motion is not emitted as cutting. + _ => null, // Tab spans are never cut. }; private void AddMotion(LayerType layer, Vector start, Vector end, bool isArc, Vector center, RotationType rotation) @@ -359,27 +378,26 @@ namespace OpenNest.CNC PartIndex = PartIndex, Start = start, End = start, - Duration = parameters.PierceTime, }); } torchOn = kind == CutSimulationStepKind.Cut; if (isArc) - AddArc(kind, start, end, center, rotation); + AddArc(kind, layer, start, end, center, rotation); else Add(new CutSimulationStep { Kind = kind, + Layer = layer, PartIndex = PartIndex, Start = start, End = end, Length = start.DistanceTo(end), - Duration = TravelTime(start.DistanceTo(end), parameters.Feedrate), }); } - private void AddArc(CutSimulationStepKind kind, Vector start, Vector end, Vector center, RotationType rotation) + private void AddArc(CutSimulationStepKind kind, LayerType layer, Vector start, Vector end, Vector center, RotationType rotation) { var radius = center.DistanceTo(start); var startAngle = center.AngleTo(start); @@ -397,6 +415,7 @@ namespace OpenNest.CNC Add(new CutSimulationStep { Kind = kind, + Layer = layer, PartIndex = PartIndex, Start = start, End = end, @@ -406,7 +425,6 @@ namespace OpenNest.CNC StartAngle = startAngle, Sweep = sweep, Length = length, - Duration = TravelTime(length, parameters.Feedrate), }); } @@ -428,28 +446,14 @@ namespace OpenNest.CNC Start = start, End = end, Length = distance, - Duration = TravelTime(distance, parameters.RapidTravelRate), }); } private void Add(CutSimulationStep step) { - step.StartTime = clock; - clock += step.Duration; tool = step.End; Steps.Add(step); } - - // Matches Timing.CalculateTime: inch rates are per minute, millimeter rates per second. - private TimeSpan TravelTime(double distance, double rate) - { - if (rate <= 0 || distance <= 0) - return TimeSpan.Zero; - - return parameters.Units == Units.Millimeters - ? TimeSpan.FromSeconds(distance / rate) - : TimeSpan.FromMinutes(distance / rate); - } } } } diff --git a/OpenNest.Core/CNC/MotionPlanner.cs b/OpenNest.Core/CNC/MotionPlanner.cs index ce9880ee..6efba800 100644 --- a/OpenNest.Core/CNC/MotionPlanner.cs +++ b/OpenNest.Core/CNC/MotionPlanner.cs @@ -14,11 +14,14 @@ namespace OpenNest.CNC { private const double Straight = 0.999999; - public static void Plan(List steps, CutParameters parameters) + /// + /// Replaces each step's with its accelerated time, + /// from its . Pierces keep their dwell. + /// + public static void Plan(List steps, MotionProfile motion) { ArgumentNullException.ThrowIfNull(steps); - ArgumentNullException.ThrowIfNull(parameters); - var motion = parameters.Motion ?? throw new ArgumentException("No motion profile.", nameof(parameters)); + ArgumentNullException.ThrowIfNull(motion); var start = 0; while (start < steps.Count) @@ -28,33 +31,22 @@ namespace OpenNest.CNC { while (end < steps.Count && IsCutting(steps[end]) && steps[end].Kind == steps[start].Kind) end++; - PlanRun(steps, start, end, NominalSpeed(parameters.Feedrate, parameters.Units), motion.CutAcceleration, motion.CornerDeviation); + PlanRun(steps, start, end, motion.CutAcceleration, motion.CornerDeviation); } else if (steps[start].Kind == CutSimulationStepKind.Rapid) { - PlanRun(steps, start, end, NominalSpeed(parameters.RapidTravelRate, parameters.Units), motion.RapidAcceleration, 0); + PlanRun(steps, start, end, motion.RapidAcceleration, 0); } start = end; } - - var clock = TimeSpan.Zero; - foreach (var step in steps) - { - step.StartTime = clock; - clock += step.Duration; - } } private static bool IsCutting(CutSimulationStep step) => step.Kind is CutSimulationStepKind.Cut or CutSimulationStepKind.Scribe; - // Feed rates follow Timing.CalculateTime: inch rates are per minute, millimeter rates per second. - private static double NominalSpeed(double rate, Units units) => - units == Units.Millimeters ? rate : rate / 60; - - private static void PlanRun(List steps, int start, int end, double nominal, double acceleration, double deviation) + private static void PlanRun(List steps, int start, int end, double acceleration, double deviation) { - if (acceleration <= 0 || nominal <= 0) + if (acceleration <= 0) return; // Keep the constant-speed timing. var count = end - start; @@ -64,6 +56,7 @@ namespace OpenNest.CNC for (var i = 0; i < count; i++) { var step = steps[start + i]; + var nominal = step.NominalSpeed; maxSpeed[i] = step.IsArc && step.Radius > 0 ? System.Math.Min(nominal, System.Math.Sqrt(acceleration * step.Radius)) : nominal; @@ -108,11 +101,8 @@ namespace OpenNest.CNC private static void Profile(CutSimulationStep step, double entry, double exit, double maxSpeed, double acceleration) { var length = step.Length; - if (length <= 0) - { - step.Duration = TimeSpan.Zero; - return; - } + if (length <= 0 || maxSpeed <= 0) + return; // Nothing to accelerate: keep the constant-speed timing. var cruise = maxSpeed; var accelDistance = (cruise * cruise - entry * entry) / (2 * acceleration); diff --git a/OpenNest.Core/CutParameters.cs b/OpenNest.Core/CutParameters.cs index 3294af4f..fe6d0b97 100644 --- a/OpenNest.Core/CutParameters.cs +++ b/OpenNest.Core/CutParameters.cs @@ -17,6 +17,12 @@ public class CutParameters /// public MotionProfile Motion { get; set; } + /// Feed for interior contours (holes); null or zero uses . + public double? HoleFeedrate { get; set; } + + /// Leave out contours that only scribe/etch, as the CI Fiber post does by default. + public bool SkipScribe { get; set; } + public static CutParameters Default => new() { diff --git a/OpenNest.Core/Timing.cs b/OpenNest.Core/Timing.cs index 775c3818..4aa5481b 100644 --- a/OpenNest.Core/Timing.cs +++ b/OpenNest.Core/Timing.cs @@ -76,14 +76,12 @@ namespace OpenNest } /// - /// A plate's cut time over its quantity. With it is the - /// accelerated timeline; otherwise every move runs at full speed. + /// A plate's cut time over its quantity: its timeline (hole and + /// outside feeds, skipped etch, and acceleration when is + /// set) plus clamp removal. /// public static TimeSpan CalculateTime(Plate plate, CutParameters cutParams) { - if (cutParams.Motion == null) - return CalculateTime(GetTimingInfo(plate), cutParams); - var sheet = CutSimulation.Build(plate, cutParams).TotalTime + TimeSpan.FromMinutes(plate.ClampRemovalMinutes); return sheet * plate.Quantity; diff --git a/OpenNest.Tests/CNC/CutSimulationTests.cs b/OpenNest.Tests/CNC/CutSimulationTests.cs index 37d0870a..eb6c3751 100644 --- a/OpenNest.Tests/CNC/CutSimulationTests.cs +++ b/OpenNest.Tests/CNC/CutSimulationTests.cs @@ -205,6 +205,90 @@ public class CutSimulationTests Assert.Equal(sim.PartStarts[1], sim.PreviousPart(sim.TotalTime)); } + // Clockwise outside (exterior for the CI Fiber post) around a counterclockwise hole. + private static Program PlateWithHole(LayerType layer = LayerType.Cut) + { + var program = new Program(); + program.Codes.Add(new RapidMove(4, 5)); + program.Codes.Add(new LinearMove(6, 5) { Layer = layer }); + program.Codes.Add(new LinearMove(6, 7) { Layer = layer }); + program.Codes.Add(new LinearMove(4, 7) { Layer = layer }); + program.Codes.Add(new LinearMove(4, 5) { Layer = layer }); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(0, 10) { Layer = layer }); + program.Codes.Add(new LinearMove(10, 10) { Layer = layer }); + program.Codes.Add(new LinearMove(10, 0) { Layer = layer }); + program.Codes.Add(new LinearMove(0, 0) { Layer = layer }); + return program; + } + + [Fact] + public void DisplayLayerContourMovesAreCut() + { + // Cutting strategies emit contour moves on the Display layer; the posts cut them. + var sim = CutSimulation.Build(PlateWith((PlateWithHole(LayerType.Display), Vector.Zero)), Parameters); + + Assert.Equal(8, sim.Steps.Count(s => s.Kind == CutSimulationStepKind.Cut)); + Assert.Equal(2, sim.Steps.Count(s => s.Kind == CutSimulationStepKind.Pierce)); + } + + [Fact] + public void SuppressedMovesAreLeftOut() + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(5, 0)); + program.Codes.Add(new LinearMove(10, 0) { Suppressed = true }); + program.Codes.Add(new LinearMove(10, 5)); + + var sim = CutSimulation.Build(PlateWith((program, Vector.Zero)), Parameters); + + Assert.Equal(10, sim.Steps.Where(s => s.Kind == CutSimulationStepKind.Cut).Sum(s => s.Length), 9); + Assert.Single(sim.Steps, s => s.Kind == CutSimulationStepKind.Rapid && s.Start == new Vector(5, 0)); + } + + [Fact] + public void HolesUseTheHoleFeedAndOutsidesTheFeed() + { + var parameters = new CutParameters + { + Feedrate = 100, + HoleFeedrate = 50, + RapidTravelRate = 600, + Units = Units.Inches, + }; + + var sim = CutSimulation.Build(PlateWith((PlateWithHole(), Vector.Zero)), parameters); + + var cuts = sim.Steps.Where(s => s.Kind == CutSimulationStepKind.Cut).ToList(); + Assert.All(cuts.Take(4), s => Assert.True(s.IsHole)); + Assert.All(cuts.Skip(4), s => Assert.False(s.IsHole)); + Assert.Equal(TimeSpan.FromMinutes(8.0 / 50).TotalSeconds, cuts.Take(4).Sum(s => s.Duration.TotalSeconds), 6); + Assert.Equal(TimeSpan.FromMinutes(40.0 / 100).TotalSeconds, cuts.Skip(4).Sum(s => s.Duration.TotalSeconds), 6); + } + + [Fact] + public void SkippedEtchContoursLeaveNoTimeAndRejoinTravel() + { + var program = new Program(); + program.Codes.Add(new RapidMove(20, 0)); + program.Codes.Add(new LinearMove(25, 0) { Layer = LayerType.Scribe }); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(10, 0)); + var plate = PlateWith((program, Vector.Zero)); + var parameters = new CutParameters { Feedrate = 100, RapidTravelRate = 600, Units = Units.Inches }; + + var kept = CutSimulation.Build(plate, parameters); + parameters.SkipScribe = true; + var skipped = CutSimulation.Build(plate, parameters); + + Assert.Contains(kept.Steps, s => s.Kind == CutSimulationStepKind.Scribe); + Assert.DoesNotContain(skipped.Steps, s => s.Kind == CutSimulationStepKind.Scribe); + Assert.DoesNotContain(skipped.Steps, s => s.Kind == CutSimulationStepKind.Rapid); // straight from the origin to the cut + Assert.Single(skipped.ContourStarts); + Assert.Equal(TimeSpan.FromMinutes(10.0 / 100).TotalSeconds, skipped.TotalTime.TotalSeconds, 6); + } + [Fact] public void EmptyPlateHasNoTimeline() { diff --git a/OpenNest/Forms/CutParametersForm.cs b/OpenNest/Forms/CutParametersForm.cs index a4dcf34c..88387b3f 100644 --- a/OpenNest/Forms/CutParametersForm.cs +++ b/OpenNest/Forms/CutParametersForm.cs @@ -1,50 +1,69 @@ using System; using System.Windows.Forms; +using NumericUpDown = OpenNest.Controls.NumericUpDown; namespace OpenNest.Forms { public partial class CutParametersForm : Form { private Units units; + private readonly NumericUpDown holeFeedBox; private readonly CutTimeMachineBox machineBox; + private readonly CheckBox skipEtchBox; public CutParametersForm() { InitializeComponent(); - Units = OpenNest.Units.Inches; - machineBox = new CutTimeMachineBox { Anchor = AnchorStyles.Left | AnchorStyles.Right }; - var label = new Label + label1.Text = "Outside feed :"; + holeFeedBox = new NumericUpDown { - Text = "Acceleration:", - AutoSize = true, - Anchor = AnchorStyles.Left | AnchorStyles.Right, + Anchor = numericUpDown1.Anchor, + Minimum = numericUpDown1.Minimum, + Maximum = numericUpDown1.Maximum, + Size = numericUpDown1.Size, }; - tableLayoutPanel1.RowCount = 4; + machineBox = new CutTimeMachineBox { Anchor = AnchorStyles.Left | AnchorStyles.Right }; + skipEtchBox = new CheckBox { Text = "Skip etch-only contours", AutoSize = true, Anchor = AnchorStyles.Left }; + + // Rows after the designer's feed, rapid and pierce. + tableLayoutPanel1.RowCount = 6; tableLayoutPanel1.RowStyles.Clear(); - for (var i = 0; i < 4; i++) - tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 25F)); - tableLayoutPanel1.Controls.Add(label, 0, 3); - tableLayoutPanel1.Controls.Add(machineBox, 1, 3); - Height += LogicalToDeviceUnits(40); + for (var i = 0; i < 6; i++) + tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 100F / 6)); + tableLayoutPanel1.Controls.Add(RowLabel("Hole feed :"), 0, 3); + tableLayoutPanel1.Controls.Add(holeFeedBox, 1, 3); + tableLayoutPanel1.Controls.Add(RowLabel("Acceleration :"), 0, 4); + tableLayoutPanel1.Controls.Add(machineBox, 1, 4); + tableLayoutPanel1.Controls.Add(skipEtchBox, 1, 5); + Height += LogicalToDeviceUnits(120); + + Units = OpenNest.Units.Inches; } + private static Label RowLabel(string text) => + new() { Text = text, AutoSize = true, Anchor = AnchorStyles.Left | AnchorStyles.Right }; + protected override void OnLoad(EventArgs e) { base.OnLoad(e); - numericUpDown1.Value = Properties.Settings.Default.LastFeedrate; - numericUpDown2.Value = Properties.Settings.Default.LastRapidFeedrate; - numericUpDown3.Value = Properties.Settings.Default.LastPierceTime; + var saved = SavedCutParameters.Load(units); + numericUpDown1.Value = Clamp(numericUpDown1, saved.Feedrate); + holeFeedBox.Value = Clamp(holeFeedBox, saved.HoleFeedrate ?? saved.Feedrate); + numericUpDown2.Value = Clamp(numericUpDown2, saved.RapidTravelRate); + numericUpDown3.Value = Clamp(numericUpDown3, saved.PierceTime.TotalSeconds); + skipEtchBox.Checked = saved.SkipScribe; } + private static decimal Clamp(System.Windows.Forms.NumericUpDown box, double value) => + System.Math.Clamp((decimal)value, box.Minimum, box.Maximum); + protected override void OnClosing(System.ComponentModel.CancelEventArgs e) { base.OnClosing(e); - Properties.Settings.Default.LastFeedrate = numericUpDown1.Value; - Properties.Settings.Default.LastRapidFeedrate = numericUpDown2.Value; - Properties.Settings.Default.LastPierceTime = numericUpDown3.Value; + SavedCutParameters.Save(GetCutParameters()); machineBox.SaveChoice(); } @@ -64,6 +83,7 @@ namespace OpenNest.Forms numericUpDown1.Suffix = " " + suffix; numericUpDown2.Suffix = " " + suffix; + holeFeedBox.Suffix = " " + suffix; } public CutParameters GetCutParameters() @@ -71,8 +91,10 @@ namespace OpenNest.Forms return new CutParameters() { Feedrate = (double)numericUpDown1.Value, + HoleFeedrate = (double)holeFeedBox.Value, RapidTravelRate = (double)numericUpDown2.Value, PierceTime = TimeSpan.FromSeconds((double)numericUpDown3.Value), + SkipScribe = skipEtchBox.Checked, Units = units, Motion = machineBox.GetMotion(units), }; diff --git a/OpenNest/Forms/CutSimulatorForm.cs b/OpenNest/Forms/CutSimulatorForm.cs index 328d4477..f71ee898 100644 --- a/OpenNest/Forms/CutSimulatorForm.cs +++ b/OpenNest/Forms/CutSimulatorForm.cs @@ -23,7 +23,8 @@ namespace OpenNest.Forms private readonly ComboBox speedBox; private readonly TrackBar trackBar; private readonly Label timeLabel, partLabel, stateLabel; - private readonly NumericUpDown feedBox, rapidBox, pierceBox; + private readonly NumericUpDown feedBox, holeFeedBox, rapidBox, pierceBox; + private readonly CheckBox skipEtchBox; private readonly CutTimeMachineBox machineBox; private Plate plate; private bool updatingTrack; @@ -86,24 +87,30 @@ namespace OpenNest.Forms var units = nestForm.Nest.Units; var rateSuffix = " " + UnitsHelper.GetShortTimeUnitPair(units); - // Settings that were never saved (1, 1, 0) would crawl; start from the timing defaults instead. - var settings = Properties.Settings.Default; - var unset = settings.LastFeedrate == 1 && settings.LastRapidFeedrate == 1 && settings.LastPierceTime == 0; - var defaults = CutParameters.Default; - feedBox = NewNumber(unset ? (decimal)defaults.Feedrate : settings.LastFeedrate, 1, 9999, 0, rateSuffix); - rapidBox = NewNumber(unset ? (decimal)defaults.RapidTravelRate : settings.LastRapidFeedrate, 1, 9999, 0, rateSuffix); - pierceBox = NewNumber(unset ? (decimal)defaults.PierceTime.TotalSeconds : settings.LastPierceTime, 0, 999, 1, " s"); - var rates = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = true }; + var saved = SavedCutParameters.Load(units); + feedBox = NewNumber((decimal)saved.Feedrate, 1, 9999, 0, rateSuffix); + holeFeedBox = NewNumber((decimal)(saved.HoleFeedrate ?? saved.Feedrate), 1, 9999, 0, rateSuffix); + rapidBox = NewNumber((decimal)saved.RapidTravelRate, 1, 9999, 0, rateSuffix); + pierceBox = NewNumber((decimal)saved.PierceTime.TotalSeconds, 0, 999, 1, " s"); + var rates = new TableLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, ColumnCount = 4 }; rates.Controls.AddRange(new Control[] { - RateLabel("Feed"), feedBox, + RateLabel("Outside feed"), feedBox, + RateLabel("Hole feed"), holeFeedBox, RateLabel("Rapid"), rapidBox, RateLabel("Pierce"), pierceBox, }); machineBox = new CutTimeMachineBox(); - var motion = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = false }; - motion.Controls.AddRange(new Control[] { RateLabel("Acceleration"), machineBox }); + skipEtchBox = new CheckBox + { + Text = "Skip etch-only contours", + Checked = saved.SkipScribe, + AutoSize = true, + Margin = new Padding(12, 6, 3, 3), + }; + var motion = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = true }; + motion.Controls.AddRange(new Control[] { RateLabel("Acceleration"), machineBox, skipEtchBox }); var layout = new TableLayoutPanel { @@ -134,6 +141,8 @@ namespace OpenNest.Forms speedBox.SelectedIndexChanged += (_, _) => overlay.Speed = Speeds[speedBox.SelectedIndex]; trackBar.Scroll += (_, _) => Seek(); feedBox.ValueChanged += (_, _) => Rebuild(); + holeFeedBox.ValueChanged += (_, _) => Rebuild(); + skipEtchBox.CheckedChanged += (_, _) => Rebuild(); rapidBox.ValueChanged += (_, _) => Rebuild(); pierceBox.ValueChanged += (_, _) => Rebuild(); machineBox.SelectedIndexChanged += (_, _) => Rebuild(); @@ -146,6 +155,9 @@ namespace OpenNest.Forms tips.SetToolTip(previousContourButton, "Back to the start of this cutout or perimeter, or the one before (Ctrl+Left)"); tips.SetToolTip(nextContourButton, "Next cutout or perimeter (Ctrl+Right)"); tips.SetToolTip(nextPartButton, "Next part (Page Down)"); + tips.SetToolTip(feedBox, "Feed for outside contours and cut-offs (the CI Fiber post's exterior, L4)"); + tips.SetToolTip(holeFeedBox, "Feed for holes and other interior contours (the CI Fiber post's interior, L2)"); + tips.SetToolTip(skipEtchBox, "Leave out contours that only etch, as the CI Fiber post does by default"); tips.SetToolTip(machineBox, "Machine whose acceleration limits time the moves (Tools > Machine Configuration)"); tips.SetToolTip(trackBar, "Drag to scrub through the cut (Left/Right step one move)"); @@ -182,12 +194,14 @@ namespace OpenNest.Forms } private static Label RateLabel(string text) => - new() { Text = text, AutoSize = true, Margin = new Padding(3, 7, 0, 0) }; + new() { Text = text, AutoSize = true, Margin = new Padding(3, 7, 0, 0), Anchor = AnchorStyles.Left }; private CutParameters GetCutParameters() => new() { Feedrate = (double)feedBox.Value, + HoleFeedrate = (double)holeFeedBox.Value, + SkipScribe = skipEtchBox.Checked, RapidTravelRate = (double)rapidBox.Value, PierceTime = TimeSpan.FromSeconds((double)pierceBox.Value), Units = nestForm.Nest.Units, @@ -381,9 +395,7 @@ namespace OpenNest.Forms protected override void OnFormClosed(FormClosedEventArgs e) { - Properties.Settings.Default.LastFeedrate = feedBox.Value; - Properties.Settings.Default.LastRapidFeedrate = rapidBox.Value; - Properties.Settings.Default.LastPierceTime = pierceBox.Value; + SavedCutParameters.Save(GetCutParameters()); machineBox.SaveChoice(); overlay.Changed -= Overlay_Changed; diff --git a/OpenNest/Forms/CutTimeMachineBox.cs b/OpenNest/Forms/CutTimeMachineBox.cs index b1493275..2fe22826 100644 --- a/OpenNest/Forms/CutTimeMachineBox.cs +++ b/OpenNest/Forms/CutTimeMachineBox.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Windows.Forms; using OpenNest.Data; @@ -26,15 +27,26 @@ namespace OpenNest.Forms DropDownStyle = ComboBoxStyle.DropDownList; Width = 200; + var machines = Sorted(MachineSettings.LoadMachines()); Items.Add(new Choice(null)); - foreach (var machine in MachineSettings.LoadMachines().OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)) + foreach (var machine in machines) Items.Add(new Choice(machine)); + var saved = SavedMachine(machines); + SelectedItem = Items.Cast().First(c => c.Machine == saved); + } + + private static List Sorted(IEnumerable machines) => + machines.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); + + /// The remembered machine, the first one until a choice is saved, or null for full speed. + public static MachineConfig SavedMachine(IEnumerable machines) + { + var sorted = Sorted(machines); var saved = Properties.Settings.Default.CutTimeMachine; - var choices = Items.Cast().ToList(); - SelectedItem = saved == ConstantSpeed - ? choices[0] - : choices.FirstOrDefault(c => c.Machine?.Id.ToString() == saved) ?? choices.ElementAtOrDefault(1) ?? choices[0]; + return saved == ConstantSpeed + ? null + : sorted.FirstOrDefault(m => m.Id.ToString() == saved) ?? sorted.FirstOrDefault(); } public MachineConfig Machine => (SelectedItem as Choice)?.Machine; diff --git a/OpenNest/Forms/SavedCutParameters.cs b/OpenNest/Forms/SavedCutParameters.cs new file mode 100644 index 00000000..09e4efb8 --- /dev/null +++ b/OpenNest/Forms/SavedCutParameters.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using OpenNest.Data; + +namespace OpenNest.Forms +{ + /// + /// The cut-time settings last used in Calculate Cut Time or the simulator, shared with + /// anything else that estimates cut time (such as reports). + /// + internal static class SavedCutParameters + { + /// + /// The saved feed, hole feed, rapid, pierce and etch choice, plus the saved machine's + /// acceleration in . Settings never saved (1, 1, 0) would crawl, so + /// they fall back to . + /// + public static CutParameters Load(Units units) + { + var settings = Properties.Settings.Default; + var unset = settings.LastFeedrate == 1 && settings.LastRapidFeedrate == 1 && settings.LastPierceTime == 0; + var defaults = CutParameters.Default; + var feed = unset ? defaults.Feedrate : (double)settings.LastFeedrate; + var machine = CutTimeMachineBox.SavedMachine(MachineSettings.LoadMachines()); + + return new CutParameters + { + Feedrate = feed, + HoleFeedrate = settings.LastHoleFeedrate > 0 ? (double)settings.LastHoleFeedrate : feed, + RapidTravelRate = unset ? defaults.RapidTravelRate : (double)settings.LastRapidFeedrate, + PierceTime = unset ? defaults.PierceTime : TimeSpan.FromSeconds((double)settings.LastPierceTime), + SkipScribe = settings.CutTimeSkipEtch, + Units = units, + Motion = machine?.GetMotionOrPlaceholder().ToProfile(machine.Units, units), + }; + } + + public static void Save(CutParameters parameters) + { + var settings = Properties.Settings.Default; + settings.LastFeedrate = (decimal)parameters.Feedrate; + settings.LastHoleFeedrate = (decimal)(parameters.HoleFeedrate ?? 0); + settings.LastRapidFeedrate = (decimal)parameters.RapidTravelRate; + settings.LastPierceTime = (decimal)parameters.PierceTime.TotalSeconds; + settings.CutTimeSkipEtch = parameters.SkipScribe; + settings.Save(); + } + } +} diff --git a/OpenNest/Properties/Settings.Designer.cs b/OpenNest/Properties/Settings.Designer.cs index c9564053..965f119a 100644 --- a/OpenNest/Properties/Settings.Designer.cs +++ b/OpenNest/Properties/Settings.Designer.cs @@ -233,6 +233,30 @@ namespace OpenNest.Properties { } } + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("0")] + public decimal LastHoleFeedrate { + get { + return ((decimal)(this["LastHoleFeedrate"])); + } + set { + this["LastHoleFeedrate"] = value; + } + } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool CutTimeSkipEtch { + get { + return ((bool)(this["CutTimeSkipEtch"])); + } + set { + this["CutTimeSkipEtch"] = value; + } + } + [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("")] diff --git a/OpenNest/Properties/Settings.settings b/OpenNest/Properties/Settings.settings index 6b1317d1..8f65e873 100644 --- a/OpenNest/Properties/Settings.settings +++ b/OpenNest/Properties/Settings.settings @@ -56,6 +56,12 @@ + + 0 + + + True + diff --git a/OpenNest/app.config b/OpenNest/app.config index be8321c7..12255e70 100644 --- a/OpenNest/app.config +++ b/OpenNest/app.config @@ -56,6 +56,12 @@ + + 0 + + + True + diff --git a/README.md b/README.md index b167a115..893782b6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges - **Nesting** — automatic multi-plate/multi-material jobs, interlocking pair evaluation, and built-in or plug-in engines. [Choose an engine](docs/nesting-engines.md). - **Plate operations** — manual sheet cut-offs, [plate- or nest-wide automatic scrap cutoffs with a minimum tail-to-keep setting](docs/automatic-scrap-cutoffs.md), oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts. - **Visual overlap check** — highlight shared material on the active plate, rechecked automatically after edits, including containment and cutouts, with area shading, pair markers, and hover details through View > Overlap Check. [Usage and limitations](docs/geometry/visual-overlap-check.md). -- **Cut simulator** — Plate > Simulate Cutting replays the active plate in cut order: the torch traces each cut, rapid and pierce at adjustable feed, rapid and pierce settings, with play/pause, scrubbing, 1×–250× speed and previous/next part (Page Up/Down) and cutout (Ctrl+Left/Right) jumps. Parts the torch has not reached stay greyed. It and Calculate Cut Time use the chosen machine's acceleration and corner limits from Machine Configuration (placeholder values until you enter the controller's). [Timing model](docs/cut-time-estimates.md). +- **Cut simulator** — Plate > Simulate Cutting replays the active plate in cut order: the torch traces each cut, rapid and pierce at adjustable feed, rapid and pierce settings, with play/pause, scrubbing, 1×–250× speed and previous/next part (Page Up/Down) and cutout (Ctrl+Left/Right) jumps. Parts the torch has not reached stay greyed. It and Calculate Cut Time use separate outside and hole feeds, skip etch-only contours like the CI Fiber post, and apply the chosen machine's acceleration and corner limits from Machine Configuration (placeholder values until you enter the controller's). [Timing model](docs/cut-time-estimates.md). - **CNC output** — configurable lead-ins/outs and tabs, contour editing, user-defined G-code variables (`$name` → `#200+` machine variables), plugin post-processors (Cincinnati CL-707/800/900/940/CLX included). [Pre-post verification](docs/post-verification.md) checks overlaps, missing lead-ins, and rapid crossings, with explicit risk acknowledgment required to bypass warnings. ## Requirements diff --git a/docs/cut-time-estimates.md b/docs/cut-time-estimates.md index a023b04c..ece1f008 100644 --- a/docs/cut-time-estimates.md +++ b/docs/cut-time-estimates.md @@ -1,12 +1,15 @@ # Cut-time estimates Calculate Cut Time and Plate > Simulate Cutting share one timing model -(`OpenNest.Core/CNC/CutSimulation.cs`, `MotionPlanner.cs`). +(`OpenNest.Core/CNC/CutSimulation.cs`, `ContourTiming.cs`, `MotionPlanner.cs`), and the +last-used settings are shared through `SavedCutParameters`. ## Inputs -- **Feed, rapid and pierce** from the dialog or simulator. Inch rates are per minute; - millimeter rates are per second (the existing `Timing.CalculateTime` convention). +- **Outside feed, hole feed, rapid and pierce** from the dialog or simulator. Inch rates are per + minute; millimeter rates are per second (the existing `Timing.CalculateTime` convention). +- **Skip etch-only contours** (on by default) leaves out contours that only scribe, as the + CI Fiber post's `SkipScribe` does, and joins the travel around them. - **Acceleration** from a machine in Tools > Machine Configuration (`MachineConfig.Motion`, in the machine's units and converted to the nest's). The choice is remembered in the `CutTimeMachine` user setting. Until any machine is chosen, the first machine is used. @@ -17,8 +20,14 @@ Calculate Cut Time and Plate > Simulate Cutting share one timing model ## Model -- Moves are walked in plate cutting order. Rapids, Display-layer moves and Tab spans are head-up - travel. A cut that follows travel gets a pierce dwell. Scribes are not pierced. +- Moves are walked in plate cutting order. Cut, Display, lead-in/out and Break moves cut; cutting + strategies emit contour moves on Display, and the posts cut them. Rapids and Tab spans are + head-up travel, and suppressed moves are left out. A cut that follows travel gets a pierce + dwell. Scribes are not pierced. +- Contours follow the CI Fiber post's interior/exterior (`L2`/`L4`) rule: a cut-off, or a + closed contour wound clockwise (sampled without its lead moves), takes the outside feed. + Everything else, including open pieces, takes the hole feed. The CI Fiber post writes no feeds, + so these are stand-ins for the controller's process tables until calibrated. - Each cutting run (between a pierce and the next travel) starts and ends at rest. Travel moves are planned separately and also go from rest to rest. - Each move follows a trapezoidal profile: it accelerates, cruises at the feed and decelerates. A @@ -29,9 +38,11 @@ Calculate Cut Time and Plate > Simulate Cutting share one timing model - A backward pass and then a forward pass keep every speed change within the acceleration (Grbl-style lookahead). -Not modeled: jerk (S-curves), controller-specific lookahead limits, per-move feed overrides and -power ramping. With real controller values, expect roughly 5–15% error. Calibrate by comparing +Not modeled: jerk (S-curves), controller-specific lookahead limits, feeds from controller +process tables beyond the outside/hole split, an etch feed (etch kept in the timing runs at the +outside feed), and power ramping. With real controller values, expect roughly 5–15% error. Calibrate by comparing a known program against the machine's own run time. -With acceleration, the plate time is the simulation total plus clamp-removal minutes, times the -plate quantity. Distances and counts in the timing dialog still come from `Timing.GetTimingInfo`. +The plate time is the simulation total plus clamp-removal minutes, times the plate quantity. +Distances and counts in the timing dialog still come from `Timing.GetTimingInfo`, which counts +only Cut-layer moves as cut distance.