fix(ui): keep the cut simulator smooth while panning and zooming

The overlay re-stroked the whole finished trail on every paint: about
275 ms at zoom-to-fit on a 392-part plate and over 11 s zoomed in, where
dashed rapids spanned millions of on-screen dashes.

The finished trail is now cached in a bitmap at the current scale, so a
pan or playback frame blits it and draws only the moves since. A wheel
zoom stretches the cached image until the wheel has been still for
150 ms, then redraws it sharp. When the trail would be too large an
image, each paint draws only the moves on screen and clips straight
rapids to the view. Arcs are added as GDI+ arcs instead of 5-degree
polylines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 19:08:08 -04:00
1 parent bbe0f17a64
commit d1aa056b9b
2 files changed
+290 -53

No files matched your search

@@ -74,6 +74,30 @@ public class CutSimulationOverlayTests
Assert.Equal(Color.Black.ToArgb(), uncut.ToArgb());
});
[Fact]
public void ZoomedInTrailIsDrawnWherePannedTo() => RunSta(() =>
{
using var view = new PlateView { Plate = TwoSquares() };
var sim = CutSimulation.Build(view.Plate, Parameters);
view.CutSimulation.Load(sim);
view.CutSimulation.Time = sim.TotalTime;
view.ZoomToPoint(new Vector(10, 5), 100 / view.ViewScale, false); // trail far larger than the screen
using var bitmap = new Bitmap(400, 400);
using var graphics = Graphics.FromImage(bitmap);
foreach (var target in new[] { new Vector(10, 5), new Vector(35, 15) })
{
var point = view.PointWorldToGraph(target);
graphics.ResetTransform();
graphics.Clear(Color.Black);
graphics.TranslateTransform(200 - point.X, 200 - point.Y);
view.CutSimulation.Draw(graphics);
var pixel = bitmap.GetPixel(200, 200);
Assert.True(pixel.R > 200 && pixel.G < 160, $"trail missing at {target}: {pixel}");
}
});
[Fact]
public void PlateSwitchClearsTheSimulation() => RunSta(() =>
{
+266 -53
View File
@@ -3,9 +3,11 @@ 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;
@@ -18,22 +20,38 @@ 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;
// Finished steps, cached in graph coordinates for the scale they were built at.
private GraphicsPath cutTrail, scribeTrail, rapidTrail;
private int trailCount;
// 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>
@@ -146,17 +164,10 @@ internal sealed class CutSimulationOverlay : IDisposable
graphics.SmoothingMode = SmoothingMode.AntiAlias;
var dpi = view.DeviceDpi / 96f;
var index = simulation.IndexAt(time);
UpdateTrail(index);
var finished = System.Math.Clamp(index, 0, simulation.Steps.Count);
using var pens = new TrailPens(dpi);
using var rapidPen = new Pen(Color.FromArgb(150, 70, 150, 255), 1 * dpi) { DashStyle = DashStyle.Dash };
using var cutGlow = new Pen(Color.FromArgb(70, 255, 140, 0), 6 * dpi) { LineJoin = LineJoin.Round };
using var cutPen = new Pen(Color.OrangeRed, 2 * dpi) { LineJoin = LineJoin.Round };
using var scribePen = new Pen(Color.MediumOrchid, 1.5f * dpi);
graphics.DrawPath(rapidPen, rapidTrail);
graphics.DrawPath(cutGlow, cutTrail);
graphics.DrawPath(cutPen, cutTrail);
graphics.DrawPath(scribePen, scribeTrail);
DrawTrail(graphics, pens, finished);
if (index >= simulation.Steps.Count)
{
@@ -172,14 +183,14 @@ internal sealed class CutSimulationOverlay : IDisposable
switch (step.Kind)
{
case CutSimulationStepKind.Rapid:
graphics.DrawLines(rapidPen, partial);
graphics.DrawLines(pens.Rapid, partial);
break;
case CutSimulationStepKind.Scribe:
graphics.DrawLines(scribePen, partial);
graphics.DrawLines(pens.Scribe, partial);
break;
case CutSimulationStepKind.Cut:
graphics.DrawLines(cutGlow, partial);
graphics.DrawLines(cutPen, partial);
graphics.DrawLines(pens.Glow, partial);
graphics.DrawLines(pens.Cut, partial);
break;
}
}
@@ -192,6 +203,225 @@ internal sealed class CutSimulationOverlay : IDisposable
}
}
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);
@@ -225,38 +455,6 @@ internal sealed class CutSimulationOverlay : IDisposable
graphics.DrawLine(pen, center.X, center.Y - radius * 1.8f, center.X, center.Y + radius * 1.8f);
}
private void UpdateTrail(int index)
{
var finished = System.Math.Clamp(index, 0, simulation.Steps.Count);
if (cutTrail == null || trailScale != view.ViewScale || finished < trailCount)
{
ResetTrail();
cutTrail = new GraphicsPath();
scribeTrail = new GraphicsPath();
rapidTrail = new GraphicsPath();
trailScale = view.ViewScale;
}
for (; trailCount < finished; trailCount++)
{
var step = simulation.Steps[trailCount];
var path = step.Kind switch
{
CutSimulationStepKind.Cut => cutTrail,
CutSimulationStepKind.Scribe => scribeTrail,
CutSimulationStepKind.Rapid => rapidTrail,
_ => null,
};
if (path == null)
continue;
var points = Points(step, 1);
if (points.Length < 2)
continue;
path.StartFigure();
path.AddLines(points);
}
}
private PointF[] Points(CutSimulationStep step, double fraction)
{
if (!step.IsArc)
@@ -271,16 +469,31 @@ internal sealed class CutSimulationOverlay : IDisposable
private void ResetTrail()
{
cutTrail?.Dispose();
scribeTrail?.Dispose();
rapidTrail?.Dispose();
cutTrail = scribeTrail = rapidTrail = null;
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();
}
}
}