mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 10:15:07 -05:00
feat(avalonia): draw plates with everything the WinForms view shows
The plate canvas now draws from the shared OpenNest.Desktop geometry (ProgramPaths.cs is gone) and maps through ViewTransform: tabbed cutouts fill as holes, break cuts stay out of the fill, display-only geometry is dashed, and etch-only parts select by their marks. It adds the origin axes, irregular sheets with hatched obstacles and their usable outline, part numbers at the label point, cut-off lines with their segment numbers, active and removed clamps, common lines, and the selection box. View gets Draw Rapids, Pierce Points, Bounds, Offset, Cut Direction and Support, per nest as in WinForms. Rapids and bounds are read from the shared user settings (PlateViewDrawRapid/PlateViewDrawBounds) when a nest opens and written when it closes; --snapshot ignores them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5edebc6144
commit
af31d783cc
6 files changed
+869
-140
No files matched your search
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Draws one plate with Skia in the colours of <see cref="Scheme"/>: 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
|
||||
/// <see cref="Scheme"/>: the sheet (or irregular outline), edge spacing, filled parts with their
|
||||
/// numbers, cut-offs, clamps, common lines and the View toggles in <see cref="Options"/>. 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 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<CNC.Program, ProgramPaths> 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<Part, PartPaths> paths = new();
|
||||
private readonly Dictionary<Drawing, (CNC.Program Program, double Rotation, Vector Point)> labelPoints = new();
|
||||
private readonly ConditionalWeakTable<Part, OffsetEntry> offsets = new();
|
||||
private readonly SupportAnalysisCache supports = new();
|
||||
private readonly Dictionary<CommonLineGroup, (string State, CommonLineCheck Check)> 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
|
||||
/// <summary>The colours drawn with: the application's active scheme unless set.</summary>
|
||||
public ColorScheme Scheme { get; set; } = ColorScheme.Default;
|
||||
|
||||
/// <summary>The View menu toggles this view draws with.</summary>
|
||||
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
|
||||
/// <summary>The cursor in plate coordinates, or null when it is outside the view.</summary>
|
||||
public Vector? CursorWorld { get; private set; }
|
||||
|
||||
/// <summary>Pixels per drawing unit.</summary>
|
||||
public double Zoom => view.Scale;
|
||||
|
||||
/// <summary>The view's world/screen mapping.</summary>
|
||||
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<Part>(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<Vector>)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<PartItem>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>The topmost part whose material contains <paramref name="world"/>.</summary>
|
||||
/// <summary>The topmost part whose material (or, for an etch-only part, closed mark) holds <paramref name="world"/>.</summary>
|
||||
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<Vector> Contacts, Vector CenterOfGravity, bool Supported,
|
||||
Vector? Tab);
|
||||
|
||||
/// <summary>Everything one frame draws, built on the UI thread and only read on the render thread.</summary>
|
||||
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<PartItem> Parts = new();
|
||||
public readonly List<(SKPath Path, Vector Location)> Offsets = new();
|
||||
public Box SelectionBounds;
|
||||
public IReadOnlyList<RapidEnumerator.Segment> Rapids;
|
||||
public IReadOnlyList<Vector> PiercePoints;
|
||||
public IReadOnlyList<ContourSample> Arrows;
|
||||
public IReadOnlyList<Box> Clamps;
|
||||
public IReadOnlyList<Box> RemovedClamps;
|
||||
public IReadOnlyList<Line> CutOffStrokes;
|
||||
public readonly List<IReadOnlyList<Vector>> CommonLines = new();
|
||||
public readonly List<Box> CommonLineProblems = new();
|
||||
public IReadOnlyList<Vector> GridPoints;
|
||||
public IReadOnlyList<Box> Slats;
|
||||
public readonly List<SupportItem> Supports = new();
|
||||
}
|
||||
|
||||
private sealed class PlateDrawOperation : ICustomDrawOperation
|
||||
{
|
||||
private readonly Box plateRect;
|
||||
private readonly Spacing? edgeSpacing;
|
||||
private readonly SKMatrix matrix;
|
||||
private readonly List<PartItem> items;
|
||||
private readonly SkiaSchemePalette palette;
|
||||
private readonly Scene scene;
|
||||
|
||||
public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
|
||||
List<PartItem> 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);
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Desktop.Rendering;
|
||||
|
||||
namespace OpenNest.Avalonia
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<PlateViewOptions, bool>)[]
|
||||
{
|
||||
(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<PlateViewOptions> 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);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,19 @@
|
||||
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem x:Name="DrawRapidsMenuItem" Header="Draw _Rapids" ToggleType="CheckBox"
|
||||
Click="OnToggleDrawRapids" />
|
||||
<MenuItem x:Name="DrawPiercePointsMenuItem" Header="Draw _Pierce Points" ToggleType="CheckBox"
|
||||
Click="OnToggleDrawPiercePoints" />
|
||||
<MenuItem x:Name="DrawBoundsMenuItem" Header="Draw _Bounds" ToggleType="CheckBox"
|
||||
Click="OnToggleDrawBounds" />
|
||||
<MenuItem x:Name="DrawOffsetMenuItem" Header="Draw _Offset" ToggleType="CheckBox"
|
||||
Click="OnToggleDrawOffset" />
|
||||
<MenuItem x:Name="DrawCutDirectionMenuItem" Header="Draw _Cut Direction" ToggleType="CheckBox"
|
||||
Click="OnToggleDrawCutDirection" />
|
||||
<MenuItem x:Name="DrawSupportMenuItem" Header="Draw _Support" ToggleType="CheckBox"
|
||||
Click="OnToggleDrawSupport" />
|
||||
<Separator />
|
||||
<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" />
|
||||
|
||||
@@ -113,6 +113,7 @@ namespace OpenNest.Avalonia
|
||||
if (!views.ContainsKey(tab))
|
||||
{
|
||||
var view = new NestView(tab);
|
||||
RestoreViewToggles(view);
|
||||
view.ViewChanged += (_, _) => { if (view == ActiveView) UpdateStatus(); };
|
||||
views[tab] = view;
|
||||
notice = null;
|
||||
@@ -151,6 +152,7 @@ namespace OpenNest.Avalonia
|
||||
{
|
||||
// No dialogs: the image is taken and the window closes unattended.
|
||||
snapshotMode = true;
|
||||
UseDefaultViewToggles();
|
||||
Opened += async (_, _) =>
|
||||
{
|
||||
ActiveView?.ShowPlate(plateNumber - 1);
|
||||
@@ -183,6 +185,7 @@ namespace OpenNest.Avalonia
|
||||
? "OpenNest (Avalonia prototype)"
|
||||
: $"{view.Tab.Name} - OpenNest (Avalonia prototype)";
|
||||
RebuildTabStrip();
|
||||
UpdateViewToggles();
|
||||
UpdateStatus();
|
||||
}
|
||||
|
||||
@@ -272,7 +275,8 @@ namespace OpenNest.Avalonia
|
||||
return false;
|
||||
|
||||
tabs.Close(tab);
|
||||
views.Remove(tab);
|
||||
if (views.Remove(tab, out var closed))
|
||||
SaveViewToggles(closed);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -296,6 +300,7 @@ namespace OpenNest.Avalonia
|
||||
if (closeConfirmed || (!tabs.AnyModified && !writingFile))
|
||||
{
|
||||
nesting?.Cancel();
|
||||
SaveAllViewToggles();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Desktop.Rendering;
|
||||
using OpenNest.Geometry;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace OpenNest.Avalonia.Rendering
|
||||
{
|
||||
/// <summary>Converts the shared plate-view figures to Skia paths.</summary>
|
||||
internal static class SkiaFigures
|
||||
{
|
||||
public static SKPath ToPath(IEnumerable<PathFigure> figures, SKPathFillType fillType = SKPathFillType.EvenOdd)
|
||||
{
|
||||
var path = new SKPath { FillType = fillType };
|
||||
foreach (var figure in figures)
|
||||
Add(path, figure);
|
||||
return path;
|
||||
}
|
||||
|
||||
public static void Add(SKPath path, PathFigure figure)
|
||||
{
|
||||
path.MoveTo((float)figure.Start.X, (float)figure.Start.Y);
|
||||
foreach (var segment in figure.Segments)
|
||||
{
|
||||
switch (segment)
|
||||
{
|
||||
case LineTo line:
|
||||
path.LineTo((float)line.End.X, (float)line.End.Y);
|
||||
break;
|
||||
|
||||
case ArcTo arc:
|
||||
{
|
||||
var r = (float)arc.Radius;
|
||||
var oval = new SKRect((float)arc.Center.X - r, (float)arc.Center.Y - r,
|
||||
(float)arc.Center.X + r, (float)arc.Center.Y + r);
|
||||
if (arc.IsFullCircle)
|
||||
{
|
||||
// Skia drops a whole-turn sweep; two halves keep the contour joined.
|
||||
var half = (float)(arc.SweepAngle / 2);
|
||||
path.ArcTo(oval, (float)arc.StartAngle, half, false);
|
||||
path.ArcTo(oval, (float)arc.StartAngle + half, half, false);
|
||||
}
|
||||
else
|
||||
path.ArcTo(oval, (float)arc.StartAngle, (float)arc.SweepAngle, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (figure.IsClosed && figure.Segments.Count > 0)
|
||||
path.Close();
|
||||
}
|
||||
|
||||
public static SKPath Polygons(IEnumerable<IReadOnlyList<Vector>> polygons)
|
||||
{
|
||||
var path = new SKPath { FillType = SKPathFillType.EvenOdd };
|
||||
foreach (var points in polygons)
|
||||
{
|
||||
if (points.Count == 0)
|
||||
continue;
|
||||
path.MoveTo((float)points[0].X, (float)points[0].Y);
|
||||
for (var i = 1; i < points.Count; i++)
|
||||
path.LineTo((float)points[i].X, (float)points[i].Y);
|
||||
path.Close();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A part's shared display geometry as Skia paths in its program frame, before placement.
|
||||
/// Built on the UI thread and only read afterwards, so the render thread may draw it.
|
||||
/// </summary>
|
||||
internal sealed class PartPaths
|
||||
{
|
||||
private PartPaths(PartDisplayGeometry geometry)
|
||||
{
|
||||
Geometry = geometry;
|
||||
Cut = SkiaFigures.ToPath(geometry.Cut);
|
||||
Lead = SkiaFigures.ToPath(geometry.Lead);
|
||||
Etch = SkiaFigures.ToPath(geometry.Etch);
|
||||
Material = SkiaFigures.ToPath(geometry.Material);
|
||||
Display = geometry.Display.Count > 0 ? SkiaFigures.ToPath(geometry.Display) : null;
|
||||
Selection = geometry.Cut.Count > 0 ? Material : Etch;
|
||||
StrokeBounds = geometry.StrokeBounds();
|
||||
}
|
||||
|
||||
public PartDisplayGeometry Geometry { get; }
|
||||
|
||||
public SKPath Cut { get; }
|
||||
|
||||
public SKPath Lead { get; }
|
||||
|
||||
public SKPath Etch { get; }
|
||||
|
||||
public SKPath Material { get; }
|
||||
|
||||
/// <summary>Display-only geometry; null when the drawing has none.</summary>
|
||||
public SKPath Display { get; }
|
||||
|
||||
/// <summary>What picks the part: its material, or an etch-only part's marks.</summary>
|
||||
public SKPath Selection { get; }
|
||||
|
||||
/// <summary>The bounds of every stroke in the program frame; null when nothing is drawn.</summary>
|
||||
public Box StrokeBounds { get; }
|
||||
|
||||
public static PartPaths Build(Part part) => new(PartDisplayGeometry.Build(part));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ namespace OpenNest.Avalonia.Rendering
|
||||
PlateOutline = ToSk(scheme.LayoutOutlineColor);
|
||||
EdgeSpacing = ToSk(scheme.EdgeSpacingColor);
|
||||
Etch = ToSk(scheme.EtchColor);
|
||||
Origin = ToSk(scheme.OriginColor);
|
||||
Rapid = ToSk(scheme.RapidColor);
|
||||
BoundingBox = ToSk(scheme.BoundingBoxColor);
|
||||
var selected = scheme.SelectedPartColor.IsEmpty ? LegacySelectedColor : scheme.SelectedPartColor;
|
||||
SelectedFill = ToSk(selected);
|
||||
// As WinForms: the scheme's outline, else the legacy selection fill darkened.
|
||||
@@ -49,6 +52,12 @@ namespace OpenNest.Avalonia.Rendering
|
||||
|
||||
public SKColor Etch { get; }
|
||||
|
||||
public SKColor Origin { get; }
|
||||
|
||||
public SKColor Rapid { get; }
|
||||
|
||||
public SKColor BoundingBox { get; }
|
||||
|
||||
public SKColor SelectedFill { get; }
|
||||
|
||||
public SKColor SelectedOutline { get; }
|
||||
|
||||
Reference in new issue
Block a user