mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-28 05:51:06 -04:00
feat(avalonia): prototype a cross-platform plate viewer
Adds OpenNest.Avalonia, a throwaway Avalonia 11 app kept out of OpenNest.sln, to gauge a Linux port. It reads nests through OpenNest.IO and draws plates with Skia: a port of the desktop's display paths (cuts, etches, manual lead-ins, filled material), pan, cursor zoom, fit, hover and selection, beside a plate list and a drawings DataGrid. --snapshot renders the window to a PNG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e18304066d
commit
3e767ca3b6
8 files changed
+835
No files matched your search
@@ -0,0 +1,9 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="OpenNest.Avalonia.App"
|
||||
RequestedThemeVariant="Light">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
@@ -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<string>();
|
||||
if (args.Length > 0)
|
||||
window.OpenNest(args[0]);
|
||||
// --snapshot <png> [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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<CNC.Program, ProgramPaths> 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; }
|
||||
|
||||
/// <summary>The cursor in plate coordinates, or null when it is outside the view.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Fits the view to the plate and its parts without requesting a redraw.</summary>
|
||||
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<PartItem>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>The topmost part whose material contains <paramref name="world"/>.</summary>
|
||||
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<PartItem> items;
|
||||
|
||||
public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
|
||||
List<PartItem> 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<ISkiaSharpApiLeaseFeature>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:c="using:OpenNest.Avalonia.Controls"
|
||||
xmlns:local="using:OpenNest.Avalonia"
|
||||
x:Class="OpenNest.Avalonia.MainWindow"
|
||||
Title="OpenNest (Avalonia prototype)"
|
||||
Width="1400" Height="860">
|
||||
<DockPanel>
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
|
||||
<Separator />
|
||||
<MenuItem Header="E_xit" Click="OnExit" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
|
||||
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
|
||||
<MenuItem Header="_Next Plate" InputGesture="PageDown" Click="OnNextPlate" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<Button Content="Open" Click="OnOpen" />
|
||||
<Button Content="Fit" Click="OnFit" />
|
||||
<Button Content="Previous" Click="OnPreviousPlate" />
|
||||
<Button Content="Next" Click="OnNextPlate" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border DockPanel.Dock="Bottom" Padding="6,3" Background="#F0F0F0">
|
||||
<TextBlock x:Name="Status" Text="Open a .nest file to begin." />
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="260,4,*,4,320">
|
||||
<DockPanel Grid.Column="0">
|
||||
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Plates" />
|
||||
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
|
||||
</DockPanel>
|
||||
<GridSplitter Grid.Column="1" ResizeDirection="Columns" />
|
||||
<c:PlateCanvas Grid.Column="2" x:Name="Canvas" />
|
||||
<GridSplitter Grid.Column="3" ResizeDirection="Columns" />
|
||||
<DockPanel Grid.Column="4">
|
||||
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Drawings on plate" />
|
||||
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
|
||||
AutoGenerateColumns="False" GridLinesVisibility="Horizontal" CanUserSortColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
|
||||
<DataGridTextColumn Header="Qty" Binding="{Binding Count}" />
|
||||
<DataGridTextColumn Header="Size" Binding="{Binding Size}" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>OpenNest.Avalonia</RootNamespace>
|
||||
<AssemblyName>OpenNest.Avalonia</AssemblyName>
|
||||
<Nullable>disable</Nullable>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace OpenNest.Avalonia.Rendering
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class ProgramPaths : IDisposable
|
||||
{
|
||||
public SKPath Cut { get; } = new() { FillType = SKPathFillType.EvenOdd };
|
||||
|
||||
public SKPath Lead { get; } = new();
|
||||
|
||||
public SKPath Etch { get; } = new();
|
||||
|
||||
/// <summary>The material outline to fill: cuts and tabs, without open break cuts.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user