mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 08:21:05 -04:00
500 lines
18 KiB
C#
500 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Drawing;
|
|
using System.Drawing.Drawing2D;
|
|
using System.Drawing.Imaging;
|
|
using System.Windows.Forms;
|
|
using OpenNest.CNC;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Math;
|
|
|
|
namespace OpenNest.Controls;
|
|
|
|
/// <summary>
|
|
/// Plays a <see cref="CutSimulation"/> over the plate: the cut trail grows behind the torch,
|
|
/// travel is traced faintly and parts the torch has not reached yet are drawn muted.
|
|
/// Display only; it never changes the plate.
|
|
/// </summary>
|
|
internal sealed class CutSimulationOverlay : IDisposable
|
|
{
|
|
private const double ArcStepRadians = System.Math.PI / 36;
|
|
|
|
// Wheel zooms stretch the cached trail until the wheel has been still this long.
|
|
private static readonly TimeSpan ZoomSettle = TimeSpan.FromMilliseconds(150);
|
|
|
|
private readonly PlateView view;
|
|
private readonly Timer timer;
|
|
private readonly Timer settleTimer;
|
|
private readonly Stopwatch clock = new();
|
|
private readonly Stopwatch sinceZoom = new();
|
|
private CutSimulation simulation;
|
|
private TimeSpan time;
|
|
|
|
// The finished trail, rendered once at the scale it was built for, so panning and playback
|
|
// only blit it and draw the moves since. Null when the whole timeline would be too large an
|
|
// image (zoomed in), where each paint draws only the moves on screen.
|
|
private Bitmap trail;
|
|
private RectangleF trailRect;
|
|
private float trailScale;
|
|
private PointF trailPhase;
|
|
private int trailCount;
|
|
private float seenScale;
|
|
|
|
public CutSimulationOverlay(PlateView view)
|
|
{
|
|
this.view = view;
|
|
timer = new Timer { Interval = 30 };
|
|
timer.Tick += (_, _) => Tick();
|
|
settleTimer = new Timer { Interval = (int)ZoomSettle.TotalMilliseconds + 10 };
|
|
settleTimer.Tick += (_, _) =>
|
|
{
|
|
settleTimer.Stop();
|
|
view.Invalidate();
|
|
};
|
|
}
|
|
|
|
/// <summary>Raised when the playhead, play state or timeline changes.</summary>
|
|
public event EventHandler Changed;
|
|
|
|
public CutSimulation Simulation => simulation;
|
|
public bool IsActive => simulation != null;
|
|
public bool IsPlaying => timer.Enabled;
|
|
public double Speed { get; set; } = 10;
|
|
|
|
public TimeSpan Time
|
|
{
|
|
get => time;
|
|
set
|
|
{
|
|
if (simulation == null)
|
|
return;
|
|
time = value < TimeSpan.Zero ? TimeSpan.Zero : value > simulation.TotalTime ? simulation.TotalTime : value;
|
|
view.Invalidate();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
|
|
/// <summary>Shows <paramref name="value"/>, keeping the playhead where it was when possible.</summary>
|
|
public void Load(CutSimulation value)
|
|
{
|
|
simulation = value;
|
|
ResetTrail();
|
|
Time = time;
|
|
if (simulation == null)
|
|
Pause();
|
|
view.Invalidate();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void Play()
|
|
{
|
|
if (simulation == null || simulation.Steps.Count == 0)
|
|
return;
|
|
if (time >= simulation.TotalTime)
|
|
Time = TimeSpan.Zero;
|
|
clock.Restart();
|
|
timer.Start();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void Pause()
|
|
{
|
|
if (!timer.Enabled)
|
|
return;
|
|
timer.Stop();
|
|
clock.Reset();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
timer.Stop();
|
|
simulation = null;
|
|
time = TimeSpan.Zero;
|
|
ResetTrail();
|
|
view.Invalidate();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
private void Tick()
|
|
{
|
|
var elapsed = clock.Elapsed;
|
|
clock.Restart();
|
|
Time = time + TimeSpan.FromTicks((long)(elapsed.Ticks * Speed));
|
|
if (simulation == null || time >= simulation.TotalTime)
|
|
Pause();
|
|
}
|
|
|
|
/// <summary>True for a part the torch has not started yet.</summary>
|
|
public bool IsPartPending(int partIndex)
|
|
{
|
|
if (simulation == null)
|
|
return false;
|
|
var index = simulation.IndexAt(time);
|
|
if (index >= simulation.Steps.Count)
|
|
return false;
|
|
if (index < 0)
|
|
return true;
|
|
var current = simulation.Steps[index];
|
|
// Travel toward a part has not touched it yet.
|
|
return partIndex > current.PartIndex
|
|
|| partIndex == current.PartIndex && current.Kind == CutSimulationStepKind.Rapid && !PartStarted(index);
|
|
}
|
|
|
|
private bool PartStarted(int index)
|
|
{
|
|
var part = simulation.Steps[index].PartIndex;
|
|
for (var i = index - 1; i >= 0 && simulation.Steps[i].PartIndex == part; i--)
|
|
{
|
|
if (simulation.Steps[i].Kind != CutSimulationStepKind.Rapid)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public void Draw(Graphics graphics)
|
|
{
|
|
if (simulation == null || simulation.Steps.Count == 0)
|
|
return;
|
|
|
|
var state = graphics.Save();
|
|
try
|
|
{
|
|
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
|
var dpi = view.DeviceDpi / 96f;
|
|
var index = simulation.IndexAt(time);
|
|
var finished = System.Math.Clamp(index, 0, simulation.Steps.Count);
|
|
using var pens = new TrailPens(dpi);
|
|
|
|
DrawTrail(graphics, pens, finished);
|
|
|
|
if (index >= simulation.Steps.Count)
|
|
{
|
|
DrawHead(graphics, simulation.Steps[^1].End, null, 0, dpi);
|
|
return;
|
|
}
|
|
|
|
var step = simulation.Steps[index];
|
|
var fraction = simulation.FractionAt(index, time);
|
|
var partial = Points(step, fraction);
|
|
if (partial.Length > 1)
|
|
{
|
|
switch (step.Kind)
|
|
{
|
|
case CutSimulationStepKind.Rapid:
|
|
graphics.DrawLines(pens.Rapid, partial);
|
|
break;
|
|
case CutSimulationStepKind.Scribe:
|
|
graphics.DrawLines(pens.Scribe, partial);
|
|
break;
|
|
case CutSimulationStepKind.Cut:
|
|
graphics.DrawLines(pens.Glow, partial);
|
|
graphics.DrawLines(pens.Cut, partial);
|
|
break;
|
|
}
|
|
}
|
|
|
|
DrawHead(graphics, step.PointAt(fraction), step.Kind, fraction, dpi);
|
|
}
|
|
finally
|
|
{
|
|
graphics.Restore(state);
|
|
}
|
|
}
|
|
|
|
private void DrawTrail(Graphics graphics, TrailPens pens, int finished)
|
|
{
|
|
var scale = view.ViewScale;
|
|
if (scale != seenScale)
|
|
{
|
|
seenScale = scale;
|
|
sinceZoom.Restart();
|
|
settleTimer.Stop();
|
|
settleTimer.Start();
|
|
}
|
|
|
|
var visible = graphics.VisibleClipBounds;
|
|
visible.Inflate(pens.Glow.Width, pens.Glow.Width);
|
|
|
|
// Mid-zoom: stretch the old image rather than re-render on every wheel notch.
|
|
if (trail != null && trailScale != scale && sinceZoom.Elapsed < ZoomSettle && finished >= trailCount)
|
|
{
|
|
var k = scale / trailScale;
|
|
graphics.InterpolationMode = InterpolationMode.Bilinear;
|
|
graphics.DrawImage(trail, new RectangleF(trailRect.X * k, trailRect.Y * k, trailRect.Width * k, trailRect.Height * k));
|
|
DrawSteps(graphics, pens, trailCount, finished, visible);
|
|
return;
|
|
}
|
|
|
|
// Keep image pixels on device pixels so the blit is exact.
|
|
PointF phase;
|
|
using (var transform = graphics.Transform)
|
|
phase = new PointF(Fraction(transform.OffsetX), Fraction(transform.OffsetY));
|
|
|
|
if (trail == null || trailScale != scale || phase != trailPhase || finished < trailCount)
|
|
RebuildTrail(scale, phase);
|
|
|
|
if (trail == null)
|
|
{
|
|
DrawSteps(graphics, pens, 0, finished, visible);
|
|
return;
|
|
}
|
|
|
|
if (finished > trailCount)
|
|
{
|
|
using var image = Graphics.FromImage(trail);
|
|
image.SmoothingMode = SmoothingMode.AntiAlias;
|
|
image.TranslateTransform(-trailRect.X, -trailRect.Y);
|
|
DrawSteps(image, pens, trailCount, finished, null);
|
|
trailCount = finished;
|
|
}
|
|
|
|
graphics.InterpolationMode = InterpolationMode.NearestNeighbor;
|
|
graphics.PixelOffsetMode = PixelOffsetMode.Half;
|
|
graphics.DrawImage(trail, trailRect, new RectangleF(0, 0, trail.Width, trail.Height), GraphicsUnit.Pixel);
|
|
}
|
|
|
|
private static float Fraction(float value) => value - (float)System.Math.Floor(value);
|
|
|
|
private void RebuildTrail(float scale, PointF phase)
|
|
{
|
|
ResetTrail();
|
|
trailScale = scale;
|
|
trailPhase = phase;
|
|
|
|
var bounds = RectangleF.Empty;
|
|
foreach (var step in simulation.Steps)
|
|
{
|
|
if (step.Kind != CutSimulationStepKind.Pierce)
|
|
bounds = bounds.IsEmpty ? StepBounds(step) : RectangleF.Union(bounds, StepBounds(step));
|
|
}
|
|
if (bounds.IsEmpty)
|
|
return;
|
|
|
|
// The view's pens are a few pixels wide; pad so the glow is not cut off at the edge.
|
|
var pad = 12 * view.DeviceDpi / 96f;
|
|
var x = (float)System.Math.Floor(bounds.Left - pad) - phase.X;
|
|
var y = (float)System.Math.Floor(bounds.Top - pad) - phase.Y;
|
|
var width = (int)System.Math.Ceiling(bounds.Right + pad - x);
|
|
var height = (int)System.Math.Ceiling(bounds.Bottom + pad - y);
|
|
var budget = System.Math.Max(2L * view.Width * view.Height, 2_000_000L);
|
|
if (width <= 0 || height <= 0 || (long)width * height > budget)
|
|
return;
|
|
|
|
trail = new Bitmap(width, height, PixelFormat.Format32bppPArgb);
|
|
trailRect = new RectangleF(x, y, width, height);
|
|
}
|
|
|
|
// Draws finished steps [from, to). With a clip, steps off it are skipped and straight
|
|
// rapids are trimmed to it: a dashed pen's cost grows with the length it strokes.
|
|
private void DrawSteps(Graphics graphics, TrailPens pens, int from, int to, RectangleF? clip)
|
|
{
|
|
using var cuts = new GraphicsPath();
|
|
using var scribes = new GraphicsPath();
|
|
using var travel = new GraphicsPath();
|
|
int lastCut = -2, lastScribe = -2, lastTravel = -2;
|
|
var smoothing = graphics.SmoothingMode;
|
|
graphics.SmoothingMode = SmoothingMode.HighSpeed;
|
|
|
|
for (var i = from; i < to; i++)
|
|
{
|
|
var step = simulation.Steps[i];
|
|
if (step.Kind == CutSimulationStepKind.Pierce)
|
|
continue;
|
|
|
|
if (step.Kind == CutSimulationStepKind.Rapid && !step.IsArc)
|
|
{
|
|
var a = view.PointWorldToGraph(step.Start);
|
|
var b = view.PointWorldToGraph(step.End);
|
|
var skipped = 0f;
|
|
if (clip is { } rect && !ClipLine(ref a, ref b, rect, out skipped))
|
|
continue;
|
|
// Keep the dash pattern fixed to the line while it pans; DashStyle.Dash repeats every 4 widths.
|
|
pens.Rapid.DashOffset = skipped / pens.Rapid.Width % 4;
|
|
graphics.DrawLine(pens.Rapid, a, b);
|
|
continue;
|
|
}
|
|
|
|
if (clip is { } visible && !StepBounds(step).IntersectsWith(visible))
|
|
continue;
|
|
|
|
switch (step.Kind)
|
|
{
|
|
case CutSimulationStepKind.Cut:
|
|
AddStep(cuts, step, i, ref lastCut);
|
|
break;
|
|
case CutSimulationStepKind.Scribe:
|
|
AddStep(scribes, step, i, ref lastScribe);
|
|
break;
|
|
default:
|
|
AddStep(travel, step, i, ref lastTravel);
|
|
break;
|
|
}
|
|
}
|
|
|
|
pens.Rapid.DashOffset = 0;
|
|
graphics.SmoothingMode = smoothing;
|
|
if (travel.PointCount > 0)
|
|
graphics.DrawPath(pens.Rapid, travel);
|
|
if (cuts.PointCount > 0)
|
|
{
|
|
graphics.DrawPath(pens.Glow, cuts);
|
|
graphics.DrawPath(pens.Cut, cuts);
|
|
}
|
|
if (scribes.PointCount > 0)
|
|
graphics.DrawPath(pens.Scribe, scribes);
|
|
}
|
|
|
|
// Consecutive moves join into one figure; arcs go in as GDI+ arcs, which flatten to the size
|
|
// they are drawn at.
|
|
private void AddStep(GraphicsPath path, CutSimulationStep step, int index, ref int last)
|
|
{
|
|
if (last != index - 1)
|
|
path.StartFigure();
|
|
last = index;
|
|
|
|
if (step.IsArc)
|
|
{
|
|
var center = view.PointWorldToGraph(step.Center);
|
|
var radius = view.LengthWorldToGui(step.Radius);
|
|
if (radius > 0.01f)
|
|
{
|
|
// Graph y points down, so world angles flip sign.
|
|
path.AddArc(center.X - radius, center.Y - radius, radius * 2, radius * 2,
|
|
(float)-Angle.ToDegrees(step.StartAngle), (float)-Angle.ToDegrees(step.Sweep));
|
|
return;
|
|
}
|
|
}
|
|
path.AddLine(view.PointWorldToGraph(step.Start), view.PointWorldToGraph(step.End));
|
|
}
|
|
|
|
// Graph-space bounds; an arc's whole circle stands in for the arc.
|
|
private RectangleF StepBounds(CutSimulationStep step)
|
|
{
|
|
if (step.IsArc)
|
|
{
|
|
var center = view.PointWorldToGraph(step.Center);
|
|
var radius = view.LengthWorldToGui(step.Radius);
|
|
return new RectangleF(center.X - radius, center.Y - radius, radius * 2, radius * 2);
|
|
}
|
|
var a = view.PointWorldToGraph(step.Start);
|
|
var b = view.PointWorldToGraph(step.End);
|
|
return RectangleF.FromLTRB(System.Math.Min(a.X, b.X), System.Math.Min(a.Y, b.Y), System.Math.Max(a.X, b.X), System.Math.Max(a.Y, b.Y));
|
|
}
|
|
|
|
// Liang-Barsky: trims a..b to the rectangle; skipped is the distance trimmed from a.
|
|
private static bool ClipLine(ref PointF a, ref PointF b, RectangleF rect, out float skipped)
|
|
{
|
|
skipped = 0;
|
|
float dx = b.X - a.X, dy = b.Y - a.Y;
|
|
float t0 = 0, t1 = 1;
|
|
if (!Clip(-dx, a.X - rect.Left, ref t0, ref t1) || !Clip(dx, rect.Right - a.X, ref t0, ref t1)
|
|
|| !Clip(-dy, a.Y - rect.Top, ref t0, ref t1) || !Clip(dy, rect.Bottom - a.Y, ref t0, ref t1))
|
|
return false;
|
|
|
|
var start = a;
|
|
a = new PointF(start.X + t0 * dx, start.Y + t0 * dy);
|
|
b = new PointF(start.X + t1 * dx, start.Y + t1 * dy);
|
|
skipped = t0 * (float)System.Math.Sqrt(dx * dx + dy * dy);
|
|
return true;
|
|
|
|
static bool Clip(float p, float q, ref float t0, ref float t1)
|
|
{
|
|
if (p == 0)
|
|
return q >= 0;
|
|
var t = q / p;
|
|
if (p < 0)
|
|
{
|
|
if (t > t1)
|
|
return false;
|
|
if (t > t0)
|
|
t0 = t;
|
|
}
|
|
else
|
|
{
|
|
if (t < t0)
|
|
return false;
|
|
if (t < t1)
|
|
t1 = t;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private void DrawHead(Graphics graphics, Vector location, CutSimulationStepKind? kind, double fraction, float dpi)
|
|
{
|
|
var center = view.PointWorldToGraph(location);
|
|
var color = kind switch
|
|
{
|
|
CutSimulationStepKind.Cut or CutSimulationStepKind.Pierce => Color.OrangeRed,
|
|
CutSimulationStepKind.Scribe => Color.MediumOrchid,
|
|
_ => Color.DodgerBlue,
|
|
};
|
|
|
|
if (kind == CutSimulationStepKind.Pierce)
|
|
{
|
|
var ring = (4 + 14 * (float)fraction) * dpi;
|
|
using var piercePen = new Pen(Color.FromArgb((int)(255 * (1 - fraction)), Color.Gold), 2 * dpi);
|
|
graphics.DrawEllipse(piercePen, center.X - ring, center.Y - ring, ring * 2, ring * 2);
|
|
}
|
|
|
|
if (kind is CutSimulationStepKind.Cut or CutSimulationStepKind.Pierce)
|
|
{
|
|
var glow = 9 * dpi;
|
|
using var glowBrush = new SolidBrush(Color.FromArgb(90, Color.Orange));
|
|
graphics.FillEllipse(glowBrush, center.X - glow, center.Y - glow, glow * 2, glow * 2);
|
|
}
|
|
|
|
var radius = 5 * dpi;
|
|
using var halo = new Pen(Color.White, 3 * dpi);
|
|
using var pen = new Pen(color, 1.5f * dpi);
|
|
graphics.DrawEllipse(halo, center.X - radius, center.Y - radius, radius * 2, radius * 2);
|
|
graphics.DrawEllipse(pen, center.X - radius, center.Y - radius, radius * 2, radius * 2);
|
|
graphics.DrawLine(pen, center.X - radius * 1.8f, center.Y, center.X + radius * 1.8f, center.Y);
|
|
graphics.DrawLine(pen, center.X, center.Y - radius * 1.8f, center.X, center.Y + radius * 1.8f);
|
|
}
|
|
|
|
private PointF[] Points(CutSimulationStep step, double fraction)
|
|
{
|
|
if (!step.IsArc)
|
|
return new[] { view.PointWorldToGraph(step.Start), view.PointWorldToGraph(step.PointAt(fraction)) };
|
|
|
|
var segments = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Abs(step.Sweep * fraction) / ArcStepRadians));
|
|
var points = new List<PointF>(segments + 1);
|
|
for (var i = 0; i <= segments; i++)
|
|
points.Add(view.PointWorldToGraph(step.PointAt(fraction * i / segments)));
|
|
return points.ToArray();
|
|
}
|
|
|
|
private void ResetTrail()
|
|
{
|
|
trail?.Dispose();
|
|
trail = null;
|
|
trailCount = 0;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
timer.Dispose();
|
|
settleTimer.Dispose();
|
|
ResetTrail();
|
|
}
|
|
|
|
private sealed class TrailPens(float dpi) : IDisposable
|
|
{
|
|
public Pen Rapid { get; } = new(Color.FromArgb(150, 70, 150, 255), 1 * dpi) { DashStyle = DashStyle.Dash };
|
|
public Pen Glow { get; } = new(Color.FromArgb(70, 255, 140, 0), 6 * dpi) { LineJoin = LineJoin.Round };
|
|
public Pen Cut { get; } = new(Color.OrangeRed, 2 * dpi) { LineJoin = LineJoin.Round };
|
|
public Pen Scribe { get; } = new(Color.MediumOrchid, 1.5f * dpi);
|
|
|
|
public void Dispose()
|
|
{
|
|
Rapid.Dispose();
|
|
Glow.Dispose();
|
|
Cut.Dispose();
|
|
Scribe.Dispose();
|
|
}
|
|
}
|
|
}
|