using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC
{
public enum CutSimulationStepKind
{
/// Head-up travel: a rapid, a gap between programs, or an uncut (display/tab) move.
Rapid,
/// The dwell before a cut that starts after travel.
Pierce,
/// Torch-on motion through material (cut, lead-in/out, break).
Cut,
/// Marking motion that does not cut through.
Scribe,
}
///
/// One timed tool motion in plate coordinates. Arcs carry a signed sweep
/// (positive counterclockwise); lines and pierces leave it zero.
///
public sealed class CutSimulationStep
{
public CutSimulationStepKind Kind { get; init; }
public int PartIndex { get; init; }
public Vector Start { get; init; }
public Vector End { get; init; }
public bool IsArc { get; init; }
public Vector Center { get; init; }
public double Radius { get; init; }
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;
///
/// Planned speed profile in units per second: accelerate from
/// to , then decelerate to . A zero
/// moves at constant speed for the whole step.
///
public double EntrySpeed { get; internal set; }
public double CruiseSpeed { get; internal set; }
public double ExitSpeed { get; internal set; }
public double Acceleration { get; internal set; }
/// Fraction (0..1) of this step's distance covered into it.
public double ProgressAt(TimeSpan elapsed)
{
if (Duration <= TimeSpan.Zero)
return elapsed >= TimeSpan.Zero ? 1 : 0;
if (Acceleration <= 0 || Length <= 0)
return System.Math.Clamp(elapsed / Duration, 0, 1);
return System.Math.Clamp(DistanceAt(elapsed.TotalSeconds) / Length, 0, 1);
}
/// Head speed in units per second into this step.
public double SpeedAt(TimeSpan elapsed)
{
if (Kind == CutSimulationStepKind.Pierce || Duration <= TimeSpan.Zero)
return 0;
if (Acceleration <= 0)
return Length / Duration.TotalSeconds;
var t = System.Math.Clamp(elapsed.TotalSeconds, 0, Duration.TotalSeconds);
var (accelEnd, cruiseEnd) = PhaseEnds();
if (t < accelEnd)
return EntrySpeed + Acceleration * t;
if (t < cruiseEnd)
return CruiseSpeed;
return System.Math.Max(0, CruiseSpeed - Acceleration * (t - cruiseEnd));
}
private (double AccelEnd, double CruiseEnd) PhaseEnds()
{
var accelTime = (CruiseSpeed - EntrySpeed) / Acceleration;
var accelDistance = (CruiseSpeed * CruiseSpeed - EntrySpeed * EntrySpeed) / (2 * Acceleration);
var decelDistance = (CruiseSpeed * CruiseSpeed - ExitSpeed * ExitSpeed) / (2 * Acceleration);
var cruise = CruiseSpeed > 0 ? System.Math.Max(0, Length - accelDistance - decelDistance) / CruiseSpeed : 0;
return (accelTime, accelTime + cruise);
}
private double DistanceAt(double t)
{
var (accelEnd, cruiseEnd) = PhaseEnds();
var accelDistance = EntrySpeed * accelEnd + Acceleration * accelEnd * accelEnd / 2;
if (t < accelEnd)
return EntrySpeed * t + Acceleration * t * t / 2;
if (t < cruiseEnd)
return accelDistance + CruiseSpeed * (t - accelEnd);
var decel = t - cruiseEnd;
return accelDistance + CruiseSpeed * (cruiseEnd - accelEnd) + CruiseSpeed * decel - Acceleration * decel * decel / 2;
}
/// Unit direction of travel at the start (0) or end (1) of the step.
internal Vector TangentAt(double fraction)
{
if (IsArc)
{
var angle = StartAngle + Sweep * fraction;
var sign = System.Math.Sign(Sweep);
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
}
return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero;
}
/// The tool position after (0..1) of this step.
public Vector PointAt(double fraction)
{
fraction = System.Math.Clamp(fraction, 0, 1);
if (IsArc)
{
var angle = StartAngle + Sweep * fraction;
return new Vector(
Center.X + Radius * System.Math.Cos(angle),
Center.Y + Radius * System.Math.Sin(angle));
}
return new Vector(
Start.X + (End.X - Start.X) * fraction,
Start.Y + (End.Y - Start.Y) * fraction);
}
}
///
/// 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
{
private readonly List steps;
private readonly List contourStarts = new();
private readonly List partStarts = new();
private CutSimulation(List steps, int partCount)
{
this.steps = steps;
PartCount = partCount;
TotalTime = steps.Count == 0 ? TimeSpan.Zero : steps[^1].EndTime;
// A contour (a cutout or a part's perimeter) begins where the torch goes down after travel.
var lastPart = -1;
var lastContour = -1;
foreach (var step in steps)
{
if (step.ContourIndex < 0 || step.ContourIndex == lastContour)
continue;
lastContour = step.ContourIndex;
contourStarts.Add(step.StartTime);
if (step.PartIndex != lastPart)
{
partStarts.Add(step.StartTime);
lastPart = step.PartIndex;
}
}
}
public IReadOnlyList Steps => steps;
public int PartCount { get; }
public TimeSpan TotalTime { get; }
/// When each contour's first cut (or its pierce) starts, in cutting order.
public IReadOnlyList ContourStarts => contourStarts;
/// When each part's first contour starts, in cutting order.
public IReadOnlyList PartStarts => partStarts;
public TimeSpan NextContour(TimeSpan time) => Next(contourStarts, time);
public TimeSpan PreviousContour(TimeSpan time) => Previous(contourStarts, time);
public TimeSpan NextPart(TimeSpan time) => Next(partStarts, time);
public TimeSpan PreviousPart(TimeSpan time) => Previous(partStarts, time);
// The first start after the playhead, or the end once none is left.
private TimeSpan Next(List starts, TimeSpan time)
{
foreach (var start in starts)
{
if (start > time)
return start;
}
return TotalTime;
}
// Like a track's previous button: back to the start of the current item, or the one
// before when the playhead is already there.
private static TimeSpan Previous(List starts, TimeSpan time)
{
for (var i = starts.Count - 1; i >= 0; i--)
{
if (starts[i] < time)
return starts[i];
}
return TimeSpan.Zero;
}
public static CutSimulation Build(Plate plate, CutParameters parameters)
{
ArgumentNullException.ThrowIfNull(plate);
ArgumentNullException.ThrowIfNull(parameters);
var builder = new Builder();
for (var i = 0; i < plate.Parts.Count; i++)
{
var part = plate.Parts[i];
if (part.Program == null)
continue;
builder.PartIndex = i;
var mode = Mode.Absolute;
var pos = part.Location;
builder.AddProgram(part.Program, part.Location, ref mode, ref pos);
}
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
return new CutSimulation(steps, plate.Parts.Count);
}
///
/// Index of the step running at ; .Count
/// once the timeline has finished, -1 when it is empty.
///
public int IndexAt(TimeSpan time)
{
if (steps.Count == 0)
return -1;
if (time >= TotalTime)
return steps.Count;
int lo = 0, hi = steps.Count - 1;
while (lo < hi)
{
var mid = (lo + hi + 1) / 2;
if (steps[mid].StartTime <= time)
lo = mid;
else
hi = mid - 1;
}
return lo;
}
///
/// Fraction (0..1) of step completed at :
/// distance for motion, elapsed time for a pierce.
///
public double FractionAt(int index, TimeSpan time)
{
var step = steps[index];
if (step.Kind == CutSimulationStepKind.Pierce)
return step.Duration <= TimeSpan.Zero
? time >= step.StartTime ? 1 : 0
: System.Math.Clamp((time - step.StartTime) / step.Duration, 0, 1);
return step.ProgressAt(time - step.StartTime);
}
///
/// When step is complete: the inverse
/// of , so a retimed timeline can find the same tool position.
///
public TimeSpan TimeAt(int index, double fraction)
{
var step = steps[index];
if (step.Duration <= TimeSpan.Zero || fraction <= 0)
return step.StartTime;
if (fraction >= 1)
return step.EndTime;
// Progress never decreases through a step, so bisect its duration.
long lo = 0, hi = step.Duration.Ticks;
while (lo < hi)
{
var mid = lo + (hi - lo) / 2;
if (step.ProgressAt(TimeSpan.FromTicks(mid)) < fraction)
lo = mid + 1;
else
hi = mid;
}
return step.StartTime + TimeSpan.FromTicks(lo);
}
public Vector ToolPositionAt(TimeSpan time)
{
var index = IndexAt(time);
if (index < 0)
return Vector.Zero;
if (index >= steps.Count)
return steps[^1].End;
return steps[index].PointAt(FractionAt(index, time));
}
private sealed class Builder
{
private Vector tool = Vector.Zero;
private bool torchOn;
public List Steps { get; } = new();
public int PartIndex { get; set; }
// Sub-program offsets are relative to the calling frame's origin, as ConvertProgram reads them.
public void AddProgram(Program program, Vector frameOrigin, ref Mode mode, ref Vector pos)
{
mode = program.Mode;
for (var i = 0; i < program.Length; i++)
{
switch (program[i])
{
case SubProgramCall { Program: { } sub } call:
var savedMode = mode;
var subOrigin = frameOrigin + call.Offset;
pos = subOrigin;
AddProgram(sub, subOrigin, ref mode, ref pos);
mode = savedMode;
break;
case RapidMove rapid:
var rapidEnd = Resolve(rapid.EndPoint, mode, pos, frameOrigin);
AddTravel(tool, rapidEnd);
torchOn = false; // A rapid in place still ends the cut.
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);
pos = lineEnd;
break;
case ArcMove arc:
var arcEnd = Resolve(arc.EndPoint, mode, pos, frameOrigin);
var center = Resolve(arc.CenterPoint, mode, pos, frameOrigin);
AddMotion(arc.Layer, pos, arcEnd, isArc: true, center, arc.Rotation);
pos = arcEnd;
break;
}
}
}
private static Vector Resolve(Vector point, Mode mode, Vector pos, Vector frameOrigin) =>
mode == Mode.Incremental ? point + pos : point + frameOrigin;
private static CutSimulationStepKind? KindOf(LayerType layer) =>
layer switch
{
// 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, // Tab spans are never cut.
};
private void AddMotion(LayerType layer, Vector start, Vector end, bool isArc, Vector center, RotationType rotation)
{
if (KindOf(layer) is not { } kind)
{
AddTravel(tool, end);
return;
}
// A program that begins cutting away from the tool reaches its start head-up.
AddTravel(tool, start);
if (!torchOn && kind == CutSimulationStepKind.Cut)
{
Add(new CutSimulationStep
{
Kind = CutSimulationStepKind.Pierce,
PartIndex = PartIndex,
Start = start,
End = start,
});
}
torchOn = kind == CutSimulationStepKind.Cut;
if (isArc)
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),
});
}
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);
var endAngle = center.AngleTo(end);
var ccw = rotation == RotationType.CCW;
// Coincident ends are a full circle, as ConvertProgram reads them.
var sweep = ccw
? Angle.NormalizeRad(endAngle - startAngle)
: -Angle.NormalizeRad(startAngle - endAngle);
if (System.Math.Abs(sweep) < Tolerance.Epsilon)
sweep = ccw ? Angle.TwoPI : -Angle.TwoPI;
var length = System.Math.Abs(sweep) * radius;
Add(new CutSimulationStep
{
Kind = kind,
Layer = layer,
PartIndex = PartIndex,
Start = start,
End = end,
IsArc = true,
Center = center,
Radius = radius,
StartAngle = startAngle,
Sweep = sweep,
Length = length,
});
}
private void AddTravel(Vector start, Vector end)
{
var distance = start.DistanceTo(end);
if (distance <= Tolerance.Epsilon)
{
tool = end;
return;
}
torchOn = false;
Add(new CutSimulationStep
{
Kind = CutSimulationStepKind.Rapid,
PartIndex = PartIndex,
Start = start,
End = end,
Length = distance,
});
}
private void Add(CutSimulationStep step)
{
tool = step.End;
Steps.Add(step);
}
}
}
}