diff --git a/OpenNest.Avalonia/App.axaml b/OpenNest.Avalonia/App.axaml
new file mode 100644
index 00000000..c2c69796
--- /dev/null
+++ b/OpenNest.Avalonia/App.axaml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/OpenNest.Avalonia/App.axaml.cs b/OpenNest.Avalonia/App.axaml.cs
new file mode 100644
index 00000000..2c1597ab
--- /dev/null
+++ b/OpenNest.Avalonia/App.axaml.cs
@@ -0,0 +1,30 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace OpenNest.Avalonia
+{
+ public partial class App : Application
+ {
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var window = new MainWindow();
+ desktop.MainWindow = window;
+ var args = desktop.Args ?? System.Array.Empty();
+ if (args.Length > 0)
+ window.OpenNest(args[0]);
+ // --snapshot [plate]: render the window to an image and exit, for checking the drawing.
+ var snapshot = System.Array.IndexOf(args, "--snapshot");
+ if (snapshot >= 0 && snapshot + 1 < args.Length)
+ window.SaveSnapshotAndClose(args[snapshot + 1],
+ snapshot + 2 < args.Length && int.TryParse(args[snapshot + 2], out var plate) ? plate : 1);
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+ }
+}
diff --git a/OpenNest.Avalonia/Controls/PlateCanvas.cs b/OpenNest.Avalonia/Controls/PlateCanvas.cs
new file mode 100644
index 00000000..5704acad
--- /dev/null
+++ b/OpenNest.Avalonia/Controls/PlateCanvas.cs
@@ -0,0 +1,388 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Platform;
+using Avalonia.Rendering.SceneGraph;
+using Avalonia.Skia;
+using OpenNest.Avalonia.Rendering;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using SkiaSharp;
+using Control = Avalonia.Controls.Control;
+using Vector = OpenNest.Geometry.Vector;
+
+namespace OpenNest.Avalonia.Controls
+{
+ ///
+ /// Draws one plate with Skia: pans with a left or middle drag, zooms at the cursor with the
+ /// wheel, fits on a double click, and highlights the part under the cursor.
+ ///
+ public sealed class PlateCanvas : Control
+ {
+ private static readonly SKColor BackgroundColor = new(169, 169, 169);
+ private static readonly SKColor PlateFillColor = new(245, 245, 245);
+ private static readonly SKColor PlateOutlineColor = new(128, 128, 128);
+ private static readonly SKColor EdgeSpacingColor = new(180, 180, 180);
+ private static readonly SKColor LeadColor = new(255, 0, 0);
+ private static readonly SKColor EtchColor = new(0, 150, 60);
+ private static readonly SKColor SelectedColor = new(150, 200, 255, 90 * 255 / 100);
+
+ // Paths are built once per program and only read afterwards, so the render thread may draw
+ // them while the UI thread builds others. Keyed by reference: a rotation replaces the program.
+ private readonly Dictionary paths = new();
+
+ private Plate plate;
+ private double scale = 1;
+ private Point origin;
+ private Point? dragStart;
+ private bool fitPending = true;
+
+ public Plate Plate
+ {
+ get => plate;
+ set
+ {
+ plate = value;
+ HoveredPart = null;
+ SelectedPart = null;
+ fitPending = true;
+ InvalidateVisual();
+ }
+ }
+
+ public Part HoveredPart { get; private set; }
+
+ public Part SelectedPart { get; private set; }
+
+ /// The cursor in plate coordinates, or null when it is outside the view.
+ public Vector? CursorWorld { get; private set; }
+
+ public event EventHandler ViewChanged;
+
+ public PlateCanvas()
+ {
+ ClipToBounds = true;
+ Focusable = true;
+ }
+
+ public void ClearCache() => paths.Clear();
+
+ public void ZoomToFit()
+ {
+ if (!Fit())
+ return;
+ InvalidateVisual();
+ ViewChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// Fits the view to the plate and its parts without requesting a redraw.
+ private bool Fit()
+ {
+ if (plate == null || Bounds.Width <= 0 || Bounds.Height <= 0)
+ return false;
+ var extents = PlateRect(plate);
+ foreach (var part in plate.Parts)
+ extents = Union(extents, part.BoundingBox);
+ const double margin = 20;
+ var sx = (Bounds.Width - 2 * margin) / System.Math.Max(extents.Length, 1e-6);
+ var sy = (Bounds.Height - 2 * margin) / System.Math.Max(extents.Width, 1e-6);
+ scale = System.Math.Min(sx, sy);
+ origin = new Point(
+ Bounds.Width / 2 - (extents.Left + extents.Length / 2) * scale,
+ Bounds.Height / 2 + (extents.Bottom + extents.Width / 2) * scale);
+ fitPending = false;
+ return true;
+ }
+
+ public double Zoom => scale;
+
+ private Vector ToWorld(Point p) => new((p.X - origin.X) / scale, (origin.Y - p.Y) / scale);
+
+ private SKMatrix WorldMatrix =>
+ new((float)scale, 0, (float)origin.X, 0, (float)-scale, (float)origin.Y, 0, 0, 1);
+
+ private static Box PlateRect(Plate plate)
+ {
+ var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
+ var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
+ return new Box(left, bottom, plate.Size.Length, plate.Size.Width);
+ }
+
+ private static Box Union(Box a, Box b)
+ {
+ var left = System.Math.Min(a.Left, b.Left);
+ var bottom = System.Math.Min(a.Bottom, b.Bottom);
+ return new Box(left, bottom,
+ System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
+ }
+
+ private ProgramPaths PathsFor(Part part)
+ {
+ if (!paths.TryGetValue(part.Program, out var result))
+ paths[part.Program] = result = ProgramPaths.Build(part.Program);
+ return result;
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ // A redraw may not be requested during a render, so tell listeners afterwards.
+ if (fitPending && Fit())
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(() => ViewChanged?.Invoke(this, EventArgs.Empty));
+
+ var items = new List();
+ if (plate != null)
+ {
+ for (var i = 0; i < plate.Parts.Count; i++)
+ {
+ var part = plate.Parts[i];
+ items.Add(new PartItem(PathsFor(part), part.Location, part.BoundingBox,
+ ToSkColor(part.BaseDrawing.Color), (i + 1).ToString(),
+ ReferenceEquals(part, SelectedPart), ReferenceEquals(part, HoveredPart),
+ part.BaseDrawing.IsCutOff, part.HasManualLeadIns));
+ }
+ }
+
+ context.Custom(new PlateDrawOperation(new Rect(Bounds.Size), plate == null ? null : PlateRect(plate),
+ plate?.EdgeSpacing, WorldMatrix, items));
+ }
+
+ private static SKColor ToSkColor(System.Drawing.Color color) =>
+ color.IsEmpty ? new SKColor(130, 204, 130) : new SKColor(color.R, color.G, color.B, color.A);
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ var at = e.GetPosition(this);
+ var factor = System.Math.Pow(1.2, e.Delta.Y);
+ // Keep the world point under the cursor fixed.
+ origin = new Point(at.X - (at.X - origin.X) * factor, at.Y - (at.Y - origin.Y) * factor);
+ scale *= factor;
+ InvalidateVisual();
+ ViewChanged?.Invoke(this, EventArgs.Empty);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ Focus();
+ var point = e.GetCurrentPoint(this);
+ if (e.ClickCount == 2)
+ {
+ ZoomToFit();
+ return;
+ }
+ if (point.Properties.IsLeftButtonPressed)
+ {
+ SelectedPart = HitTest(ToWorld(point.Position));
+ InvalidateVisual();
+ ViewChanged?.Invoke(this, EventArgs.Empty);
+ }
+ if (point.Properties.IsLeftButtonPressed || point.Properties.IsMiddleButtonPressed)
+ {
+ dragStart = point.Position;
+ e.Pointer.Capture(this);
+ }
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ var at = e.GetPosition(this);
+ if (dragStart is { } start)
+ {
+ origin += at - start;
+ dragStart = at;
+ InvalidateVisual();
+ }
+
+ CursorWorld = ToWorld(at);
+ var hovered = dragStart == null ? HitTest(CursorWorld.Value) : HoveredPart;
+ if (!ReferenceEquals(hovered, HoveredPart))
+ {
+ HoveredPart = hovered;
+ InvalidateVisual();
+ }
+ ViewChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ dragStart = null;
+ e.Pointer.Capture(null);
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ CursorWorld = null;
+ if (HoveredPart != null)
+ {
+ HoveredPart = null;
+ InvalidateVisual();
+ }
+ ViewChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ if (e.Key == Key.F)
+ {
+ ZoomToFit();
+ e.Handled = true;
+ }
+ }
+
+ protected override void OnSizeChanged(global::Avalonia.Controls.SizeChangedEventArgs e)
+ {
+ base.OnSizeChanged(e);
+ if (fitPending)
+ InvalidateVisual();
+ }
+
+ /// The topmost part whose material contains .
+ private Part HitTest(Vector world)
+ {
+ if (plate == null)
+ return null;
+ for (var i = plate.Parts.Count - 1; i >= 0; i--)
+ {
+ var part = plate.Parts[i];
+ var box = part.BoundingBox;
+ if (world.X < box.Left || world.X > box.Right || world.Y < box.Bottom || world.Y > box.Top)
+ continue;
+ var local = world - part.Location;
+ if (PathsFor(part).Fill.Contains((float)local.X, (float)local.Y))
+ return part;
+ }
+ return null;
+ }
+
+ private sealed record PartItem(ProgramPaths Paths, Vector Location, Box Bounds, SKColor Color,
+ string Label, bool Selected, bool Hovered, bool IsCutOff, bool ShowLeads);
+
+ private sealed class PlateDrawOperation : ICustomDrawOperation
+ {
+ private readonly Box plateRect;
+ private readonly Spacing? edgeSpacing;
+ private readonly SKMatrix matrix;
+ private readonly List items;
+
+ public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
+ List items)
+ {
+ Bounds = bounds;
+ this.plateRect = plateRect;
+ this.edgeSpacing = edgeSpacing;
+ this.matrix = matrix;
+ this.items = items;
+ }
+
+ public Rect Bounds { get; }
+
+ public bool HitTest(Point p) => Bounds.Contains(p);
+
+ public bool Equals(ICustomDrawOperation other) => false;
+
+ public void Dispose()
+ {
+ }
+
+ public void Render(ImmediateDrawingContext context)
+ {
+ var lease = context.TryGetFeature();
+ if (lease == null)
+ return;
+ using var api = lease.Lease();
+ var canvas = api.SkCanvas;
+ canvas.Save();
+ canvas.ClipRect(new SKRect(0, 0, (float)Bounds.Width, (float)Bounds.Height));
+ canvas.Clear(BackgroundColor);
+
+ if (plateRect != null)
+ Draw(canvas);
+
+ canvas.Restore();
+ }
+
+ private void Draw(SKCanvas canvas)
+ {
+ // Hairline strokes (width 0) stay one pixel wide at any zoom.
+ using var fill = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
+ using var stroke = new SKPaint { Style = SKPaintStyle.Stroke, StrokeWidth = 0, IsAntialias = true };
+
+ canvas.Save();
+ var world = matrix;
+ canvas.Concat(ref world);
+
+ var plate = new SKRect((float)plateRect.Left, (float)plateRect.Bottom,
+ (float)plateRect.Right, (float)plateRect.Top);
+ fill.Color = PlateFillColor;
+ canvas.DrawRect(plate, fill);
+ if (edgeSpacing is { } spacing)
+ {
+ stroke.Color = EdgeSpacingColor;
+ canvas.DrawRect(new SKRect(plate.Left + (float)spacing.Left,
+ plate.Top + (float)spacing.Bottom, plate.Right - (float)spacing.Right,
+ plate.Bottom - (float)spacing.Top), stroke);
+ }
+ stroke.Color = PlateOutlineColor;
+ canvas.DrawRect(plate, stroke);
+
+ var view = canvas.LocalClipBounds;
+ foreach (var item in items)
+ {
+ var b = item.Bounds;
+ if (b.Right < view.Left || b.Left > view.Right || b.Top < view.Top || b.Bottom > view.Bottom)
+ continue;
+
+ canvas.Save();
+ canvas.Translate((float)item.Location.X, (float)item.Location.Y);
+
+ fill.Color = item.Selected ? SelectedColor
+ : item.Hovered ? Lighten(item.Color) : item.Color;
+ canvas.DrawPath(item.Paths.Fill, fill);
+
+ stroke.Color = EtchColor;
+ canvas.DrawPath(item.Paths.Etch, stroke);
+ stroke.Color = item.Selected ? new SKColor(30, 90, 200) : Darken(item.Color);
+ canvas.DrawPath(item.Paths.Cut, stroke);
+ // As on the desktop, only manually placed lead-ins are shown.
+ if (item.ShowLeads)
+ {
+ stroke.Color = LeadColor;
+ canvas.DrawPath(item.Paths.Lead, stroke);
+ }
+
+ canvas.Restore();
+ }
+
+ canvas.Restore();
+ DrawLabels(canvas);
+ }
+
+ // Ids are drawn in screen space and only where the part is big enough on screen to hold one.
+ private void DrawLabels(SKCanvas canvas)
+ {
+ using var text = new SKPaint { Color = SKColors.Black, IsAntialias = true, TextSize = 12 };
+ foreach (var item in items)
+ {
+ if (item.IsCutOff)
+ continue;
+ var center = matrix.MapPoint((float)item.Bounds.Center.X, (float)item.Bounds.Center.Y);
+ var width = item.Bounds.Length * matrix.ScaleX;
+ var height = item.Bounds.Width * -matrix.ScaleY;
+ var measured = text.MeasureText(item.Label);
+ if (measured + 4 > width || text.TextSize + 4 > height)
+ continue;
+ canvas.DrawText(item.Label, center.X - measured / 2, center.Y + text.TextSize / 3, text);
+ }
+ }
+
+ private static SKColor Darken(SKColor c) =>
+ new((byte)(c.Red * 0.55), (byte)(c.Green * 0.55), (byte)(c.Blue * 0.55), 255);
+
+ private static SKColor Lighten(SKColor c) =>
+ new((byte)(c.Red + (255 - c.Red) / 2), (byte)(c.Green + (255 - c.Green) / 2),
+ (byte)(c.Blue + (255 - c.Blue) / 2), c.Alpha);
+ }
+ }
+}
diff --git a/OpenNest.Avalonia/MainWindow.axaml b/OpenNest.Avalonia/MainWindow.axaml
new file mode 100644
index 00000000..e20918a4
--- /dev/null
+++ b/OpenNest.Avalonia/MainWindow.axaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OpenNest.Avalonia/MainWindow.axaml.cs b/OpenNest.Avalonia/MainWindow.axaml.cs
new file mode 100644
index 00000000..378883c4
--- /dev/null
+++ b/OpenNest.Avalonia/MainWindow.axaml.cs
@@ -0,0 +1,139 @@
+using System;
+using System.IO;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using OpenNest.IO;
+
+namespace OpenNest.Avalonia
+{
+ public sealed record DrawingRow(string Name, int Count, string Size);
+
+ public partial class MainWindow : Window
+ {
+ private Nest nest;
+ private string fileName;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ Canvas.ViewChanged += (_, _) => UpdateStatus();
+ KeyBindings.Add(new KeyBinding
+ {
+ Gesture = new KeyGesture(Key.O, KeyModifiers.Control),
+ Command = new Command(() => OnOpen(null, null)),
+ });
+ KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageUp), Command = new Command(() => StepPlate(-1)) });
+ KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageDown), Command = new Command(() => StepPlate(1)) });
+ }
+
+ public void OpenNest(string path)
+ {
+ try
+ {
+ nest = new NestReader(path).Read();
+ }
+ catch (Exception ex)
+ {
+ Status.Text = $"Could not open {Path.GetFileName(path)}: {ex.Message}";
+ return;
+ }
+
+ fileName = Path.GetFileName(path);
+ Title = $"{fileName} - OpenNest (Avalonia prototype)";
+ Canvas.ClearCache();
+ PlateList.ItemsSource = nest.Plates
+ .Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
+ .ToList();
+ PlateList.SelectedIndex = nest.Plates.Count > 0 ? 0 : -1;
+ }
+
+ public void SaveSnapshotAndClose(string path, int plateNumber)
+ {
+ Opened += async (_, _) =>
+ {
+ if (nest?.Plates.Count > 0)
+ PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
+ await System.Threading.Tasks.Task.Delay(500);
+ var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
+ using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
+ bitmap.Render(this);
+ bitmap.Save(path);
+ Close();
+ };
+ }
+
+ private async void OnOpen(object sender, RoutedEventArgs e)
+ {
+ var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Open Nest",
+ AllowMultiple = false,
+ FileTypeFilter = new[] { new FilePickerFileType("Nest files") { Patterns = new[] { "*.nest" } } },
+ });
+ var path = files.FirstOrDefault()?.TryGetLocalPath();
+ if (path != null)
+ OpenNest(path);
+ }
+
+ private void OnExit(object sender, RoutedEventArgs e) => Close();
+
+ private void OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
+
+ private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
+
+ private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
+
+ private void StepPlate(int step)
+ {
+ if (nest == null || nest.Plates.Count == 0)
+ return;
+ PlateList.SelectedIndex = System.Math.Clamp(PlateList.SelectedIndex + step, 0, nest.Plates.Count - 1);
+ }
+
+ private void OnPlateSelected(object sender, SelectionChangedEventArgs e)
+ {
+ var index = PlateList.SelectedIndex;
+ var plate = nest != null && index >= 0 ? nest.Plates[index] : null;
+ Canvas.Plate = plate;
+ DrawingGrid.ItemsSource = plate?.Parts
+ .Where(part => !part.BaseDrawing.IsCutOff)
+ .GroupBy(part => part.BaseDrawing)
+ .Select(group => new DrawingRow(group.Key.Name, group.Count(), SizeOf(group.First())))
+ .OrderByDescending(row => row.Count)
+ .ToList();
+ UpdateStatus();
+ }
+
+ private static string SizeOf(Part part) =>
+ $"{part.BoundingBox.Length:0.###} x {part.BoundingBox.Width:0.###}";
+
+ private void UpdateStatus()
+ {
+ if (Canvas.Plate == null)
+ return;
+ var text = $"{fileName} Plate {PlateList.SelectedIndex + 1} of {nest.Plates.Count} Zoom {Canvas.Zoom:0.##} px/unit";
+ if (Canvas.CursorWorld is { } at)
+ text += $" X {at.X:0.000} Y {at.Y:0.000}";
+ var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
+ if (part != null)
+ text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
+ Status.Text = text;
+ }
+
+ private sealed class Command : System.Windows.Input.ICommand
+ {
+ private readonly Action action;
+
+ public Command(Action action) => this.action = action;
+
+ public event EventHandler CanExecuteChanged { add { } remove { } }
+
+ public bool CanExecute(object parameter) => true;
+
+ public void Execute(object parameter) => action();
+ }
+ }
+}
diff --git a/OpenNest.Avalonia/OpenNest.Avalonia.csproj b/OpenNest.Avalonia/OpenNest.Avalonia.csproj
new file mode 100644
index 00000000..2d5cd223
--- /dev/null
+++ b/OpenNest.Avalonia/OpenNest.Avalonia.csproj
@@ -0,0 +1,20 @@
+
+
+ WinExe
+ net8.0
+ OpenNest.Avalonia
+ OpenNest.Avalonia
+ disable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OpenNest.Avalonia/Program.cs b/OpenNest.Avalonia/Program.cs
new file mode 100644
index 00000000..9576f44e
--- /dev/null
+++ b/OpenNest.Avalonia/Program.cs
@@ -0,0 +1,18 @@
+using System;
+using Avalonia;
+
+namespace OpenNest.Avalonia
+{
+ internal static class Program
+ {
+ [STAThread]
+ public static void Main(string[] args) =>
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+ }
+}
diff --git a/OpenNest.Avalonia/Rendering/ProgramPaths.cs b/OpenNest.Avalonia/Rendering/ProgramPaths.cs
new file mode 100644
index 00000000..7cd6fd27
--- /dev/null
+++ b/OpenNest.Avalonia/Rendering/ProgramPaths.cs
@@ -0,0 +1,175 @@
+using System;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.Math;
+using SkiaSharp;
+
+namespace OpenNest.Avalonia.Rendering
+{
+ ///
+ /// A program's display geometry as Skia paths in the program frame, before placement.
+ /// A Skia port of the desktop's GraphicsHelper.GetDisplayPaths, without tab bridging.
+ ///
+ internal sealed class ProgramPaths : IDisposable
+ {
+ public SKPath Cut { get; } = new() { FillType = SKPathFillType.EvenOdd };
+
+ public SKPath Lead { get; } = new();
+
+ public SKPath Etch { get; } = new();
+
+ /// The material outline to fill: cuts and tabs, without open break cuts.
+ public SKPath Fill { get; } = new() { FillType = SKPathFillType.EvenOdd };
+
+ public SKRect Bounds { get; private set; }
+
+ public static ProgramPaths Build(CNC.Program program)
+ {
+ var paths = new ProgramPaths();
+ var position = Vector.Zero;
+ paths.AddProgram(program, ref position);
+ var bounds = paths.Cut.Bounds;
+ if (!paths.Etch.IsEmpty)
+ bounds = paths.Cut.IsEmpty ? paths.Etch.Bounds : SKRect.Union(bounds, paths.Etch.Bounds);
+ paths.Bounds = bounds;
+ return paths;
+ }
+
+ private SKPath TargetFor(LayerType layer) => layer switch
+ {
+ LayerType.Leadin or LayerType.Leadout => Lead,
+ LayerType.Scribe => Etch,
+ LayerType.Display or LayerType.Tab => null,
+ _ => Cut,
+ };
+
+ private void AddProgram(CNC.Program program, ref Vector position)
+ {
+ // Sub-program offsets are relative to the frame origin at entry, not the tool position.
+ var frameOrigin = position;
+ var mode = program.Mode;
+ SKPath open = null;
+
+ for (var i = 0; i < program.Length; ++i)
+ {
+ var code = program[i];
+ switch (code)
+ {
+ case LinearMove line:
+ {
+ var end = mode == Mode.Incremental ? line.EndPoint + position : line.EndPoint;
+ var target = line.Suppressed ? null : TargetFor(line.Layer);
+ if (target != null)
+ {
+ Continue(ref open, target, position);
+ target.LineTo((float)end.X, (float)end.Y);
+ }
+ if (!line.Suppressed && line.Layer != LayerType.Break)
+ AddFillLine(line.Layer, position, end);
+ position = end;
+ break;
+ }
+
+ case ArcMove arc:
+ {
+ var end = arc.EndPoint;
+ var center = arc.CenterPoint;
+ if (mode == Mode.Incremental)
+ {
+ end += position;
+ center += position;
+ }
+ var target = arc.Suppressed ? null : TargetFor(arc.Layer);
+ if (target != null)
+ {
+ Continue(ref open, target, position);
+ AddArc(target, position, end, center, arc.Rotation);
+ }
+ if (!arc.Suppressed && arc.Layer != LayerType.Break)
+ AddFillArc(arc.Layer, position, end, center, arc.Rotation);
+ position = end;
+ break;
+ }
+
+ case RapidMove rapid:
+ {
+ var end = mode == Mode.Incremental ? rapid.EndPoint + position : rapid.EndPoint;
+ if (end.DistanceTo(position) > 0.001)
+ open = null;
+ position = end;
+ break;
+ }
+
+ case SubProgramCall call when call.Program != null:
+ open = null;
+ position = new Vector(frameOrigin.X + call.Offset.X, frameOrigin.Y + call.Offset.Y);
+ AddProgram(call.Program, ref position);
+ open = null;
+ break;
+ }
+ }
+ }
+
+ // Starts a new contour unless the move continues the one already open on the same path.
+ private SKPath openFill;
+
+ private static void Continue(ref SKPath open, SKPath target, Vector from)
+ {
+ if (!ReferenceEquals(open, target) || target.LastPoint != new SKPoint((float)from.X, (float)from.Y))
+ target.MoveTo((float)from.X, (float)from.Y);
+ open = target;
+ }
+
+ private bool IsMaterial(LayerType layer) =>
+ layer is not (LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Display);
+
+ private void AddFillLine(LayerType layer, Vector from, Vector to)
+ {
+ if (!IsMaterial(layer))
+ return;
+ Continue(ref openFill, Fill, from);
+ Fill.LineTo((float)to.X, (float)to.Y);
+ }
+
+ private void AddFillArc(LayerType layer, Vector from, Vector to, Vector center, RotationType rotation)
+ {
+ if (!IsMaterial(layer))
+ return;
+ Continue(ref openFill, Fill, from);
+ AddArc(Fill, from, to, center, rotation);
+ }
+
+ private static void AddArc(SKPath path, Vector from, Vector to, Vector center, RotationType rotation)
+ {
+ var startAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(from.Y - center.Y, from.X - center.X)));
+ var endAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(to.Y - center.Y, to.X - center.X)));
+ var radius = (float)center.DistanceTo(to);
+ var oval = new SKRect((float)center.X - radius, (float)center.Y - radius,
+ (float)center.X + radius, (float)center.Y + radius);
+
+ if (startAngle.IsEqualTo(endAngle))
+ {
+ // A full circle: two half sweeps keep the contour starting at the current point.
+ var sweep = rotation == RotationType.CCW ? 180f : -180f;
+ path.ArcTo(oval, (float)startAngle, sweep, false);
+ path.ArcTo(oval, (float)startAngle + sweep, sweep, false);
+ return;
+ }
+
+ if (rotation == RotationType.CCW && endAngle < startAngle)
+ endAngle += 360.0;
+ else if (rotation == RotationType.CW && startAngle < endAngle)
+ startAngle += 360.0;
+
+ path.ArcTo(oval, (float)startAngle, (float)(endAngle - startAngle), false);
+ }
+
+ public void Dispose()
+ {
+ Cut.Dispose();
+ Lead.Dispose();
+ Etch.Dispose();
+ Fill.Dispose();
+ }
+ }
+}