diff --git a/OpenNest.Avalonia/Controls/PlateCanvas.cs b/OpenNest.Avalonia/Controls/PlateCanvas.cs
index f39e98f9..5b71da4c 100644
--- a/OpenNest.Avalonia/Controls/PlateCanvas.cs
+++ b/OpenNest.Avalonia/Controls/PlateCanvas.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Input;
using Avalonia.Media;
@@ -8,7 +10,9 @@ using Avalonia.Rendering.SceneGraph;
using Avalonia.Skia;
using OpenNest.Avalonia.Rendering;
using OpenNest.CNC;
+using OpenNest.Desktop.Rendering;
using OpenNest.Geometry;
+using OpenNest.Support;
using SkiaSharp;
using Control = Avalonia.Controls.Control;
using Vector = OpenNest.Geometry.Vector;
@@ -16,24 +20,30 @@ using Vector = OpenNest.Geometry.Vector;
namespace OpenNest.Avalonia.Controls
{
///
- /// Draws one plate with Skia in the colours of : 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.
+ /// Draws one plate with Skia as the WinForms plate view does, in the colours of
+ /// : the sheet (or irregular outline), edge spacing, filled parts with their
+ /// numbers, cut-offs, clamps, common lines and the View toggles in . 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 LeadColor = new(255, 0, 0);
-
- // 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();
+ // Paths are built once per part pose and only read afterwards, so the render thread may draw
+ // them while the UI thread builds others.
+ private readonly Dictionary paths = new();
+ private readonly Dictionary labelPoints = new();
+ private readonly ConditionalWeakTable offsets = new();
+ private readonly SupportAnalysisCache supports = new();
+ private readonly Dictionary commonLineChecks = new();
+ private readonly ViewTransform view = new();
private Plate plate;
- private double scale = 1;
- private Point origin;
private Point? dragStart;
private bool fitPending = true;
+ private sealed record OffsetEntry(CNC.Program Program, double Rotation, double Spacing, double Tolerance,
+ SKPath Path);
+
public Plate Plate
{
get => plate;
@@ -43,6 +53,7 @@ namespace OpenNest.Avalonia.Controls
HoveredPart = null;
SelectedPart = null;
fitPending = true;
+ TrimCache();
InvalidateVisual();
}
}
@@ -50,6 +61,9 @@ namespace OpenNest.Avalonia.Controls
/// The colours drawn with: the application's active scheme unless set.
public ColorScheme Scheme { get; set; } = ColorScheme.Default;
+ /// The View menu toggles this view draws with.
+ public PlateViewOptions Options { get; set; } = new();
+
public Part HoveredPart { get; private set; }
public Part SelectedPart { get; private set; }
@@ -57,6 +71,12 @@ namespace OpenNest.Avalonia.Controls
/// The cursor in plate coordinates, or null when it is outside the view.
public Vector? CursorWorld { get; private set; }
+ /// Pixels per drawing unit.
+ public double Zoom => view.Scale;
+
+ /// The view's world/screen mapping.
+ public ViewTransform View => view;
+
public event EventHandler ViewChanged;
public PlateCanvas()
@@ -65,7 +85,25 @@ namespace OpenNest.Avalonia.Controls
Focusable = true;
}
- public void ClearCache() => paths.Clear();
+ public void ClearCache()
+ {
+ paths.Clear();
+ labelPoints.Clear();
+ commonLineChecks.Clear();
+ }
+
+ // Keeps only the shown plate's parts, so closed plates do not hold their paths.
+ private void TrimCache()
+ {
+ if (plate == null)
+ {
+ paths.Clear();
+ return;
+ }
+ var shown = new HashSet(plate.Parts);
+ foreach (var stale in paths.Keys.Where(part => !shown.Contains(part)).ToList())
+ paths.Remove(stale);
+ }
public void ZoomToFit()
{
@@ -80,84 +118,191 @@ namespace OpenNest.Avalonia.Controls
{
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);
+ view.Resize(Bounds.Width, Bounds.Height);
+ if (!view.ZoomToArea(plate.BoundingBox(true)))
+ return false;
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 Vector ToWorld(Point p) => view.ScreenToWorld(p.X, p.Y);
private SKMatrix WorldMatrix =>
- new((float)scale, 0, (float)origin.X, 0, (float)-scale, (float)origin.Y, 0, 0, 1);
+ new((float)view.Scale, 0, (float)view.OriginX, 0, (float)-view.Scale, (float)view.OriginY, 0, 0, 1);
- private static Box PlateRect(Plate plate)
+ private PartPaths PathsFor(Part part)
{
- 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);
+ if (!paths.TryGetValue(part, out var result) || !result.Geometry.IsCurrentFor(part))
+ paths[part] = result = PartPaths.Build(part);
return result;
}
+ private Vector LabelPoint(Part part)
+ {
+ if (PartLabels.CutOffSegmentPoint(part) is { } cutOff)
+ return cutOff;
+ var drawing = part.BaseDrawing;
+ if (!labelPoints.TryGetValue(drawing, out var cached)
+ || !ReferenceEquals(cached.Program, drawing.Program) || cached.Rotation != drawing.Program.Rotation)
+ {
+ cached = (drawing.Program, drawing.Program.Rotation, PartLabels.LabelPoint(drawing));
+ labelPoints[drawing] = cached;
+ }
+ return PartLabels.Place(part, cached.Point);
+ }
+
+ private SKPath OffsetFor(Part part, double spacing, double tolerance)
+ {
+ if (offsets.TryGetValue(part, out var entry) && ReferenceEquals(entry.Program, part.Program)
+ && entry.Rotation == part.Rotation && entry.Spacing == spacing && entry.Tolerance == tolerance)
+ return entry.Path;
+ var polygons = PlateOverlays.OffsetOutline(part, spacing, tolerance);
+ var path = SkiaFigures.Polygons(polygons.Select(p => (IReadOnlyList)p.Vertices));
+ offsets.AddOrUpdate(part, new OffsetEntry(part.Program, part.Rotation, spacing, tolerance, path));
+ return path;
+ }
+
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));
+ if (Bounds.Width > 0 && Bounds.Height > 0)
+ view.Resize(Bounds.Width, Bounds.Height);
- var palette = SkiaSchemePalette.For(Scheme ?? ColorScheme.Default);
- var items = new List();
- if (plate != null)
+ var scene = new Scene
{
- for (var i = 0; i < plate.Parts.Count; i++)
- {
- var part = plate.Parts[i];
- var color = ToSkColor(part.BaseDrawing.Color);
- items.Add(new PartItem(PathsFor(part), part.Location, part.BoundingBox,
- color, palette.PartOutline(color), (i + 1).ToString(),
- ReferenceEquals(part, SelectedPart), ReferenceEquals(part, HoveredPart),
- part.BaseDrawing.IsCutOff, part.HasManualLeadIns));
- }
+ Bounds = new Rect(Bounds.Size),
+ Palette = SkiaSchemePalette.For(Scheme ?? ColorScheme.Default),
+ Matrix = WorldMatrix,
+ Scale = view.Scale,
+ DrawOrigin = Options.DrawOrigin,
+ };
+ if (plate != null)
+ BuildScene(scene);
+ context.Custom(new PlateDrawOperation(scene));
+ }
+
+ private void BuildScene(Scene scene)
+ {
+ var options = Options;
+ var visible = view.VisibleWorld();
+ scene.Sheet = PlateOverlays.Sheet(plate);
+ if (plate.Outline is { } outline)
+ {
+ scene.SheetOutline = SkiaFigures.Polygons(new[] { PlateOverlays.PlaceOutline(plate, outline.Points) }
+ .Concat(outline.Obstacles.Select(o => PlateOverlays.PlaceOutline(plate, o))));
+ scene.Obstacles = outline.Obstacles.Count == 0 ? null
+ : SkiaFigures.Polygons(outline.Obstacles.Select(o => PlateOverlays.PlaceOutline(plate, o)));
+ var usable = PlateOverlays.UsableOutline(plate);
+ scene.Usable = usable.Count == 0 ? null : SkiaFigures.Polygons(usable);
+ }
+ else
+ {
+ var edge = PlateOverlays.EdgeSpacing(plate);
+ // As WinForms: the edge-spacing line is left out while it surrounds the whole view.
+ if (!Contains(edge, visible))
+ scene.EdgeSpacing = edge;
}
- context.Custom(new PlateDrawOperation(new Rect(Bounds.Size), plate == null ? null : PlateRect(plate),
- plate?.EdgeSpacing, WorldMatrix, items, palette));
+ var palette = scene.Palette;
+ for (var i = 0; i < plate.Parts.Count; i++)
+ {
+ var part = plate.Parts[i];
+ var partPaths = PathsFor(part);
+ var bounds = partPaths.StrokeBounds;
+ var placed = bounds == null ? null : bounds.Translate(part.Location.X, part.Location.Y);
+ if (placed == null || !Intersects(placed, visible))
+ continue;
+ var color = ToSkColor(part.BaseDrawing.Color);
+ scene.Parts.Add(new PartItem(partPaths, part.Location, placed, color, palette.PartOutline(color),
+ (i + 1).ToString(), LabelPoint(part), ReferenceEquals(part, SelectedPart),
+ ReferenceEquals(part, HoveredPart), part.BaseDrawing.IsCutOff));
+ }
+
+ if (options.DrawOffset && plate.PartSpacing > 0)
+ {
+ foreach (var part in plate.Parts)
+ scene.Offsets.Add((OffsetFor(part, plate.PartSpacing, options.OffsetTolerance), part.Location));
+ }
+ if (options.DrawBounds && SelectedPart != null)
+ scene.SelectionBounds = PlateOverlays.SelectionBounds(new[] { SelectedPart });
+ if (options.DrawRapid)
+ scene.Rapids = PlateOverlays.Rapids(plate);
+ if (options.DrawPiercePoints)
+ scene.PiercePoints = PlateOverlays.PiercePoints(plate);
+ if (options.DrawCutDirection)
+ scene.Arrows = PlateOverlays.CutDirection(plate, view.LengthToWorld(60));
+
+ scene.Clamps = PlateOverlays.Clamps(plate);
+ scene.RemovedClamps = PlateOverlays.RemovedClamps(plate);
+ scene.CutOffStrokes = PlateOverlays.CutOffStrokes(plate);
+ BuildCommonLines(scene);
+ if (options.DrawSupport)
+ BuildSupport(scene);
}
+ private void BuildCommonLines(Scene scene)
+ {
+ if (plate.CommonLines is not { Count: > 0 } groups)
+ {
+ commonLineChecks.Clear();
+ return;
+ }
+ foreach (var stale in commonLineChecks.Keys.Where(group => !groups.Contains(group)).ToList())
+ commonLineChecks.Remove(stale);
+ foreach (var group in groups)
+ {
+ var state = string.Join(";", group.Members.Select(m =>
+ $"{m.Location.X:R},{m.Location.Y:R},{m.Rotation:R},{RuntimeHelpers.GetHashCode(m.BaseDrawing.Program)}"));
+ if (!commonLineChecks.TryGetValue(group, out var cached) || cached.State != state)
+ {
+ cached = (state, CommonLineGeometry.Check(group));
+ commonLineChecks[group] = cached;
+ }
+ foreach (var path in CommonLineGeometry.Paths(cached.Check.Spans, group.Kerf))
+ scene.CommonLines.Add(path.Points);
+ foreach (var problem in cached.Check.Problems)
+ scene.CommonLineProblems.Add(problem.Part.BoundingBox);
+ }
+ }
+
+ private void BuildSupport(Scene scene)
+ {
+ if (plate.SupportBed is not { } bed)
+ return;
+ // Grid points and slats only while they are at least 6 pixels apart on screen.
+ const double minimumSpacing = 6;
+ if (bed is SupportGrid grid
+ && view.LengthToScreen(System.Math.Min(grid.PitchX, grid.PitchY)) >= minimumSpacing)
+ scene.GridPoints = grid.PointsIn(scene.Sheet, plate.Quadrant);
+ else if (bed is SupportSlats slats && view.LengthToScreen(slats.Pitch) >= minimumSpacing)
+ scene.Slats = slats.SlatsIn(scene.Sheet, plate.Quadrant);
+
+ var clearance = PlateOverlays.SupportClearance(plate);
+ foreach (var part in plate.Parts)
+ {
+ var support = supports.Analyze(part, bed, plate.Quadrant, clearance);
+ if (support is not { IsComplete: true })
+ continue;
+ scene.Supports.Add(new SupportItem(support.Contacts, support.CenterOfGravity, support.IsSupported,
+ support.IsSupported ? null : support.BestTabPoint));
+ }
+ }
+
+ private static bool Contains(Box outer, Box inner) =>
+ inner.Left >= outer.Left && inner.Right <= outer.Right && inner.Bottom >= outer.Bottom && inner.Top <= outer.Top;
+
+ private static bool Intersects(Box a, Box b) =>
+ a.Left <= b.Right && a.Right >= b.Left && a.Bottom <= b.Top && a.Top >= b.Bottom;
+
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;
+ view.ZoomAt(new ScreenPoint(at.X, at.Y), System.Math.Pow(1.2, e.Delta.Y));
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
e.Handled = true;
@@ -190,7 +335,7 @@ namespace OpenNest.Avalonia.Controls
var at = e.GetPosition(this);
if (dragStart is { } start)
{
- origin += at - start;
+ view.Pan(at.X - start.X, at.Y - start.Y);
dragStart = at;
InvalidateVisual();
}
@@ -234,11 +379,11 @@ namespace OpenNest.Avalonia.Controls
protected override void OnSizeChanged(global::Avalonia.Controls.SizeChangedEventArgs e)
{
base.OnSizeChanged(e);
- if (fitPending)
- InvalidateVisual();
+ view.Resize(e.NewSize.Width, e.NewSize.Height);
+ InvalidateVisual();
}
- /// The topmost part whose material contains .
+ /// The topmost part whose material (or, for an etch-only part, closed mark) holds .
private Part HitTest(Vector world)
{
if (plate == null)
@@ -250,35 +395,57 @@ namespace OpenNest.Avalonia.Controls
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))
+ if (PathsFor(part).Selection.Contains((float)local.X, (float)local.Y))
return part;
}
return null;
}
- private sealed record PartItem(ProgramPaths Paths, Vector Location, Box Bounds, SKColor Color,
- SKColor Outline, string Label, bool Selected, bool Hovered, bool IsCutOff, bool ShowLeads);
+ private sealed record PartItem(PartPaths Paths, Vector Location, Box Bounds, SKColor Color, SKColor Outline,
+ string Label, Vector LabelPoint, bool Selected, bool Hovered, bool IsCutOff);
+
+ private sealed record SupportItem(IReadOnlyList Contacts, Vector CenterOfGravity, bool Supported,
+ Vector? Tab);
+
+ /// Everything one frame draws, built on the UI thread and only read on the render thread.
+ private sealed class Scene
+ {
+ public Rect Bounds;
+ public SkiaSchemePalette Palette;
+ public SKMatrix Matrix;
+ public double Scale;
+ public bool DrawOrigin;
+ public Box Sheet;
+ public SKPath SheetOutline;
+ public SKPath Obstacles;
+ public SKPath Usable;
+ public Box EdgeSpacing;
+ public readonly List Parts = new();
+ public readonly List<(SKPath Path, Vector Location)> Offsets = new();
+ public Box SelectionBounds;
+ public IReadOnlyList Rapids;
+ public IReadOnlyList PiercePoints;
+ public IReadOnlyList Arrows;
+ public IReadOnlyList Clamps;
+ public IReadOnlyList RemovedClamps;
+ public IReadOnlyList CutOffStrokes;
+ public readonly List> CommonLines = new();
+ public readonly List CommonLineProblems = new();
+ public IReadOnlyList GridPoints;
+ public IReadOnlyList Slats;
+ public readonly List Supports = new();
+ }
private sealed class PlateDrawOperation : ICustomDrawOperation
{
- private readonly Box plateRect;
- private readonly Spacing? edgeSpacing;
- private readonly SKMatrix matrix;
- private readonly List items;
- private readonly SkiaSchemePalette palette;
+ private readonly Scene scene;
- public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
- List items, SkiaSchemePalette palette)
+ public PlateDrawOperation(Scene scene)
{
- this.palette = palette;
- Bounds = bounds;
- this.plateRect = plateRect;
- this.edgeSpacing = edgeSpacing;
- this.matrix = matrix;
- this.items = items;
+ this.scene = scene;
}
- public Rect Bounds { get; }
+ public Rect Bounds => scene.Bounds;
public bool HitTest(Point p) => Bounds.Contains(p);
@@ -297,86 +464,422 @@ namespace OpenNest.Avalonia.Controls
var canvas = api.SkCanvas;
canvas.Save();
canvas.ClipRect(new SKRect(0, 0, (float)Bounds.Width, (float)Bounds.Height));
- canvas.Clear(palette.Background);
+ canvas.Clear(scene.Palette.Background);
+ new PlatePainter(canvas, scene).Paint();
+ canvas.Restore();
+ }
+ }
- if (plateRect != null)
- Draw(canvas);
+ ///
+ /// Paints a scene in the WinForms view's order and pen sizes: widths and dashes are screen
+ /// pixels, so parts drawn in world units scale them by the zoom.
+ ///
+ private sealed class PlatePainter
+ {
+ private static readonly SKColor LeadColor = new(255, 69, 0); // OrangeRed
+ private static readonly SKColor DisplayColor = new(70, 110, 140);
+ private static readonly SKColor OffsetColor = new(255, 100, 100, 120);
+ private static readonly SKColor CutDirectionColor = new(60, 60, 60);
+ private static readonly SKColor PierceFill = new(255, 0, 0);
+ private static readonly SKColor PierceOutline = new(139, 0, 0); // DarkRed
+ private static readonly SKColor ClampHatch = new(70, 80, 90, 150);
+ private static readonly SKColor ClampBack = new(230, 170, 40, 70);
+ private static readonly SKColor ClampOutline = new(150, 100, 0, 220);
+ private static readonly SKColor RemovedClampOutline = new(110, 110, 110, 200);
+ private static readonly SKColor ObstacleHatch = new(120, 60, 60, 170);
+ private static readonly SKColor ObstacleBack = new(200, 80, 80, 60);
+ private static readonly SKColor CutOffColor = new(255, 69, 0); // OrangeRed
+ private static readonly SKColor CommonLineColor = new(0, 150, 136);
+ private static readonly SKColor BrokenCommonLineColor = new(255, 0, 0);
+ private static readonly SKColor GridColor = new(120, 120, 120, 110);
+ private static readonly SKColor SlatColor = new(120, 120, 120, 90);
+ private static readonly SKColor ContactColor = new(30, 150, 60, 220);
+ private static readonly SKColor SupportedColor = new(20, 140, 50, 230);
+ private static readonly SKColor TippingColor = new(210, 30, 30, 240);
+ private static readonly SKColor TabColor = new(240, 140, 0, 240);
+ // The WinForms default font, bold and underlined, for part numbers.
+ private static readonly SKTypeface LabelTypeface =
+ SKTypeface.FromFamilyName(null, SKFontStyle.Bold) ?? SKTypeface.Default;
+
+ private readonly SKCanvas canvas;
+ private readonly Scene scene;
+ // Not readonly: SKCanvas.Concat takes it by reference.
+ private SKMatrix matrix;
+ private readonly float scale;
+
+ public PlatePainter(SKCanvas canvas, Scene scene)
+ {
+ this.canvas = canvas;
+ this.scene = scene;
+ matrix = scene.Matrix;
+ scale = (float)scene.Scale;
+ }
+
+ private SKPoint Screen(Vector world) => matrix.MapPoint((float)world.X, (float)world.Y);
+
+ private SKRect Screen(Box box)
+ {
+ var a = Screen(new Vector(box.Left, box.Top));
+ var b = Screen(new Vector(box.Right, box.Bottom));
+ return new SKRect(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));
+ }
+
+ private static SKPaint Stroke(SKColor color, float width = 1, float[] dash = null) => new()
+ {
+ Style = SKPaintStyle.Stroke,
+ Color = color,
+ StrokeWidth = width,
+ IsAntialias = true,
+ PathEffect = dash == null ? null : SKPathEffect.CreateDash(dash, 0),
+ };
+
+ private static SKPaint Fill(SKColor color) => new() { Style = SKPaintStyle.Fill, Color = color, IsAntialias = true };
+
+ // A pen of screen width in the world frame: width 1 stays a one-pixel hairline.
+ private SKPaint WorldStroke(SKColor color, float width = 1, float[] dash = null) =>
+ Stroke(color, width <= 1 && dash == null ? 0 : width / scale,
+ dash?.Select(d => d / scale).ToArray());
+
+ public void Paint()
+ {
+ if (scene.DrawOrigin)
+ {
+ using var origin = Stroke(scene.Palette.Origin);
+ origin.IsAntialias = false;
+ var o = Screen(Vector.Zero);
+ canvas.DrawLine(o.X, 0, o.X, (float)scene.Bounds.Height, origin);
+ canvas.DrawLine(0, o.Y, (float)scene.Bounds.Width, o.Y, origin);
+ }
+
+ if (scene.Sheet == null)
+ return;
+
+ DrawSheet();
+ DrawParts();
+ DrawOffsets();
+ if (scene.SelectionBounds != null)
+ {
+ using var pen = Stroke(scene.Palette.BoundingBox);
+ canvas.DrawRect(Screen(scene.SelectionBounds), pen);
+ }
+ DrawRapids();
+ DrawPiercePoints();
+ DrawCutDirection();
+ DrawClamps();
+ DrawCutOffs();
+ DrawCommonLines();
+ DrawSupport();
+ }
+
+ private void DrawSheet()
+ {
+ var palette = scene.Palette;
+ canvas.Save();
+ canvas.Concat(ref matrix);
+ using var sheetFill = Fill(palette.PlateFill);
+ if (scene.SheetOutline != null)
+ {
+ canvas.DrawPath(scene.SheetOutline, sheetFill);
+ canvas.Restore();
+ if (scene.Obstacles != null)
+ {
+ HatchPath(scene.Obstacles, ObstacleHatch, ObstacleBack);
+ using var obstaclePen = WorldStroke(ObstacleHatch, 1.5f);
+ DrawWorld(scene.Obstacles, obstaclePen);
+ }
+ if (scene.Usable != null)
+ {
+ using var usablePen = WorldStroke(palette.EdgeSpacing, 1, new float[] { 3, 3 });
+ DrawWorld(scene.Usable, usablePen);
+ }
+ using var outlinePen = WorldStroke(palette.PlateOutline);
+ DrawWorld(scene.SheetOutline, outlinePen);
+ return;
+ }
+
+ var sheet = scene.Sheet;
+ canvas.DrawRect(new SKRect((float)sheet.Left, (float)sheet.Bottom, (float)sheet.Right, (float)sheet.Top),
+ sheetFill);
+ canvas.Restore();
+ if (scene.EdgeSpacing != null)
+ {
+ using var edgePen = Stroke(palette.EdgeSpacing, 1, new float[] { 3, 3 });
+ edgePen.IsAntialias = false;
+ canvas.DrawRect(Screen(scene.EdgeSpacing), edgePen);
+ }
+ using var outline = Stroke(palette.PlateOutline);
+ outline.IsAntialias = false;
+ canvas.DrawRect(Screen(sheet), outline);
+ }
+
+ private void DrawWorld(SKPath path, SKPaint paint)
+ {
+ canvas.Save();
+ canvas.Concat(ref matrix);
+ canvas.DrawPath(path, paint);
canvas.Restore();
}
- private void Draw(SKCanvas canvas)
+ // GDI+ WideUpwardDiagonal: three-pixel "/" stripes every eight pixels, in screen space.
+ private void HatchPath(SKPath worldPath, SKColor hatch, SKColor back)
{
- // 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 };
+ using var screenPath = new SKPath(worldPath);
+ screenPath.Transform(matrix);
+ using var shader = HatchShader(hatch, back);
+ using var paint = new SKPaint { Style = SKPaintStyle.Fill, Shader = shader };
+ canvas.DrawPath(screenPath, paint);
+ }
- canvas.Save();
- var world = matrix;
- canvas.Concat(ref world);
+ private void HatchRect(SKRect rect, SKColor hatch, SKColor back)
+ {
+ using var shader = HatchShader(hatch, back);
+ using var paint = new SKPaint { Style = SKPaintStyle.Fill, Shader = shader };
+ canvas.DrawRect(rect, paint);
+ }
- var plate = new SKRect((float)plateRect.Left, (float)plateRect.Bottom,
- (float)plateRect.Right, (float)plateRect.Top);
- fill.Color = palette.PlateFill;
- canvas.DrawRect(plate, fill);
- if (edgeSpacing is { } spacing)
+ private static SKShader HatchShader(SKColor hatch, SKColor back)
+ {
+ using var bitmap = new SKBitmap(8, 8, SKColorType.Rgba8888, SKAlphaType.Unpremul);
+ for (var y = 0; y < 8; y++)
+ for (var x = 0; x < 8; x++)
+ bitmap.SetPixel(x, y, (x + y) % 8 < 3 ? hatch : back);
+ return SKShader.CreateBitmap(bitmap, SKShaderTileMode.Repeat, SKShaderTileMode.Repeat);
+ }
+
+ private void DrawParts()
+ {
+ var palette = scene.Palette;
+ using var fill = Fill(SKColors.Black);
+ using var cut = WorldStroke(SKColors.Black);
+ using var etch = WorldStroke(palette.Etch, 1.5f);
+ using var lead = WorldStroke(LeadColor, 1.5f);
+ using var display = WorldStroke(DisplayColor, 1, new float[] { 6, 3 });
+ using var text = new SKPaint
{
- stroke.Color = palette.EdgeSpacing;
- 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 = palette.PlateOutline;
- canvas.DrawRect(plate, stroke);
+ Color = SKColors.Black,
+ IsAntialias = true,
+ TextSize = 11,
+ Typeface = LabelTypeface,
+ TextAlign = SKTextAlign.Center,
+ };
+ using var underline = Stroke(SKColors.Black);
+ var metrics = text.FontMetrics;
+ var textHeight = metrics.Descent - metrics.Ascent;
- var view = canvas.LocalClipBounds;
- foreach (var item in items)
+ foreach (var item in scene.Parts)
{
- 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.Concat(ref matrix);
canvas.Translate((float)item.Location.X, (float)item.Location.Y);
fill.Color = item.Selected ? palette.SelectedFill
: item.Hovered ? Lighten(item.Color) : item.Color;
- canvas.DrawPath(item.Paths.Fill, fill);
-
- stroke.Color = palette.Etch;
- canvas.DrawPath(item.Paths.Etch, stroke);
+ canvas.DrawPath(item.Paths.Material, fill);
+ if (item.Paths.Display != null)
+ canvas.DrawPath(item.Paths.Display, display);
+ canvas.DrawPath(item.Paths.Etch, etch);
// Keep real cuts visible even where an etch overlaps them.
- stroke.Color = item.Selected ? palette.SelectedOutline : item.Outline;
- canvas.DrawPath(item.Paths.Cut, stroke);
+ cut.Color = item.Selected ? palette.SelectedOutline : item.Outline;
+ canvas.DrawPath(item.Paths.Cut, cut);
// As on the desktop, only manually placed lead-ins are shown.
- if (item.ShowLeads)
- {
- stroke.Color = LeadColor;
- canvas.DrawPath(item.Paths.Lead, stroke);
- }
-
+ canvas.DrawPath(item.Paths.Lead, lead);
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)
- {
+ // A number shows only once the part is big enough on screen to hold it.
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)
+ var bounds = Screen(item.Bounds);
+ var width = text.MeasureText(item.Label);
+ if (bounds.Width < width || bounds.Height < textHeight)
continue;
- canvas.DrawText(item.Label, center.X - measured / 2, center.Y + text.TextSize / 3, text);
+ var at = Screen(item.LabelPoint);
+ var baseline = at.Y - (metrics.Ascent + metrics.Descent) / 2;
+ canvas.DrawText(item.Label, at.X, baseline, text);
+ var lineY = baseline + System.Math.Max(1, metrics.UnderlinePosition ?? 1.5f);
+ canvas.DrawLine(at.X - width / 2, lineY, at.X + width / 2, lineY, underline);
+ }
+ }
+
+ private void DrawOffsets()
+ {
+ if (scene.Offsets.Count == 0)
+ return;
+ using var pen = WorldStroke(OffsetColor);
+ foreach (var (path, location) in scene.Offsets)
+ {
+ canvas.Save();
+ canvas.Concat(ref matrix);
+ canvas.Translate((float)location.X, (float)location.Y);
+ canvas.DrawPath(path, pen);
+ canvas.Restore();
+ }
+ }
+
+ private void DrawRapids()
+ {
+ if (scene.Rapids == null)
+ return;
+ using var pen = Stroke(scene.Palette.Rapid, 1, new float[] { 10, 10 });
+ foreach (var segment in scene.Rapids)
+ canvas.DrawLine(Screen(segment.From), Screen(segment.To), pen);
+ }
+
+ private void DrawPiercePoints()
+ {
+ if (scene.PiercePoints == null)
+ return;
+ using var fill = Fill(PierceFill);
+ using var pen = Stroke(PierceOutline);
+ foreach (var point in scene.PiercePoints)
+ {
+ var p = Screen(point);
+ canvas.DrawCircle(p, 2, fill);
+ canvas.DrawCircle(p, 2, pen);
+ }
+ }
+
+ private void DrawCutDirection()
+ {
+ if (scene.Arrows == null)
+ return;
+ const float size = 6;
+ using var pen = Stroke(CutDirectionColor, 1.5f);
+ foreach (var sample in scene.Arrows)
+ {
+ var tip = Screen(sample.Position);
+ // Screen space flips Y, so the screen angle mirrors the world tangent.
+ var angle = System.Math.Atan2(-sample.Direction.Y, sample.Direction.X);
+ foreach (var side in new[] { 0.5, -0.5 })
+ {
+ var a = angle + System.Math.PI + side;
+ canvas.DrawLine(tip.X + size * (float)System.Math.Cos(a), tip.Y + size * (float)System.Math.Sin(a),
+ tip.X, tip.Y, pen);
+ }
+ }
+ }
+
+ // Machine clamps are fixtures, not material: a translucent hatched amber keep-out.
+ private void DrawClamps()
+ {
+ using var outline = Stroke(ClampOutline, 1.5f);
+ foreach (var clamp in scene.Clamps)
+ {
+ var rect = Screen(clamp);
+ HatchRect(rect, ClampHatch, ClampBack);
+ canvas.DrawRect(rect, outline);
+ }
+
+ using var removed = Stroke(RemovedClampOutline, 1.5f, new float[] { 4.5f, 1.5f });
+ using var text = new SKPaint { Color = RemovedClampOutline, IsAntialias = true, TextSize = 9 };
+ foreach (var clamp in scene.RemovedClamps)
+ {
+ var rect = Screen(clamp);
+ canvas.DrawRect(rect, removed);
+ canvas.DrawText("remove", rect.Left, rect.Top - text.FontMetrics.Descent, text);
+ }
+ }
+
+ // Scrap cut-off lines stand out from the dark part outlines: a saturated orange-red dash.
+ private void DrawCutOffs()
+ {
+ using var pen = Stroke(CutOffColor, 2, new float[] { 6, 2 });
+ foreach (var stroke in scene.CutOffStrokes)
+ canvas.DrawLine(Screen(stroke.StartPoint), Screen(stroke.EndPoint), pen);
+
+ // Each segment's number over the line, on a disc of the line's colour.
+ using var disc = Fill(CutOffColor);
+ using var text = new SKPaint
+ {
+ Color = SKColors.White,
+ IsAntialias = true,
+ TextSize = 11,
+ Typeface = LabelTypeface,
+ TextAlign = SKTextAlign.Center,
+ };
+ var metrics = text.FontMetrics;
+ var textHeight = metrics.Descent - metrics.Ascent;
+ foreach (var item in scene.Parts)
+ {
+ if (!item.IsCutOff)
+ continue;
+ var bounds = Screen(item.Bounds);
+ var diameter = System.Math.Max(text.MeasureText(item.Label), textHeight) + 2;
+ if (System.Math.Max(bounds.Width, bounds.Height) < diameter)
+ continue;
+ var at = Screen(item.LabelPoint);
+ canvas.DrawCircle(at, diameter / 2, disc);
+ canvas.DrawText(item.Label, at.X, at.Y - (metrics.Ascent + metrics.Descent) / 2, text);
+ }
+ }
+
+ private void DrawCommonLines()
+ {
+ using var pen = Stroke(CommonLineColor, 2, new float[] { 6, 2 });
+ foreach (var points in scene.CommonLines)
+ for (var i = 1; i < points.Count; i++)
+ canvas.DrawLine(Screen(points[i - 1]), Screen(points[i]), pen);
+ using var broken = Stroke(BrokenCommonLineColor, 2);
+ foreach (var box in scene.CommonLineProblems)
+ canvas.DrawRect(Screen(box), broken);
+ }
+
+ private void DrawSupport()
+ {
+ if (scene.GridPoints != null)
+ {
+ // Whole-pixel squares, not anti-aliased dots, so every point is the same size.
+ using var grid = new SKPaint { Style = SKPaintStyle.Fill, Color = GridColor, IsAntialias = false };
+ foreach (var point in scene.GridPoints)
+ {
+ var p = Screen(point);
+ var x = (float)System.Math.Round(p.X) - 1;
+ var y = (float)System.Math.Round(p.Y) - 1;
+ canvas.DrawRect(x, y, 3, 3, grid);
+ }
+ }
+ if (scene.Slats != null)
+ {
+ using var slat = new SKPaint { Style = SKPaintStyle.Fill, Color = SlatColor, IsAntialias = false };
+ foreach (var box in scene.Slats)
+ {
+ var rect = Screen(box);
+ // At least a pixel wide however far out the view is.
+ if (rect.Width < 1)
+ rect = new SKRect(rect.MidX - 0.5f, rect.Top, rect.MidX + 0.5f, rect.Bottom);
+ if (rect.Height < 1)
+ rect = new SKRect(rect.Left, rect.MidY - 0.5f, rect.Right, rect.MidY + 0.5f);
+ canvas.DrawRect(rect, slat);
+ }
+ }
+
+ using var contact = Fill(ContactColor);
+ using var supported = Stroke(SupportedColor, 2);
+ using var tipping = Stroke(TippingColor, 2);
+ using var tab = Fill(TabColor);
+ foreach (var item in scene.Supports)
+ {
+ foreach (var point in item.Contacts)
+ canvas.DrawCircle(Screen(point), 2.5f, contact);
+ const float size = 6;
+ var c = Screen(item.CenterOfGravity);
+ var pen = item.Supported ? supported : tipping;
+ canvas.DrawCircle(c, size, pen);
+ canvas.DrawLine(c.X - size, c.Y, c.X + size, c.Y, pen);
+ canvas.DrawLine(c.X, c.Y - size, c.X, c.Y + size, pen);
+ if (item.Tab is { } point2)
+ {
+ const float d = 5;
+ var t = Screen(point2);
+ using var diamond = new SKPath();
+ diamond.MoveTo(t.X, t.Y - d);
+ diamond.LineTo(t.X + d, t.Y);
+ diamond.LineTo(t.X, t.Y + d);
+ diamond.LineTo(t.X - d, t.Y);
+ diamond.Close();
+ canvas.DrawPath(diamond, tab);
+ }
}
}
diff --git a/OpenNest.Avalonia/MainWindow.View.cs b/OpenNest.Avalonia/MainWindow.View.cs
new file mode 100644
index 00000000..c571be24
--- /dev/null
+++ b/OpenNest.Avalonia/MainWindow.View.cs
@@ -0,0 +1,92 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using OpenNest.Data;
+using OpenNest.Desktop.Rendering;
+
+namespace OpenNest.Avalonia
+{
+ ///
+ /// View > Draw Rapids, Pierce Points, Bounds, Offset, Cut Direction and Support: toggles of the
+ /// active nest's plate view, as each WinForms nest window keeps its own. Rapids and bounds are
+ /// read from the shared user settings when a nest opens and written back when it closes.
+ ///
+ public partial class MainWindow
+ {
+ private void RestoreViewToggles(NestView view) =>
+ view.PlateView.Options = snapshotMode
+ ? new PlateViewOptions()
+ : PlateViewOptions.Read(UserSettingsFile.Read(UserSettingsFile.DefaultPath));
+
+ // A snapshot must not depend on the user's settings.
+ private void UseDefaultViewToggles()
+ {
+ foreach (var view in views.Values)
+ {
+ view.PlateView.Options = new PlateViewOptions();
+ view.PlateView.InvalidateVisual();
+ }
+ UpdateViewToggles();
+ }
+
+ private void SaveViewToggles(NestView view)
+ {
+ if (snapshotMode)
+ return;
+ UserSettingsFile.Update(UserSettingsFile.DefaultPath, view.PlateView.Options.WriteTo);
+ }
+
+ // As the WinForms nest windows closing in turn: the last one's toggles are kept.
+ private void SaveAllViewToggles()
+ {
+ foreach (var tab in tabs.Tabs)
+ {
+ if (views.TryGetValue(tab, out var view))
+ SaveViewToggles(view);
+ }
+ }
+
+ private void UpdateViewToggles()
+ {
+ var options = ActiveView?.PlateView.Options;
+ foreach (var (item, value) in new (MenuItem, Func)[]
+ {
+ (DrawRapidsMenuItem, o => o.DrawRapid),
+ (DrawPiercePointsMenuItem, o => o.DrawPiercePoints),
+ (DrawBoundsMenuItem, o => o.DrawBounds),
+ (DrawOffsetMenuItem, o => o.DrawOffset),
+ (DrawCutDirectionMenuItem, o => o.DrawCutDirection),
+ (DrawSupportMenuItem, o => o.DrawSupport),
+ })
+ {
+ item.IsEnabled = options != null;
+ item.IsChecked = options != null && value(options);
+ }
+ }
+
+ private void Toggle(Action toggle)
+ {
+ if (ActiveView is { } view)
+ {
+ toggle(view.PlateView.Options);
+ view.PlateView.InvalidateVisual();
+ }
+ UpdateViewToggles();
+ }
+
+ private void OnToggleDrawRapids(object sender, RoutedEventArgs e) => Toggle(o => o.DrawRapid = !o.DrawRapid);
+
+ private void OnToggleDrawPiercePoints(object sender, RoutedEventArgs e) =>
+ Toggle(o => o.DrawPiercePoints = !o.DrawPiercePoints);
+
+ private void OnToggleDrawBounds(object sender, RoutedEventArgs e) => Toggle(o => o.DrawBounds = !o.DrawBounds);
+
+ private void OnToggleDrawOffset(object sender, RoutedEventArgs e) => Toggle(o => o.DrawOffset = !o.DrawOffset);
+
+ private void OnToggleDrawCutDirection(object sender, RoutedEventArgs e) =>
+ Toggle(o => o.DrawCutDirection = !o.DrawCutDirection);
+
+ private void OnToggleDrawSupport(object sender, RoutedEventArgs e) =>
+ Toggle(o => o.DrawSupport = !o.DrawSupport);
+ }
+}
diff --git a/OpenNest.Avalonia/MainWindow.axaml b/OpenNest.Avalonia/MainWindow.axaml
index b896f8a8..de1e2faf 100644
--- a/OpenNest.Avalonia/MainWindow.axaml
+++ b/OpenNest.Avalonia/MainWindow.axaml
@@ -21,6 +21,19 @@