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