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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 17:32:38 -04:00
1 parent 095b4d6e72
commit d3cb23f117
15 files changed
+533 -116

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
/// <summary>
/// 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.
/// </summary>
internal static class ContourTiming
{
private const double ClosedTolerance = 1e-4;
public static List<CutSimulationStep> Finish(List<CutSimulationStep> 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;
/// <summary>
/// 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.
/// </summary>
private static void NumberContours(List<CutSimulationStep> 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;
}
}
/// <summary>
/// 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.
/// </summary>
private static bool RemoveScribeContours(ref List<CutSimulationStep> 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<CutSimulationStep>();
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<CutSimulationStep>(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),
};
/// <summary>
/// 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.
/// </summary>
private static void ClassifyHoles(List<CutSimulationStep> 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<CutSimulationStep> moves)
{
var points = new List<Vector>();
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;
}
}
}
+43 -39
View File
@@ -36,6 +36,24 @@ namespace OpenNest.CNC
public double StartAngle { get; init; }
public double Sweep { get; init; }
public double Length { get; init; }
/// <summary>The program layer of a cutting or scribing move.</summary>
public LayerType Layer { get; init; }
/// <summary>
/// The contour this move or pierce belongs to, numbered in cutting order; -1 for travel.
/// </summary>
public int ContourIndex { get; internal set; } = -1;
/// <summary>
/// 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.
/// </summary>
public bool IsHole { get; internal set; }
/// <summary>Programmed speed in units per second, before acceleration.</summary>
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
}
/// <summary>
/// A replayable timeline of a plate's tool motion in cutting order, timed with the
/// same feed, rapid and pierce rules as <see cref="Timing.CalculateTime(TimingInfo, CutParameters)"/>,
/// plus <see cref="CutParameters.Motion"/> acceleration when set (see <see cref="MotionPlanner"/>).
/// 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 <see cref="CutParameters.SkipScribe"/>, etch-only contours are left out. Adds
/// <see cref="CutParameters.Motion"/> acceleration when set (see <see cref="MotionPlanner"/>).
/// It does not model jerk, controller feed tables or clamp removal. See docs/cut-time-estimates.md.
/// </summary>
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);
}
/// <summary>
@@ -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<CutSimulationStep> 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);
}
}
}
}
+13 -23
View File
@@ -14,11 +14,14 @@ namespace OpenNest.CNC
{
private const double Straight = 0.999999;
public static void Plan(List<CutSimulationStep> steps, CutParameters parameters)
/// <summary>
/// Replaces each step's <see cref="CutSimulationStep.Duration"/> with its accelerated time,
/// from its <see cref="CutSimulationStep.NominalSpeed"/>. Pierces keep their dwell.
/// </summary>
public static void Plan(List<CutSimulationStep> 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<CutSimulationStep> steps, int start, int end, double nominal, double acceleration, double deviation)
private static void PlanRun(List<CutSimulationStep> 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);
+6
View File
@@ -17,6 +17,12 @@ public class CutParameters
/// </summary>
public MotionProfile Motion { get; set; }
/// <summary>Feed for interior contours (holes); null or zero uses <see cref="Feedrate"/>.</summary>
public double? HoleFeedrate { get; set; }
/// <summary>Leave out contours that only scribe/etch, as the CI Fiber post does by default.</summary>
public bool SkipScribe { get; set; }
public static CutParameters Default =>
new()
{
+3 -5
View File
@@ -76,14 +76,12 @@ namespace OpenNest
}
/// <summary>
/// A plate's cut time over its quantity. With <see cref="CutParameters.Motion"/> it is the
/// accelerated <see cref="CutSimulation"/> timeline; otherwise every move runs at full speed.
/// A plate's cut time over its quantity: its <see cref="CutSimulation"/> timeline (hole and
/// outside feeds, skipped etch, and acceleration when <see cref="CutParameters.Motion"/> is
/// set) plus clamp removal.
/// </summary>
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;
+84
View File
@@ -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()
{
+40 -18
View File
@@ -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),
};
+28 -16
View File
@@ -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;
+17 -5
View File
@@ -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<Choice>().First(c => c.Machine == saved);
}
private static List<MachineConfig> Sorted(IEnumerable<MachineConfig> machines) =>
machines.OrderBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
/// <summary>The remembered machine, the first one until a choice is saved, or null for full speed.</summary>
public static MachineConfig SavedMachine(IEnumerable<MachineConfig> machines)
{
var sorted = Sorted(machines);
var saved = Properties.Settings.Default.CutTimeMachine;
var choices = Items.Cast<Choice>().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;
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Linq;
using OpenNest.Data;
namespace OpenNest.Forms
{
/// <summary>
/// The cut-time settings last used in Calculate Cut Time or the simulator, shared with
/// anything else that estimates cut time (such as reports).
/// </summary>
internal static class SavedCutParameters
{
/// <summary>
/// The saved feed, hole feed, rapid, pierce and etch choice, plus the saved machine's
/// acceleration in <paramref name="units"/>. Settings never saved (1, 1, 0) would crawl, so
/// they fall back to <see cref="CutParameters.Default"/>.
/// </summary>
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();
}
}
}
+24
View File
@@ -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("")]
+6
View File
@@ -56,6 +56,12 @@
<Setting Name="CutTimeMachine" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
<Setting Name="LastHoleFeedrate" Type="System.Decimal" Scope="User">
<Value Profile="(Default)">0</Value>
</Setting>
<Setting Name="CutTimeSkipEtch" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">True</Value>
</Setting>
<Setting Name="DisabledStrategies" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
+6
View File
@@ -56,6 +56,12 @@
<setting name="CutTimeMachine" serializeAs="String">
<value/>
</setting>
<setting name="LastHoleFeedrate" serializeAs="String">
<value>0</value>
</setting>
<setting name="CutTimeSkipEtch" serializeAs="String">
<value>True</value>
</setting>
<setting name="CuttingParametersJson" serializeAs="String">
<value/>
</setting>
+1 -1
View File
@@ -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
+20 -9
View File
@@ -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.