diff --git a/OpenNest.WinForms.Tests/Controls/LayoutPartViewCacheTests.cs b/OpenNest.WinForms.Tests/Controls/LayoutPartViewCacheTests.cs
new file mode 100644
index 00000000..5d3c3a58
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Controls/LayoutPartViewCacheTests.cs
@@ -0,0 +1,96 @@
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using OpenNest.CNC;
+using OpenNest.Controls;
+using OpenNest.Geometry;
+using Program = OpenNest.CNC.Program;
+
+namespace OpenNest.WinForms.Tests.Controls;
+
+///
+/// A zoom or a move maps a part's cached program-frame paths to the view instead of rebuilding
+/// them; the result must match a part built from scratch in the same state.
+///
+public class LayoutPartViewCacheTests
+{
+ [Fact]
+ public void ZoomMoveAndRotateMatchAFreshBuild() => StaTestThread.Run(() =>
+ {
+ var part = new Part(new Drawing("Holes", PlateWithHoles()), new Vector(5, 7));
+ using var view = new PlateView(ColorSchemeRegistry.Get("Workshop"));
+ view.Matrix = new Matrix(2, 0, 0, -2, 0, 0);
+ var layout = LayoutPart.Create(part, view);
+ AssertMatchesFreshBuild(layout, view);
+
+ view.Matrix = new Matrix(3.5f, 0, 0, -3.5f, 0, 0);
+ layout.UpdateView(view);
+ AssertMatchesFreshBuild(layout, view);
+
+ layout.Offset(12.25, -3.5);
+ Assert.True(layout.IsDirty);
+ layout.UpdateView(view);
+ Assert.False(layout.IsDirty);
+ AssertMatchesFreshBuild(layout, view);
+
+ // A rotation of the base part, then a move through the layout, must not reuse stale paths.
+ part.Rotate(OpenNest.Math.Angle.ToRadians(30), part.BoundingBox.Center);
+ layout.Offset(1, 1);
+ layout.UpdateView(view);
+ AssertMatchesFreshBuild(layout, view);
+ }, TimeSpan.FromMinutes(1), "Layout part view cache test timed out.");
+
+ [Fact]
+ public void DirtyRebuildsAfterInPlaceProgramEdit() => StaTestThread.Run(() =>
+ {
+ var part = new Part(new Drawing("Edited", PlateWithHoles()));
+ using var view = new PlateView(ColorSchemeRegistry.Get("Workshop"));
+ view.Matrix = new Matrix(2, 0, 0, -2, 0, 0);
+ var layout = LayoutPart.Create(part, view);
+ var before = layout.Path.PointCount;
+
+ part.Program.MoveTo(5, 25);
+ part.Program.LineTo(10, 25);
+ layout.IsDirty = true;
+ layout.UpdateView(view);
+
+ Assert.True(layout.Path.PointCount > before);
+ AssertMatchesFreshBuild(layout, view);
+ }, TimeSpan.FromMinutes(1), "Layout part rebuild test timed out.");
+
+ private static void AssertMatchesFreshBuild(LayoutPart layout, PlateView view)
+ {
+ var fresh = LayoutPart.Create(layout.BasePart, view);
+ Assert.Equal(fresh.Path.PathTypes, layout.Path.PathTypes);
+ var expected = fresh.Path.PathPoints;
+ var actual = layout.Path.PathPoints;
+ Assert.Equal(expected.Length, actual.Length);
+ for (var i = 0; i < expected.Length; i++)
+ {
+ Assert.Equal(expected[i].X, actual[i].X, 2);
+ Assert.Equal(expected[i].Y, actual[i].Y, 2);
+ }
+ Assert.Equal(fresh.DisplayBounds.X, layout.DisplayBounds.X, 2);
+ Assert.Equal(fresh.DisplayBounds.Y, layout.DisplayBounds.Y, 2);
+ Assert.Equal(fresh.DisplayBounds.Width, layout.DisplayBounds.Width, 2);
+ Assert.Equal(fresh.DisplayBounds.Height, layout.DisplayBounds.Height, 2);
+ fresh.ReleaseDisplayResources();
+ }
+
+ private static Program PlateWithHoles()
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(40, 0);
+ program.LineTo(40, 30);
+ program.LineTo(0, 30);
+ program.LineTo(0, 0);
+ for (var i = 0; i < 6; i++)
+ {
+ var center = new Vector(5 + i * 6, 15);
+ var start = new Vector(center.X + 2, center.Y);
+ program.MoveTo(start);
+ program.ArcTo(start, center, RotationType.CCW);
+ }
+ return program;
+ }
+}
diff --git a/OpenNest/Actions/ActionClone.cs b/OpenNest/Actions/ActionClone.cs
index c098c8b4..6a5f4bca 100644
--- a/OpenNest/Actions/ActionClone.cs
+++ b/OpenNest/Actions/ActionClone.cs
@@ -88,7 +88,7 @@ namespace OpenNest.Actions
{
parts.ForEach(p =>
{
- p.Update(plateView);
+ p.UpdateView(plateView);
p.Draw(e.Graphics);
});
}
@@ -97,7 +97,7 @@ namespace OpenNest.Actions
parts.ForEach(p =>
{
if (p.IsDirty)
- p.Update(plateView);
+ p.UpdateView(plateView);
p.Draw(e.Graphics);
});
diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs
index bd26100c..c240a83d 100644
--- a/OpenNest/Controls/PlateRenderer.cs
+++ b/OpenNest/Controls/PlateRenderer.cs
@@ -116,7 +116,7 @@ namespace OpenNest.Controls
var part = layoutParts[i];
if (part.IsDirty)
- part.Update(view);
+ part.UpdateView(view);
if (!part.DisplayBounds.IntersectsWith(viewBounds))
continue;
@@ -142,7 +142,7 @@ namespace OpenNest.Controls
var part = previewParts[i];
if (part.IsDirty)
- part.Update(view);
+ part.UpdateView(view);
var path = part.Path;
if (!part.DisplayBounds.IntersectsWith(viewBounds))
@@ -554,7 +554,7 @@ namespace OpenNest.Controls
var layoutPart = layoutParts[i];
if (layoutPart.IsDirty)
- layoutPart.Update(view);
+ layoutPart.UpdateView(view);
layoutPart.UpdateOffset(view.DisplayPlate.PartSpacing, view.OffsetTolerance, view.Matrix);
diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs
index 1c66e844..ddec1fa1 100644
--- a/OpenNest/Controls/PlateView.cs
+++ b/OpenNest/Controls/PlateView.cs
@@ -1119,9 +1119,10 @@ namespace OpenNest.Controls
{
ClearHover();
base.UpdateMatrix();
- parts.ForEach(p => p.Update(this));
- displayParts?.ForEach(p => p.Update(this));
- previewManager.Update();
+ // Geometry is unchanged: map the cached paths to the new scale, not rebuild them.
+ parts.ForEach(p => p.UpdateView(this));
+ displayParts?.ForEach(p => p.UpdateView(this));
+ previewManager.UpdateView();
}
}
}
diff --git a/OpenNest/Controls/PreviewManager.cs b/OpenNest/Controls/PreviewManager.cs
index 26e8acaa..eea12191 100644
--- a/OpenNest/Controls/PreviewManager.cs
+++ b/OpenNest/Controls/PreviewManager.cs
@@ -54,6 +54,11 @@ namespace OpenNest.Controls
activeParts.ForEach(p => p.Update(view));
}
+ public void UpdateView()
+ {
+ activeParts.ForEach(p => p.UpdateView(view));
+ }
+
public void Clear()
{
activeParts.Clear();
diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs
index 9df6dd7f..887deeb2 100644
--- a/OpenNest/LayoutPart.cs
+++ b/OpenNest/LayoutPart.cs
@@ -20,6 +20,7 @@ namespace OpenNest
[ThreadStatic] private static Pen selectedPen;
[ThreadStatic] private static Brush selectedBrush;
[ThreadStatic] private static Pen leadInPen;
+ [ThreadStatic] private static StringFormat labelFormat;
private Color color;
private Color? colorOverride;
@@ -35,6 +36,20 @@ namespace OpenNest
private Vector? _labelPoint;
private PointF _labelScreenPoint;
+ // Paths in the drawing's program frame, before placement. A zoom or a move only maps
+ // these to the screen again; rebuilding them from the program dominates for parts with
+ // hundreds of holes. They are rebuilt when the program, its rotation or its lead-ins change.
+ private GraphicsPath localCut;
+ private GraphicsPath localLead;
+ private GraphicsPath localEtch;
+ private GraphicsPath localFill;
+ private GraphicsPath localDisplay;
+ private CNC.Program localProgram;
+ private double localRotation;
+ private bool localManualLeadIns;
+ private bool needsRebuild;
+ private bool moved;
+
public readonly Part BasePart;
private LayoutPart(Part part)
@@ -47,7 +62,16 @@ namespace OpenNest
SetDisplayColor(part.BaseDrawing.Color);
}
- internal bool IsDirty { get; set; }
+ /// Setting it rebuilds the paths from the program on the next update.
+ internal bool IsDirty
+ {
+ get => needsRebuild || moved;
+ set
+ {
+ needsRebuild = value;
+ moved = false;
+ }
+ }
public bool IsSelected { get; set; }
@@ -119,18 +143,13 @@ namespace OpenNest
{
Draw(g, muted);
- using var sf = new StringFormat
- {
- Alignment = StringAlignment.Center,
- LineAlignment = StringAlignment.Center,
- };
g.DrawString(
id,
ProgramIdFont,
muted ? Brushes.DimGray : Brushes.Black,
_labelScreenPoint.X,
_labelScreenPoint.Y,
- sf
+ LabelFormat
);
}
@@ -152,6 +171,22 @@ namespace OpenNest
DisplayPath = null;
OffsetPath?.Dispose();
OffsetPath = null;
+ ReleaseLocalPaths();
+ }
+
+ private void ReleaseLocalPaths()
+ {
+ localCut?.Dispose();
+ localCut = null;
+ localLead?.Dispose();
+ localLead = null;
+ localEtch?.Dispose();
+ localEtch = null;
+ localFill?.Dispose();
+ localFill = null;
+ localDisplay?.Dispose();
+ localDisplay = null;
+ localProgram = null;
}
public GraphicsPath OffsetPath { get; private set; }
@@ -188,7 +223,7 @@ namespace OpenNest
// Display-only entities are kept in the drawing's program frame before rotation, so they
// follow the part by its cumulative rotation about the program origin, then its location.
- private GraphicsPath BuildDisplayPath(Matrix matrix)
+ private GraphicsPath BuildDisplayPath()
{
var entities = EntityRoles.DisplayGeometry(BasePart.BaseDrawing);
if (entities.Count == 0)
@@ -198,11 +233,9 @@ namespace OpenNest
foreach (var entity in entities)
{
entity.Rotate(BasePart.Rotation);
- entity.Offset(BasePart.Location);
using var figure = new Shape { Entities = new List { entity } }.GetGraphicsPath();
path.AddPath(figure, false);
}
- path.Transform(matrix);
return path;
}
@@ -237,33 +270,79 @@ namespace OpenNest
return PolyLabel.Find(outer, holes, PolyLabel.ThicknessPrecision(outer, holes));
}
+ /// Rebuilds the part's paths from its program and maps them to the view.
public void Update(DrawControl plateView)
+ {
+ BuildLocalPaths();
+ ApplyView(plateView);
+ }
+
+ ///
+ /// Maps the part to the view, rebuilding its paths only when was set
+ /// or its program, rotation or lead-ins changed. Use after a zoom or a move.
+ ///
+ internal void UpdateView(DrawControl plateView)
+ {
+ if (needsRebuild
+ || localCut == null
+ || !ReferenceEquals(localProgram, BasePart.Program)
+ || localRotation != BasePart.Rotation
+ || localManualLeadIns != BasePart.HasManualLeadIns)
+ BuildLocalPaths();
+
+ ApplyView(plateView);
+ }
+
+ private void BuildLocalPaths()
+ {
+ ReleaseLocalPaths();
+ var program = BasePart.Program;
+ program.GetDisplayPaths(Vector.Zero,
+ out var cutPath, out var leadPath, out var etchPath, out var hasOpenCuts);
+ localCut = cutPath;
+ localEtch = etchPath;
+ // Only programs with break cuts or open (tabbed) cuts need a fill apart from the outline path.
+ if (hasOpenCuts || HasBreakCuts(program))
+ localFill = program.GetFillPath(Vector.Zero);
+ localLead = BasePart.HasManualLeadIns ? leadPath : null;
+ if (!BasePart.HasManualLeadIns)
+ leadPath.Dispose();
+ localDisplay = BuildDisplayPath();
+
+ localProgram = program;
+ localRotation = BasePart.Rotation;
+ localManualLeadIns = BasePart.HasManualLeadIns;
+ needsRebuild = false;
+ }
+
+ private static GraphicsPath Transformed(GraphicsPath path, Matrix matrix)
+ {
+ if (path == null)
+ return null;
+ var result = (GraphicsPath)path.Clone();
+ result.Transform(matrix);
+ return result;
+ }
+
+ private void ApplyView(DrawControl plateView)
{
colorScheme = (plateView as PlateView)?.ColorScheme ?? ColorScheme.Default;
RefreshColors();
- BasePart.Program.GetDisplayPaths(BasePart.Location,
- out var cutPath, out var leadPath, out var etchPath, out var hasOpenCuts);
- cutPath.Transform(plateView.Matrix);
- leadPath.Transform(plateView.Matrix);
- etchPath.Transform(plateView.Matrix);
+
+ // Place the program-frame paths, then map them to the screen.
+ using var matrix = plateView.Matrix.Clone();
+ matrix.Translate((float)BasePart.Location.X, (float)BasePart.Location.Y);
+
Path?.Dispose();
LeadInPath?.Dispose();
EtchPath?.Dispose();
FillPath?.Dispose();
- FillPath = null;
- // Only programs with break cuts or open (tabbed) cuts need a fill apart from the outline path.
- if (hasOpenCuts || HasBreakCuts(BasePart.Program))
- {
- FillPath = BasePart.Program.GetFillPath(BasePart.Location);
- FillPath.Transform(plateView.Matrix);
- }
- Path = cutPath;
- EtchPath = etchPath;
DisplayPath?.Dispose();
- DisplayPath = BuildDisplayPath(plateView.Matrix);
- LeadInPath = BasePart.HasManualLeadIns ? leadPath : null;
- if (!BasePart.HasManualLeadIns)
- leadPath.Dispose();
+ var cutPath = Path = Transformed(localCut, matrix);
+ var etchPath = EtchPath = Transformed(localEtch, matrix);
+ FillPath = Transformed(localFill, matrix);
+ LeadInPath = Transformed(localLead, matrix);
+ DisplayPath = Transformed(localDisplay, matrix);
var bounds = cutPath.GetBounds();
if (etchPath.PointCount > 0)
@@ -398,6 +477,12 @@ namespace OpenNest
private static Pen LeadInPen => leadInPen ??= new Pen(Color.OrangeRed, 1.5f);
+ private static StringFormat LabelFormat => labelFormat ??= new StringFormat
+ {
+ Alignment = StringAlignment.Center,
+ LineAlignment = StringAlignment.Center,
+ };
+
private static Brush SelectedBrush
{
get
@@ -433,7 +518,7 @@ namespace OpenNest
set
{
BasePart.Location = value;
- IsDirty = true;
+ moved = true;
}
}
@@ -457,13 +542,13 @@ namespace OpenNest
public void Offset(double x, double y)
{
BasePart.Offset(x, y);
- IsDirty = true;
+ moved = true;
}
public void Offset(Vector voffset)
{
BasePart.Offset(voffset);
- IsDirty = true;
+ moved = true;
}
public Box BoundingBox