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 @@ + + + + + + + diff --git a/OpenNest.Avalonia/MainWindow.axaml.cs b/OpenNest.Avalonia/MainWindow.axaml.cs index b2ff72a1..0ed9e87f 100644 --- a/OpenNest.Avalonia/MainWindow.axaml.cs +++ b/OpenNest.Avalonia/MainWindow.axaml.cs @@ -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; } diff --git a/OpenNest.Avalonia/Rendering/SkiaFigures.cs b/OpenNest.Avalonia/Rendering/SkiaFigures.cs new file mode 100644 index 00000000..1f4b9d8f --- /dev/null +++ b/OpenNest.Avalonia/Rendering/SkiaFigures.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using OpenNest.Desktop.Rendering; +using OpenNest.Geometry; +using SkiaSharp; + +namespace OpenNest.Avalonia.Rendering +{ + /// Converts the shared plate-view figures to Skia paths. + internal static class SkiaFigures + { + public static SKPath ToPath(IEnumerable 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> 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; + } + } + + /// + /// 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. + /// + 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; } + + /// Display-only geometry; null when the drawing has none. + public SKPath Display { get; } + + /// What picks the part: its material, or an etch-only part's marks. + public SKPath Selection { get; } + + /// The bounds of every stroke in the program frame; null when nothing is drawn. + public Box StrokeBounds { get; } + + public static PartPaths Build(Part part) => new(PartDisplayGeometry.Build(part)); + } +} diff --git a/OpenNest.Avalonia/Rendering/SkiaSchemePalette.cs b/OpenNest.Avalonia/Rendering/SkiaSchemePalette.cs index 23294324..7dc66f96 100644 --- a/OpenNest.Avalonia/Rendering/SkiaSchemePalette.cs +++ b/OpenNest.Avalonia/Rendering/SkiaSchemePalette.cs @@ -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; }