From 049b45b394b9d0db20135200cbf1d82e05bd0c59 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 11 Nov 2026 21:40:51 -0500 Subject: [PATCH] feat(desktop): share plate-view geometry, view transform and overlays Port the WinForms GraphicsHelper program walker to a toolkit-neutral figure list (move/line/arc) with its layer routing: cuts without tabs, leads, etches, and a material fill without break cuts whose tab-gapped pieces are bridged. Suppressed moves are skipped and marks never bound the fill. PartDisplayGeometry picks the fill as LayoutPart does (common-line members, break cuts, open cuts) and keeps display-only entities; PartLabels ports the label points. ViewTransform carries DrawControl's world/screen mapping and zoom rules. PlateOverlays computes what the view draws around parts (sheet, edge spacing, irregular usable area, clamps, cut-off strokes, rapids, pierce points, cut direction, offset outline, support) through the existing Core code. PlateViewOptions reads and writes the View toggles under the WinForms PlateViewDrawRapid/PlateViewDrawBounds names. WinForms still draws with GraphicsHelper; nothing there changes. Co-Authored-By: Claude Opus 5.5 --- .../Rendering/PlateSceneTests.cs | 270 +++++++++++++ .../Rendering/ProgramGeometryTests.cs | 324 ++++++++++++++++ .../Rendering/ViewTransformTests.cs | 154 ++++++++ .../Rendering/PartDisplayGeometry.cs | 96 +++++ OpenNest.Desktop/Rendering/PartLabels.cs | 89 +++++ OpenNest.Desktop/Rendering/PathFigure.cs | 46 +++ OpenNest.Desktop/Rendering/PlateOverlays.cs | 205 ++++++++++ .../Rendering/PlateViewOptions.cs | 55 +++ OpenNest.Desktop/Rendering/ProgramGeometry.cs | 365 ++++++++++++++++++ OpenNest.Desktop/Rendering/ViewTransform.cs | 145 +++++++ 10 files changed, 1749 insertions(+) create mode 100644 OpenNest.Desktop.Tests/Rendering/PlateSceneTests.cs create mode 100644 OpenNest.Desktop.Tests/Rendering/ProgramGeometryTests.cs create mode 100644 OpenNest.Desktop.Tests/Rendering/ViewTransformTests.cs create mode 100644 OpenNest.Desktop/Rendering/PartDisplayGeometry.cs create mode 100644 OpenNest.Desktop/Rendering/PartLabels.cs create mode 100644 OpenNest.Desktop/Rendering/PathFigure.cs create mode 100644 OpenNest.Desktop/Rendering/PlateOverlays.cs create mode 100644 OpenNest.Desktop/Rendering/PlateViewOptions.cs create mode 100644 OpenNest.Desktop/Rendering/ProgramGeometry.cs create mode 100644 OpenNest.Desktop/Rendering/ViewTransform.cs diff --git a/OpenNest.Desktop.Tests/Rendering/PlateSceneTests.cs b/OpenNest.Desktop.Tests/Rendering/PlateSceneTests.cs new file mode 100644 index 00000000..4790ebcb --- /dev/null +++ b/OpenNest.Desktop.Tests/Rendering/PlateSceneTests.cs @@ -0,0 +1,270 @@ +using OpenNest.CNC; +using OpenNest.Desktop.Rendering; +using OpenNest.Geometry; +using OpenNest.Support; + +namespace OpenNest.Desktop.Tests.Rendering; + +public class PartGeometryTests +{ + private static Program Square(double size = 10) + { + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(new LinearMove(new Vector(size, 0))); + program.Codes.Add(new LinearMove(new Vector(size, size))); + program.Codes.Add(new LinearMove(new Vector(0, size))); + program.Codes.Add(new LinearMove(new Vector(0, 0))); + return program; + } + + [Fact] + public void A_plain_part_fills_its_cut_outline_and_hides_planned_leads() + { + var program = Square(); + program.Codes.Insert(1, new LinearMove(new Vector(0, 0)) { Layer = LayerType.Leadin }); + var part = new Part(new Drawing("square", program)); + + var geometry = PartDisplayGeometry.Build(part); + + Assert.Same(geometry.Cut, geometry.Material); + Assert.Empty(geometry.Lead); + Assert.Empty(geometry.Display); + Assert.Same(geometry.Material, geometry.Selection); + Assert.True(geometry.IsCurrentFor(part)); + } + + [Fact] + public void Break_cuts_fill_from_the_material_outline_only() + { + var program = Square(); + program.Codes.Add(new RapidMove(new Vector(5, 0))); + program.Codes.Add(new LinearMove(new Vector(5, 10)) { Layer = LayerType.Break }); + + var geometry = PartDisplayGeometry.Build(new Part(new Drawing("break", program))); + + Assert.Equal(2, geometry.Cut.Count); + Assert.True(Assert.Single(geometry.Material).IsClosed); + } + + [Fact] + public void An_etch_only_part_is_selected_by_its_marks() + { + var program = Square(); + foreach (var line in program.Codes.OfType()) + line.Layer = LayerType.Scribe; + + var geometry = PartDisplayGeometry.Build(new Part(new Drawing("etch", program))); + + Assert.Empty(geometry.Cut); + Assert.Empty(geometry.Material); + Assert.Same(geometry.Etch, geometry.Selection); + Assert.Single(geometry.Etch); + } + + [Fact] + public void A_common_line_member_cut_once_fills_with_its_drawing_material() + { + var part = new Part(new Drawing("member", Square())); + // Cut once: the left edge is the shared cut's, so the part's own program leaves it out. + part.Program.Codes.RemoveAt(part.Program.Codes.Count - 1); + part.Program.Codes.Add(new LinearMove(new Vector(0, 0)) { Layer = LayerType.CommonLine, Suppressed = true }); + + var geometry = PartDisplayGeometry.Build(part); + + Assert.True(Assert.Single(geometry.Cut).Segments.Count == 3); + var material = Assert.Single(geometry.Material); + Assert.True(material.IsClosed); + Assert.Equal(4, material.Segments.Count); + } + + [Fact] + public void A_rotation_makes_the_geometry_stale() + { + var part = new Part(new Drawing("square", Square())); + var geometry = PartDisplayGeometry.Build(part); + + part.Rotate(System.Math.PI / 2); + + Assert.False(geometry.IsCurrentFor(part)); + } + + [Fact] + public void Labels_sit_inside_the_material_and_cut_off_segments_at_their_middle() + { + var drawing = new Drawing("square", Square(20)); + var point = PartLabels.LabelPoint(drawing); + Assert.InRange(point.X, 9, 11); + Assert.InRange(point.Y, 9, 11); + + var part = new Part(drawing, new Vector(100, 50)); + var placed = PartLabels.Place(part, point); + Assert.Equal(point.X + 100, placed.X, 9); + Assert.Null(PartLabels.CutOffSegmentPoint(part)); + } +} + +public class PlateOverlaysTests +{ + private static Program Square(double size) + { + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(new LinearMove(new Vector(size, 0))); + program.Codes.Add(new LinearMove(new Vector(size, size))); + program.Codes.Add(new LinearMove(new Vector(0, size))); + program.Codes.Add(new LinearMove(new Vector(0, 0))); + return program; + } + + [Theory] + [InlineData(1, 0, 0)] + [InlineData(2, -96, 0)] + [InlineData(3, -96, -48)] + [InlineData(4, 0, -48)] + public void The_sheet_is_placed_in_its_quadrant(int quadrant, double left, double bottom) + { + var plate = new Plate(new Size(48, 96)) { Quadrant = quadrant, EdgeSpacing = new Spacing(1, 2, 3, 4) }; + + var sheet = PlateOverlays.Sheet(plate); + var edge = PlateOverlays.EdgeSpacing(plate); + + Assert.Equal((left, bottom, 96.0, 48.0), (sheet.X, sheet.Y, sheet.Length, sheet.Width)); + Assert.Equal(sheet.X + plate.EdgeSpacing.Left, edge.X, 9); + Assert.Equal(sheet.Y + plate.EdgeSpacing.Bottom, edge.Y, 9); + Assert.Equal(96 - plate.EdgeSpacing.Left - plate.EdgeSpacing.Right, edge.Length, 9); + Assert.Equal(48 - plate.EdgeSpacing.Top - plate.EdgeSpacing.Bottom, edge.Width, 9); + } + + [Fact] + public void Pierce_points_are_every_rapid_end_with_holes_at_their_offsets() + { + var hole = new Program(); + hole.Codes.Add(new RapidMove(new Vector(1, 0))); + hole.Codes.Add(new ArcMove(new Vector(1, 0), Vector.Zero)); + var program = Square(10); + program.Codes.Add(new SubProgramCall(hole, 0) { Offset = new Vector(5, 5) }); + var plate = new Plate(new Size(48, 96)); + plate.Parts.Add(new Part(new Drawing("holed", program), new Vector(20, 10))); + + var points = PlateOverlays.PiercePoints(plate); + + Assert.Equal(new[] { new Vector(20, 10), new Vector(26, 15) }, points); + } + + [Fact] + public void Rapids_and_cut_direction_come_from_the_core_walkers() + { + var plate = new Plate(new Size(48, 96)); + plate.Parts.Add(new Part(new Drawing("a", Square(10)), new Vector(20, 10))); + + var rapid = Assert.Single(PlateOverlays.Rapids(plate)); + Assert.Equal(new Vector(20, 10), rapid.To); + var samples = PlateOverlays.CutDirection(plate, 5); + Assert.NotEmpty(samples); + // The first edge runs +X. + Assert.Equal(1, samples[0].Direction.X, 6); + } + + [Fact] + public void Clamps_and_cut_off_strokes_are_placed_on_the_sheet() + { + var plate = new Plate(new Size(48, 96)) { Quadrant = 2 }; + plate.ClampZones.Add(new ClampZone("c1", new Box(10, 0, 4, 2))); + plate.CutOffs.Add(new CutOff(new Vector(-40, 0), CutOffAxis.Vertical)); + plate.RegenerateCutOffs(new CutOffSettings()); + + var clamp = Assert.Single(PlateOverlays.Clamps(plate)); + Assert.Equal(-86, clamp.X, 9); + var strokes = PlateOverlays.CutOffStrokes(plate); + Assert.NotEmpty(strokes); + Assert.All(strokes, s => Assert.Equal(-40, s.StartPoint.X, 6)); + Assert.Empty(PlateOverlays.RemovedClamps(plate)); + } + + [Fact] + public void The_offset_outline_grows_the_part_by_the_spacing() + { + var part = new Part(new Drawing("a", Square(10))); + + var outline = PlateOverlays.OffsetOutline(part, 1, 0.001); + + var outer = Assert.Single(outline); + var bounds = outer.Vertices.GetBoundingBox(); + Assert.Equal(-1, bounds.Left, 3); + Assert.Equal(11, bounds.Right, 3); + } + + [Fact] + public void Selection_bounds_are_null_when_nothing_is_selected() + { + Assert.Null(PlateOverlays.SelectionBounds(Array.Empty())); + var a = new Part(new Drawing("a", Square(10)), new Vector(0, 0)); + var b = new Part(new Drawing("b", Square(10)), new Vector(20, 5)); + var box = PlateOverlays.SelectionBounds(new[] { a, b }); + Assert.Equal((0.0, 0.0, 30.0, 15.0), (box.X, box.Y, box.Length, box.Width)); + } + + [Fact] + public void Support_analyses_are_cached_until_the_part_moves_and_skip_cut_offs() + { + var plate = new Plate(new Size(48, 96)) { SupportBed = new SupportGrid(2, 2) }; + var part = new Part(new Drawing("a", Square(10)), new Vector(20, 10)); + plate.Parts.Add(part); + var cache = new SupportAnalysisCache(); + var clearance = PlateOverlays.SupportClearance(plate); + + var first = cache.Analyze(part, plate.SupportBed, plate.Quadrant, clearance); + Assert.NotNull(first); + Assert.Same(first, cache.Analyze(part, plate.SupportBed, plate.Quadrant, clearance)); + part.Offset(1, 0); + Assert.NotSame(first, cache.Analyze(part, plate.SupportBed, plate.Quadrant, clearance)); + Assert.Equal(0, clearance); + } +} + +public class PlateViewOptionsTests +{ + [Fact] + public void Defaults_match_the_desktop_view() + { + var options = PlateViewOptions.Read(new Dictionary()); + + Assert.True(options.DrawOrigin); + Assert.False(options.DrawRapid); + Assert.True(options.DrawBounds); + Assert.False(options.DrawPiercePoints); + Assert.False(options.DrawOffset); + Assert.False(options.DrawCutDirection); + Assert.False(options.DrawSupport); + } + + [Fact] + public void Rapids_and_bounds_round_trip_under_the_winforms_names() + { + var settings = new Dictionary { ["Other"] = "kept" }; + new PlateViewOptions { DrawRapid = true, DrawBounds = false, DrawSupport = true }.WriteTo(settings); + + Assert.Equal("True", settings["PlateViewDrawRapid"]); + Assert.Equal("False", settings["PlateViewDrawBounds"]); + Assert.Equal(3, settings.Count); + var read = PlateViewOptions.Read(settings); + Assert.True(read.DrawRapid); + Assert.False(read.DrawBounds); + // Only rapids and bounds persist, as in WinForms. + Assert.False(read.DrawSupport); + } + + [Fact] + public void Unreadable_values_are_defaults() + { + var read = PlateViewOptions.Read(new Dictionary + { + ["PlateViewDrawRapid"] = "yes", + ["PlateViewDrawBounds"] = "", + }); + + Assert.False(read.DrawRapid); + Assert.True(read.DrawBounds); + } +} diff --git a/OpenNest.Desktop.Tests/Rendering/ProgramGeometryTests.cs b/OpenNest.Desktop.Tests/Rendering/ProgramGeometryTests.cs new file mode 100644 index 00000000..abe32f6b --- /dev/null +++ b/OpenNest.Desktop.Tests/Rendering/ProgramGeometryTests.cs @@ -0,0 +1,324 @@ +using OpenNest.CNC; +using OpenNest.Desktop.Rendering; +using OpenNest.Geometry; + +namespace OpenNest.Desktop.Tests.Rendering; + +public class ProgramGeometryTests +{ + private static LinearMove Line(double x, double y, LayerType layer = LayerType.Cut, bool suppressed = false) => + new(new Vector(x, y)) { Layer = layer, Suppressed = suppressed }; + + private static Program Square(double size = 10, LayerType layer = LayerType.Cut) + { + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(Line(size, 0, layer)); + program.Codes.Add(Line(size, size, layer)); + program.Codes.Add(Line(0, size, layer)); + program.Codes.Add(Line(0, 0, layer)); + return program; + } + + private static Vector[] Points(PathFigure figure) => + new[] { figure.Start }.Concat(figure.Segments.Select(s => s.End)).ToArray(); + + private static void Near(Vector expected, Vector actual) => + Assert.True(expected.DistanceTo(actual) < 1e-9, $"expected {expected}, got {actual}"); + + [Fact] + public void A_closed_square_is_one_closed_cut_figure_filled_as_cut() + { + var layers = ProgramGeometry.Layers(Square()); + + var cut = Assert.Single(layers.Cut); + Assert.True(cut.IsClosed); + Assert.Equal(4, cut.Segments.Count); + Assert.All(cut.Segments, s => Assert.IsType(s)); + Assert.Equal(new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10), new Vector(0, 0) }, + Points(cut)); + Assert.Empty(layers.Lead); + Assert.Empty(layers.Etch); + Assert.False(layers.HasOpenCuts); + Assert.Single(ProgramGeometry.Fill(Square())); + } + + [Fact] + public void Ccw_and_cw_arcs_sweep_from_the_current_point_in_degrees() + { + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(10, 0))); + // Counterclockwise half circle about the origin to (-10, 0), then clockwise back. + program.Codes.Add(new ArcMove(new Vector(-10, 0), Vector.Zero, RotationType.CCW)); + program.Codes.Add(new ArcMove(new Vector(10, 0), Vector.Zero, RotationType.CW)); + + var figure = Assert.Single(ProgramGeometry.Layers(program).Cut); + + var ccw = Assert.IsType(figure.Segments[0]); + Assert.Equal(0, ccw.StartAngle, 9); + Assert.Equal(180, ccw.SweepAngle, 9); + Assert.Equal(10, ccw.Radius, 9); + Near(new Vector(-10, 0), ccw.End); + + var cw = Assert.IsType(figure.Segments[1]); + Assert.Equal(180, cw.StartAngle, 9); + Assert.Equal(-180, cw.SweepAngle, 9); + Assert.True(figure.IsClosed); + } + + [Theory] + [InlineData(RotationType.CCW, 360)] + [InlineData(RotationType.CW, -360)] + public void An_arc_ending_where_it_starts_is_a_full_circle(RotationType rotation, double sweep) + { + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(5, 0))); + program.Codes.Add(new ArcMove(new Vector(5, 0), new Vector(2, 0), rotation)); + + var arc = Assert.IsType(Assert.Single(Assert.Single(ProgramGeometry.Layers(program).Cut).Segments)); + + Assert.True(arc.IsFullCircle); + Assert.Equal(sweep, arc.SweepAngle, 9); + Assert.Equal(3, arc.Radius, 9); + Near(new Vector(2, 0), arc.Center); + } + + [Fact] + public void Incremental_moves_and_arc_centres_are_relative_to_the_current_point() + { + var program = new Program(Mode.Incremental); + program.Codes.Add(new RapidMove(new Vector(5, 5))); + program.Codes.Add(Line(10, 0)); + // A quarter circle CCW about a centre 5 above the current point. + program.Codes.Add(new ArcMove(new Vector(5, 5), new Vector(0, 5), RotationType.CCW)); + + var figure = Assert.Single(ProgramGeometry.Layers(program).Cut); + + Near(new Vector(5, 5), figure.Start); + Near(new Vector(15, 5), figure.Segments[0].End); + var arc = Assert.IsType(figure.Segments[1]); + Near(new Vector(15, 10), arc.Center); + Near(new Vector(20, 10), arc.End); + Assert.Equal(270, arc.StartAngle, 9); + Assert.Equal(90, arc.SweepAngle, 9); + } + + [Fact] + public void Leads_etches_and_cuts_are_routed_to_their_own_layers() + { + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(-2, 0))); + program.Codes.Add(Line(0, 0, LayerType.Leadin)); + program.Codes.Add(Line(10, 0)); + program.Codes.Add(Line(10, 10)); + program.Codes.Add(Line(0, 10)); + program.Codes.Add(Line(0, 0)); + program.Codes.Add(Line(-2, -2, LayerType.Leadout)); + program.Codes.Add(new RapidMove(new Vector(3, 3))); + program.Codes.Add(Line(7, 7, LayerType.Scribe)); + + var layers = ProgramGeometry.Layers(program); + + var cut = Assert.Single(layers.Cut); + Assert.True(cut.IsClosed); + Near(new Vector(0, 0), cut.Start); + Assert.Equal(2, layers.Lead.Count); + Near(new Vector(-2, 0), layers.Lead[0].Start); + Near(new Vector(0, 0), layers.Lead[1].Start); + var etch = Assert.Single(layers.Etch); + Near(new Vector(3, 3), etch.Start); + Near(new Vector(7, 7), etch.End); + + // Marks and leads never bound the material. + var fill = Assert.Single(ProgramGeometry.Fill(program)); + Assert.Equal(Points(cut), Points(fill)); + } + + [Fact] + public void Suppressed_moves_are_skipped_and_end_the_figure() + { + var program = Square(); + ((LinearMove)program.Codes[2]).Suppressed = true; + + var layers = ProgramGeometry.Layers(program); + + Assert.Equal(2, layers.Cut.Count); + Near(new Vector(10, 0), layers.Cut[0].End); + Near(new Vector(10, 10), layers.Cut[1].Start); + Assert.True(layers.HasOpenCuts); + } + + [Fact] + public void A_suppressed_lead_is_not_drawn() + { + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(-2, 0))); + program.Codes.Add(Line(0, 0, LayerType.Leadin, suppressed: true)); + program.Codes.Add(Line(10, 0)); + + var layers = ProgramGeometry.Layers(program); + + Assert.Empty(layers.Lead); + Near(new Vector(0, 0), Assert.Single(layers.Cut).Start); + } + + [Fact] + public void Break_cuts_are_drawn_as_cuts_but_never_bound_the_fill() + { + var program = Square(); + program.Codes.Add(new RapidMove(new Vector(5, 0))); + program.Codes.Add(Line(5, 10, LayerType.Break)); + + var layers = ProgramGeometry.Layers(program); + var fill = ProgramGeometry.Fill(program); + + Assert.Equal(2, layers.Cut.Count); + Assert.True(layers.HasOpenCuts); + Assert.True(ProgramGeometry.HasBreakCuts(program)); + Assert.False(ProgramGeometry.HasBreakCuts(Square())); + var outline = Assert.Single(fill); + Assert.True(outline.IsClosed); + Assert.DoesNotContain(outline.Segments, s => s.End.X == 5); + } + + [Fact] + public void Break_cuts_in_a_sub_program_count() + { + var hole = new Program(); + hole.Codes.Add(Line(1, 1, LayerType.Break)); + var program = new Program(); + program.Codes.Add(new SubProgramCall(hole, 0)); + + Assert.True(ProgramGeometry.HasBreakCuts(program)); + } + + [Fact] + public void Tab_spans_are_filled_but_never_drawn_as_cuts() + { + var program = Square(); + ((LinearMove)program.Codes[3]).Layer = LayerType.Tab; + + var layers = ProgramGeometry.Layers(program); + var fill = Assert.Single(ProgramGeometry.Fill(program)); + + Assert.Equal(2, layers.Cut.Count); + Assert.True(layers.HasOpenCuts); + Assert.True(fill.IsClosed); + Assert.Equal(4, fill.Segments.Count); + } + + [Fact] + public void Pieces_cut_around_tab_gaps_are_bridged_into_one_fill_figure() + { + // A square cut in two pieces with gaps at (10,10)-(8,10) and (0,2)-(0,0). + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(Line(10, 0)); + program.Codes.Add(Line(10, 10)); + program.Codes.Add(new RapidMove(new Vector(8, 10))); + program.Codes.Add(Line(0, 10)); + program.Codes.Add(Line(0, 2)); + + var cut = ProgramGeometry.Layers(program).Cut; + var fill = Assert.Single(ProgramGeometry.Fill(program)); + + Assert.Equal(2, cut.Count); + Assert.Equal( + new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(8, 10), new Vector(0, 10), new Vector(0, 2) }, + Points(fill)); + } + + [Fact] + public void Bridging_joins_the_nearest_open_piece_and_leaves_closed_figures_alone() + { + var a = new PathFigure(new Vector(0, 0), new PathSegment[] { new LineTo(new Vector(10, 0)) }); + var far = new PathFigure(new Vector(50, 50), new PathSegment[] { new LineTo(new Vector(60, 50)) }); + var near = new PathFigure(new Vector(10, 1), new PathSegment[] { new LineTo(new Vector(0, 1)) }); + var closed = new PathFigure(new Vector(20, 0), new PathSegment[] + { + new LineTo(new Vector(21, 0)), new LineTo(new Vector(21, 1)), new LineTo(new Vector(20, 0)), + }); + + var combined = ProgramGeometry.Combine(new[] { a, closed, far, near }, bridgeTabs: true); + + Assert.Equal(3, combined.Count); + Assert.Equal(new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 1), new Vector(0, 1) }, + Points(combined[0])); + Assert.Same(closed, combined[1]); + Assert.Equal(Points(far), Points(combined[2])); + Assert.Equal(4, ProgramGeometry.Combine(new[] { a, closed, far, near }, bridgeTabs: false).Count); + } + + [Fact] + public void A_rapid_that_does_not_move_does_not_split_a_figure() + { + var program = Square(); + program.Codes.Insert(3, new RapidMove(new Vector(10, 10.0001))); + + Assert.Single(ProgramGeometry.Layers(program).Cut); + } + + [Fact] + public void Sub_program_calls_start_at_the_frame_origin_plus_their_offset() + { + // Hole programs are incremental: they start from the call's place. + var hole = new Program(Mode.Incremental); + hole.Codes.Add(new RapidMove(new Vector(1, 0))); + hole.Codes.Add(new ArcMove(Vector.Zero, new Vector(-1, 0), RotationType.CW)); + var program = Square(); + program.Codes.Add(new SubProgramCall(hole, 0) { Offset = new Vector(5, 5) }); + + var cut = ProgramGeometry.Layers(program).Cut; + + Assert.Equal(2, cut.Count); + var arc = Assert.IsType(Assert.Single(cut[1].Segments)); + Near(new Vector(6, 5), cut[1].Start); + Near(new Vector(5, 5), arc.Center); + } + + [Fact] + public void Entity_figures_keep_lines_arcs_circles_and_closed_polygons_but_skip_leads() + { + var lead = new Line(new Vector(0, 0), new Vector(1, 1)) { Layer = SpecialLayers.Leadin }; + var polygon = new Polygon(); + polygon.Vertices.Add(new Vector(0, 0)); + polygon.Vertices.Add(new Vector(4, 0)); + polygon.Vertices.Add(new Vector(4, 4)); + var figures = ProgramGeometry.EntityFigures(new Entity[] + { + new Line(new Vector(0, 0), new Vector(3, 4)), + new Circle(new Vector(10, 10), 2), + new Arc(new Vector(0, 0), 5, 0, System.Math.PI / 2), + polygon, + lead, + }); + + Assert.Equal(4, figures.Count); + Near(new Vector(3, 4), figures[0].End); + var circle = Assert.IsType(Assert.Single(figures[1].Segments)); + Assert.True(circle.IsFullCircle); + Near(new Vector(12, 10), figures[1].Start); + var arc = Assert.IsType(Assert.Single(figures[2].Segments)); + Near(new Vector(5, 0), figures[2].Start); + Near(new Vector(0, 5), arc.End); + Assert.Equal(90, arc.SweepAngle, 9); + Assert.True(figures[3].IsClosed); + Assert.Equal(3, figures[3].Segments.Count); + } + + [Fact] + public void Bounds_include_arc_extremes() + { + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(10, 0))); + program.Codes.Add(new ArcMove(new Vector(-10, 0), Vector.Zero, RotationType.CCW)); + + var bounds = ProgramGeometry.Bounds(ProgramGeometry.Layers(program).Cut); + + Assert.Equal(-10, bounds.Left, 9); + Assert.Equal(10, bounds.Right, 9); + Assert.Equal(0, bounds.Bottom, 9); + Assert.Equal(10, bounds.Top, 9); + Assert.Null(ProgramGeometry.Bounds(Array.Empty())); + } +} diff --git a/OpenNest.Desktop.Tests/Rendering/ViewTransformTests.cs b/OpenNest.Desktop.Tests/Rendering/ViewTransformTests.cs new file mode 100644 index 00000000..9dd2b8ad --- /dev/null +++ b/OpenNest.Desktop.Tests/Rendering/ViewTransformTests.cs @@ -0,0 +1,154 @@ +using OpenNest.Desktop.Rendering; +using OpenNest.Geometry; + +namespace OpenNest.Desktop.Tests.Rendering; + +public class ViewTransformTests +{ + private static ViewTransform Sized(double width = 800, double height = 600) + { + var view = new ViewTransform(); + view.Resize(width, height); + return view; + } + + [Fact] + public void World_and_screen_round_trip_with_y_flipped() + { + var view = Sized(); + view.ZoomToArea(new Box(0, 0, 100, 50)); + + var world = new Vector(12.5, -3.25); + var screen = view.WorldToScreen(world); + var back = view.ScreenToWorld(screen); + + Assert.Equal(world.X, back.X, 9); + Assert.Equal(world.Y, back.Y, 9); + var above = view.WorldToScreen(new Vector(12.5, 10)); + Assert.True(above.Y < screen.Y, "world up is screen up"); + Assert.Equal(view.OriginX, view.WorldToScreen(Vector.Zero).X, 9); + Assert.Equal(view.OriginY, view.WorldToScreen(Vector.Zero).Y, 9); + Assert.Equal(25 * view.Scale, view.LengthToScreen(25), 9); + Assert.Equal(25, view.LengthToWorld(view.LengthToScreen(25)), 9); + } + + [Fact] + public void Zoom_to_area_fits_and_centres_with_the_border() + { + var view = Sized(800, 600); + + Assert.True(view.ZoomToArea(new Box(10, 20, 100, 50))); + + // Width-limited: (800 - 50) / 100 against (600 - 50) / 50. + Assert.Equal(7.5, view.Scale, 9); + var centre = view.WorldToScreen(new Vector(60, 45)); + Assert.Equal(400, centre.X, 9); + Assert.Equal(300, centre.Y, 9); + } + + [Fact] + public void Zoom_to_an_empty_area_or_viewport_changes_nothing() + { + var view = new ViewTransform(); + var before = view.Capture(); + + Assert.False(view.ZoomToArea(new Box(0, 0, 10, 10))); + view.Resize(400, 300); + Assert.False(view.ZoomToArea(new Box(0, 0, 0, 10))); + Assert.False(view.ZoomToArea(null)); + Assert.Equal(before.Scale, view.Scale); + } + + [Fact] + public void Zooming_about_a_point_keeps_it_still() + { + var view = Sized(); + view.ZoomToArea(new Box(0, 0, 100, 100)); + var at = new ScreenPoint(123, 456); + var world = view.ScreenToWorld(at); + + view.ZoomAt(at, 1.7); + + var after = view.WorldToScreen(world); + Assert.Equal(at.X, after.X, 6); + Assert.Equal(at.Y, after.Y, 6); + } + + [Fact] + public void Zoom_stays_within_its_limits() + { + var view = Sized(); + view.ZoomToArea(new Box(0, 0, 100, 100)); + + view.ZoomAt(new ScreenPoint(10, 10), 1e9); + Assert.Equal(view.MaxScale, view.Scale, 9); + view.ZoomAt(new ScreenPoint(10, 10), 1e-12); + Assert.Equal(view.MinScale, view.Scale, 9); + + Assert.True(view.ZoomToArea(new Box(0, 0, 1e7, 1e7))); + Assert.Equal(view.MinScale, view.Scale, 9); + } + + [Fact] + public void Zoom_steps_are_the_desktop_factor_about_the_centre() + { + var view = Sized(800, 600); + view.ZoomToArea(new Box(0, 0, 100, 100)); + var scale = view.Scale; + var centre = view.ScreenToWorld(400, 300); + + view.ZoomStep(zoomIn: true); + Assert.Equal(scale * 1.15, view.Scale, 9); + view.ZoomStep(zoomIn: false); + Assert.Equal(scale, view.Scale, 9); + var after = view.WorldToScreen(centre); + Assert.Equal(400, after.X, 6); + Assert.Equal(300, after.Y, 6); + } + + [Fact] + public void Resizing_keeps_the_view_centre() + { + var view = Sized(800, 600); + view.ZoomToArea(new Box(0, 0, 100, 100)); + var centre = view.ScreenToWorld(400, 300); + + view.Resize(1000, 500); + + var moved = view.WorldToScreen(centre); + Assert.Equal(500, moved.X, 9); + Assert.Equal(250, moved.Y, 9); + } + + [Fact] + public void Panning_moves_the_origin_and_the_visible_area_follows() + { + var view = Sized(800, 600); + view.ZoomToArea(new Box(0, 0, 100, 100)); + var visible = view.VisibleWorld(); + + view.Pan(view.Scale * 10, 0); + + var after = view.VisibleWorld(); + Assert.Equal(visible.Left - 10, after.Left, 9); + Assert.Equal(visible.Bottom, after.Bottom, 9); + Assert.Equal(800 / view.Scale, after.Length, 9); + Assert.Equal(600 / view.Scale, after.Width, 9); + } + + [Fact] + public void Capture_and_restore_round_trip_and_raise_changed() + { + var view = Sized(); + view.ZoomToArea(new Box(0, 0, 100, 100)); + var state = view.Capture(); + view.ZoomAt(new ScreenPoint(0, 0), 3); + var changes = 0; + view.Changed += (_, _) => changes++; + + view.Restore(state); + + Assert.Equal(state, view.Capture()); + Assert.Equal(1, changes); + } +} diff --git a/OpenNest.Desktop/Rendering/PartDisplayGeometry.cs b/OpenNest.Desktop/Rendering/PartDisplayGeometry.cs new file mode 100644 index 00000000..fa6908e1 --- /dev/null +++ b/OpenNest.Desktop/Rendering/PartDisplayGeometry.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Desktop.Rendering; + +/// +/// What the plate view draws for one part, in its program frame before placement, chosen as the +/// WinForms LayoutPart chooses it: cut, etch and display-only strokes, lead strokes only for +/// manually placed lead-ins, and the material fill. Immutable once built; rebuild it when +/// says the part's program, rotation or lead-ins changed. +/// +public sealed class PartDisplayGeometry +{ + private PartDisplayGeometry(Program program, double rotation, bool manualLeadIns) + { + Program = program; + Rotation = rotation; + ManualLeadIns = manualLeadIns; + } + + public Program Program { get; } + + public double Rotation { get; } + + public bool ManualLeadIns { get; } + + public IReadOnlyList Cut { get; private init; } + + /// Lead strokes; empty unless the part has manually placed lead-ins. + public IReadOnlyList Lead { get; private init; } + + public IReadOnlyList Etch { get; private init; } + + /// The filled material region: the cut outline unless break cuts, tabs or common lines make it differ. + public IReadOnlyList Material { get; private init; } + + /// The drawing's display-only geometry, rotated as placed. Never material or cut. + public IReadOnlyList Display { get; private init; } + + public bool HasOpenCuts { get; private init; } + + /// + /// What picks the part: its material, or for an etch-only part its closed marks, so it stays + /// selectable without treating scribe geometry as a cut or fill. + /// + public IReadOnlyList Selection => Cut.Count > 0 ? Material : Etch; + + public bool IsCurrentFor(Part part) => + ReferenceEquals(Program, part.Program) && Rotation == part.Rotation + && ManualLeadIns == part.HasManualLeadIns; + + public static PartDisplayGeometry Build(Part part) + { + var program = part.Program; + var layers = ProgramGeometry.Layers(program); + IReadOnlyList material; + // A common-line member cut once leaves its shared spans to the shared cut: its material is + // the drawing's, rotated as placed (docs/common-line-cutting.md). + if (CommonLineCuts.IsSinglePass(program)) + { + var clean = (Program)part.BaseDrawing.Program.Clone(); + clean.Rotate(part.Rotation - part.BaseDrawing.Program.Rotation); + material = ProgramGeometry.Fill(clean); + } + else if (layers.HasOpenCuts || ProgramGeometry.HasBreakCuts(program)) + material = ProgramGeometry.Fill(program); + else + material = layers.Cut; + + return new PartDisplayGeometry(program, part.Rotation, part.HasManualLeadIns) + { + Cut = layers.Cut, + Lead = part.HasManualLeadIns ? layers.Lead : new List(), + Etch = layers.Etch, + Material = material, + Display = DisplayFigures(part), + HasOpenCuts = layers.HasOpenCuts, + }; + } + + // Display-only entities are kept in the drawing's program frame before rotation, so they + // follow the part by its cumulative rotation about the program origin, then its location. + private static List DisplayFigures(Part part) + { + var entities = EntityRoles.DisplayGeometry(part.BaseDrawing); + foreach (var entity in entities) + entity.Rotate(part.Rotation); + return ProgramGeometry.EntityFigures(entities); + } + + /// The bounds of every drawn stroke (cut, etch, leads and display), or null when none. + public Box StrokeBounds() => ProgramGeometry.Bounds(Cut.Concat(Etch).Concat(Lead).Concat(Display)); +} diff --git a/OpenNest.Desktop/Rendering/PartLabels.cs b/OpenNest.Desktop/Rendering/PartLabels.cs new file mode 100644 index 00000000..8697b46a --- /dev/null +++ b/OpenNest.Desktop/Rendering/PartLabels.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Desktop.Rendering; + +/// +/// Where the plate view writes a part's number: the pole of inaccessibility of the drawing's +/// material, or the middle of a cut-off segment's own cut. A port of the WinForms +/// LayoutPart label rules. +/// +public static class PartLabels +{ + /// The search budget that keeps any shape from stalling a paint. + public const int SearchCells = 20000; + + /// The drawing's label point in its program frame. + public static Vector LabelPoint(Drawing drawing, int maxCells = SearchCells) + { + var entities = ConvertProgram.ToGeometry(drawing.Program); + var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList(); + var shapes = ShapeBuilder.GetShapes(material); + + if (shapes.Count == 0) + { + var bbox = drawing.Program.BoundingBox(); + return new Vector(bbox.Location.X + bbox.Length / 2, bbox.Location.Y + bbox.Width / 2); + } + + var profile = ShapeProfile.FromShapes(shapes); + var outer = profile.Perimeter.ToPolygonWithTolerance(0.1); + + List holes = null; + if (profile.Cutouts.Count > 0) + { + holes = new List(); + foreach (var cutout in profile.Cutouts) + holes.Add(cutout.ToPolygonWithTolerance(0.1)); + } + + return PolyLabel.Find(outer, holes, PolyLabel.ThicknessPrecision(outer, holes), maxCells); + } + + /// + /// A drawing label point (from ) placed with . The + /// point already carries the drawing program's own rotation, so only the part's rotation past + /// it is applied. + /// + public static Vector Place(Part part, Vector drawingLabelPoint) => + drawingLabelPoint.Rotate(part.Rotation - part.BaseDrawing.Program.Rotation) + part.Location; + + /// + /// The middle of a cut-off segment part's own cutting moves, in plate coordinates; null when + /// the part is not an absolute cut-off segment. Every segment shares the cut-off drawing, so + /// labelling from the drawing would stack all the numbers on one point. + /// + public static Vector? CutOffSegmentPoint(Part part) + { + var program = part.Program; + if (!part.BaseDrawing.IsCutOff || program?.Mode != Mode.Absolute) + return null; + + double minX = double.MaxValue, minY = double.MaxValue; + double maxX = double.MinValue, maxY = double.MinValue; + Vector? position = null; + foreach (var code in program.Codes) + { + if (code is not Motion motion) + continue; + if (motion is not RapidMove && position is { } start) + { + foreach (var point in new[] { start, motion.EndPoint }) + { + minX = System.Math.Min(minX, point.X); + minY = System.Math.Min(minY, point.Y); + maxX = System.Math.Max(maxX, point.X); + maxY = System.Math.Max(maxY, point.Y); + } + } + position = motion.EndPoint; + } + + if (minX > maxX) + return null; + return new Vector((minX + maxX) / 2, (minY + maxY) / 2) + part.Location; + } +} diff --git a/OpenNest.Desktop/Rendering/PathFigure.cs b/OpenNest.Desktop/Rendering/PathFigure.cs new file mode 100644 index 00000000..af7980ac --- /dev/null +++ b/OpenNest.Desktop/Rendering/PathFigure.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Desktop.Rendering; + +/// One drawn piece of a figure, ending at . +public abstract record PathSegment(Vector End); + +/// A straight line from the previous point to . +public sealed record LineTo(Vector End) : PathSegment(End); + +/// +/// A circular arc about , in degrees measured from +X toward +Y (the +/// y-up drawing frame); a positive sweep turns counterclockwise. A sweep of 360 degrees is a full +/// circle. Toolkits join the previous point to the arc's start with a line, as GDI+ and Skia do. +/// +public sealed record ArcTo(Vector End, Vector Center, double Radius, double StartAngle, double SweepAngle) + : PathSegment(End) +{ + public bool IsFullCircle => System.Math.Abs(SweepAngle) >= 360.0; +} + +/// +/// One continuous run of drawing moves: a move to followed by +/// . Toolkit-neutral; each desktop app converts figures to its own path type. +/// +public sealed class PathFigure +{ + public PathFigure(Vector start, IReadOnlyList segments) + { + Start = start; + Segments = segments; + } + + public Vector Start { get; } + + public IReadOnlyList Segments { get; } + + public Vector End => Segments.Count == 0 ? Start : Segments[Segments.Count - 1].End; + + /// Whether the figure ends where it starts, so a toolkit may close it. + public bool IsClosed => Start.DistanceTo(End) <= Tolerance.Epsilon; + + public override string ToString() => $"{Start} +{Segments.Count} {(IsClosed ? "closed" : "open")}"; +} diff --git a/OpenNest.Desktop/Rendering/PlateOverlays.cs b/OpenNest.Desktop/Rendering/PlateOverlays.cs new file mode 100644 index 00000000..2c3ad1ea --- /dev/null +++ b/OpenNest.Desktop/Rendering/PlateOverlays.cs @@ -0,0 +1,205 @@ +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.Support; + +namespace OpenNest.Desktop.Rendering; + +/// +/// The world geometry of what the plate view draws over and around the parts, computed by the +/// Core code the WinForms renderer calls, so each desktop app only strokes and fills it. Results +/// are in plate (world) coordinates. +/// +public static class PlateOverlays +{ + /// The sheet rectangle, placed in the plate's quadrant about the origin. + public static Box Sheet(Plate plate) + { + var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0; + var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0; + return new Box(left, bottom, plate.Size.Length, plate.Size.Width); + } + + /// The sheet less its edge spacing on each side. + public static Box EdgeSpacing(Plate plate) + { + var sheet = Sheet(plate); + var spacing = plate.EdgeSpacing; + return new Box(sheet.X + spacing.Left, sheet.Y + spacing.Bottom, + plate.Size.Length - spacing.Left - spacing.Right, plate.Size.Width - spacing.Bottom - spacing.Top); + } + + /// + /// An irregular sheet's usable area outlines (the outline eroded by the largest edge spacing), + /// placed in the plate's quadrant; empty for a rectangular sheet or no edge spacing. + /// + public static IReadOnlyList> UsableOutline(Plate plate) + { + var outline = plate.Outline; + var spacing = plate.EdgeSpacing; + var edge = System.Math.Max(System.Math.Max(spacing.Left, spacing.Right), + System.Math.Max(spacing.Top, spacing.Bottom)); + if (outline == null || !(edge > 0)) + return new List>(); + return outline.Usable(edge, outline.PlacementOrigin(plate.Quadrant)) + .Select(path => (IReadOnlyList)path.Select(p => new Vector(p.x, p.y)).ToList()) + .ToList(); + } + + /// A polygon of an irregular sheet placed in the plate's quadrant. + public static IReadOnlyList PlaceOutline(Plate plate, IReadOnlyList points) + { + var origin = plate.Outline.PlacementOrigin(plate.Quadrant); + return points.Select(p => new Vector(p.X + origin.X, p.Y + origin.Y)).ToList(); + } + + /// The active clamp zones, placed with the sheet. + public static IReadOnlyList Clamps(Plate plate) + { + var sheet = Sheet(plate); + return plate.ClampZones.Select(zone => zone.Bounds.Translate(sheet.X, sheet.Y)).ToList(); + } + + /// The rail's clamps taken off this sheet, placed with the sheet. + public static IReadOnlyList RemovedClamps(Plate plate) + { + if (plate.ClampRail == null || plate.RemovedClamps.Count == 0) + return new List(); + var sheet = Sheet(plate); + return plate.ClampRail.ResolveFor(plate.Size, plate.Quadrant) + .Where(zone => plate.RemovedClamps.Contains(zone.Id)) + .Select(zone => zone.Bounds.Translate(sheet.X, sheet.Y)) + .ToList(); + } + + /// Each cut-off definition's cut strokes, including a head-locked overcut past the sheet. + public static IReadOnlyList CutOffStrokes(Plate plate) + { + var strokes = new List(); + if (plate.CutOffs == null) + return strokes; + foreach (var cutoff in plate.CutOffs) + { + var program = cutoff.Drawing?.Program; + if (program == null || program.Codes.Count == 0) + continue; + strokes.AddRange(CutOff.CutStrokes(program, Vector.Zero)); + } + return strokes; + } + + /// The rapid moves in cutting order, cut-off segments included. + public static IReadOnlyList Rapids(Plate plate) => + RapidEnumerator.Enumerate(plate.Parts); + + /// + /// Where each rapid ends: the pierce points of every part, sub-program calls placed at their + /// offsets, as the WinForms Draw Pierce Points marks them. + /// + public static IReadOnlyList PiercePoints(Plate plate) + { + var points = new List(); + foreach (var part in plate.Parts) + { + var position = part.Location; + AddPiercePoints(points, part.Program, part.Location, ref position); + } + return points; + } + + private static void AddPiercePoints(List points, Program program, Vector basePosition, ref Vector position) + { + for (var i = 0; i < program.Length; ++i) + { + var code = program[i]; + if (code is SubProgramCall call) + { + if (call.Program != null) + { + var holeBase = basePosition + call.Offset; + position = holeBase; + AddPiercePoints(points, call.Program, holeBase, ref position); + } + continue; + } + + if (code is not Motion motion) + continue; + var end = program.Mode == Mode.Incremental ? motion.EndPoint + position : motion.EndPoint + basePosition; + if (motion is RapidMove) + points.Add(end); + position = end; + } + } + + /// + /// Cut-direction arrow positions and travel directions along every part's moves, one every + /// world units (the WinForms view uses 60 screen pixels). + /// + public static IReadOnlyList CutDirection(Plate plate, double spacing) + { + var samples = new List(); + foreach (var part in plate.Parts) + ContourSampler.ProgramMoves(part.Program, part.Location, part.Location, spacing, samples); + return samples; + } + + /// The box around , or null when there are none. + public static Box SelectionBounds(IEnumerable parts) + { + var list = parts.ToList(); + return list.Count == 0 ? null : list.GetBoundingBox(); + } + + /// + /// The part-spacing outline of in its program frame: its moves (all but + /// rapids) offset by , as View > Draw Offset shows it. + /// + public static IReadOnlyList OffsetOutline(Part part, double spacing, double tolerance) + { + var entities = ConvertProgram.ToGeometry(part.Program); + var profile = new ShapeProfile(entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()); + var offset = ClipperBridge.Offset(profile, spacing, tolerance); + return offset.Outers.Concat(offset.Holes).ToList(); + } + + /// + /// The inside-corner clearance support tab points keep: the plate's tab size, or the live + /// plate's when is a proposal with no cutting parameters of its own. + /// + public static double SupportClearance(Plate plate, Plate livePlate = null) + { + var parameters = plate.CuttingParameters ?? livePlate?.CuttingParameters; + return SupportAnalyzer.InsideCornerClearance(parameters?.TabConfig?.Size ?? 0); + } +} + +/// +/// Support analyses for View > Draw Support, kept per part until its program, pose, the bed, the +/// quadrant or the tab clearance changes. Not thread-safe; use one per view. +/// +public sealed class SupportAnalysisCache +{ + private readonly ConditionalWeakTable cache = new(); + + private sealed record Entry(Program Program, Vector Location, double Rotation, SupportBed Bed, int Quadrant, + double Clearance, PartSupport Support); + + /// The part's support on ; cut-offs are never analysed (null). + public PartSupport Analyze(Part part, SupportBed bed, int quadrant, double clearance) + { + if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff) + return null; + if (cache.TryGetValue(part, out var entry) && ReferenceEquals(entry.Program, part.Program) + && entry.Location == part.Location && entry.Rotation == part.Rotation && Equals(entry.Bed, bed) + && entry.Quadrant == quadrant && entry.Clearance == clearance) + return entry.Support; + var support = SupportAnalyzer.Analyze(bed, quadrant, part, clearance); + cache.AddOrUpdate(part, new Entry(part.Program, part.Location, part.Rotation, bed, quadrant, clearance, + support)); + return support; + } +} diff --git a/OpenNest.Desktop/Rendering/PlateViewOptions.cs b/OpenNest.Desktop/Rendering/PlateViewOptions.cs new file mode 100644 index 00000000..4d1212f7 --- /dev/null +++ b/OpenNest.Desktop/Rendering/PlateViewOptions.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; + +namespace OpenNest.Desktop.Rendering; + +/// +/// One plate view's View menu toggles, with the WinForms defaults: rapids off, bounds on, and +/// pierce points, offset outline, cut direction and support off. Only rapids and bounds are kept +/// between sessions, under the WinForms names PlateViewDrawRapid and PlateViewDrawBounds: +/// each nest view reads them when it opens and writes its own when it closes. +/// +public sealed class PlateViewOptions +{ + public const string DrawRapidName = "PlateViewDrawRapid"; + public const string DrawBoundsName = "PlateViewDrawBounds"; + + /// The origin axes through world (0, 0). + public bool DrawOrigin { get; set; } = true; + + public bool DrawRapid { get; set; } + + public bool DrawPiercePoints { get; set; } + + /// The box around the selected parts. + public bool DrawBounds { get; set; } = true; + + /// Each part's outline offset by the plate's part spacing. + public bool DrawOffset { get; set; } + + public bool DrawCutDirection { get; set; } + + /// The support bed, each part's contacts and center of gravity. + public bool DrawSupport { get; set; } + + /// The chord tolerance of the offset outline. + public double OffsetTolerance { get; set; } = 0.001; + + /// A view's toggles with the saved rapids and bounds; missing or unreadable values are defaults. + public static PlateViewOptions Read(IReadOnlyDictionary settings) + { + ArgumentNullException.ThrowIfNull(settings); + var options = new PlateViewOptions(); + options.DrawRapid = UserSettingText.ReadBool(settings, DrawRapidName, options.DrawRapid); + options.DrawBounds = UserSettingText.ReadBool(settings, DrawBoundsName, options.DrawBounds); + return options; + } + + /// Stores the rapids and bounds toggles in the WinForms format. + public void WriteTo(IDictionary settings) + { + ArgumentNullException.ThrowIfNull(settings); + settings[DrawRapidName] = UserSettingText.Format(DrawRapid); + settings[DrawBoundsName] = UserSettingText.Format(DrawBounds); + } +} diff --git a/OpenNest.Desktop/Rendering/ProgramGeometry.cs b/OpenNest.Desktop/Rendering/ProgramGeometry.cs new file mode 100644 index 00000000..d7704374 --- /dev/null +++ b/OpenNest.Desktop/Rendering/ProgramGeometry.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Desktop.Rendering; + +/// A program's display layers in its own frame, before placement. +/// +/// Whether a cut contour is open, such as a cutout left attached by tabs. Filling +/// would close it with a chord; fill instead. +/// +public sealed record ProgramLayers( + IReadOnlyList Cut, + IReadOnlyList Lead, + IReadOnlyList Etch, + bool HasOpenCuts); + +/// +/// A CNC program's moves as toolkit-neutral figures, routed by layer as the WinForms plate view +/// routes them (its GraphicsHelper): cuts, lead-ins/outs, etches and the filled material. +/// Suppressed moves are skipped, a tabbed cutout's tabs are filled but never drawn as cuts, and +/// break cuts are drawn but never bound the fill. Coordinates stay in the program frame: callers +/// place the figures at the part's location. +/// +public static class ProgramGeometry +{ + /// Cut strokes: everything but etches, leads and tabs (tabs are never cut). + public static bool IsCutLayer(LayerType layer) => + layer is not (LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Tab); + + public static bool IsLeadLayer(LayerType layer) => layer is LayerType.Leadin or LayerType.Leadout; + + public static bool IsEtchLayer(LayerType layer) => layer == LayerType.Scribe; + + /// + /// Material outline layers: everything but etches, leads and break cuts. Break cuts are open + /// lines through material: a fill would close each with a chord. + /// + public static bool IsFillLayer(LayerType layer) => + layer is not (LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Break); + + /// The cut, lead and etch figures of . + public static ProgramLayers Layers(Program program) + { + ArgumentNullException.ThrowIfNull(program); + var cut = Figures(program, IsCutLayer, bridgeTabs: false, out var hasOpenCuts); + var lead = Figures(program, IsLeadLayer, bridgeTabs: false, out _); + var etch = Figures(program, IsEtchLayer, bridgeTabs: false, out _); + return new ProgramLayers(cut, lead, etch, hasOpenCuts); + } + + /// + /// The closed material outlines to fill. A contour cut in pieces around tabs is rejoined + /// across its tab gaps, so it fills as one region rather than as chord-closed pieces. + /// + public static IReadOnlyList Fill(Program program) + { + ArgumentNullException.ThrowIfNull(program); + return Figures(program, IsFillLayer, bridgeTabs: true, out _); + } + + /// Whether or a sub-program has a break cut. + public static bool HasBreakCuts(Program program) => + program.Codes.Any(code => code is LinearMove { Layer: LayerType.Break } or ArcMove { Layer: LayerType.Break } + || code is SubProgramCall { Program: { } sub } && HasBreakCuts(sub)); + + /// + /// The unsuppressed moves on layers accepts, one figure per + /// continuous run. A run ends at a skipped move, a rapid that moves, or a sub-program call. + /// With , open figures are chained end to start (see ). + /// + public static List Figures(Program program, Func includeLayer, bool bridgeTabs, + out bool hasOpenFigures) + { + ArgumentNullException.ThrowIfNull(program); + ArgumentNullException.ThrowIfNull(includeLayer); + var figures = new List(); + var position = Vector.Zero; + AddProgram(figures, program, ref position, includeLayer); + hasOpenFigures = figures.Exists(figure => !figure.IsClosed); + return Combine(figures, bridgeTabs); + } + + /// + /// With , each open figure is joined end-to-start with the open + /// figure starting nearest its end, until its own start is nearer, so the tab-separated pieces + /// of one contour fill as a single region. Closed figures pass through unchanged. + /// + public static List Combine(IReadOnlyList figures, bool bridgeTabs) + { + var result = new List(figures.Count); + var pending = new List(figures); + + while (pending.Count > 0) + { + var figure = pending[0]; + pending.RemoveAt(0); + + if (!bridgeTabs || figure.IsClosed) + { + result.Add(figure); + continue; + } + + var segments = new List(figure.Segments); + var end = figure.End; + + while (true) + { + PathFigure next = null; + var nextGap = end.DistanceTo(figure.Start); + + foreach (var candidate in pending) + { + if (candidate.IsClosed) + continue; + var gap = end.DistanceTo(candidate.Start); + if (gap < nextGap) + { + next = candidate; + nextGap = gap; + } + } + + if (next == null) + break; + + pending.Remove(next); + // Bridge the tab gap with a line, as GDI+ AddPath(connect: true) does. + if (nextGap > 0) + segments.Add(new LineTo(next.Start)); + segments.AddRange(next.Segments); + end = next.End; + } + + result.Add(new PathFigure(figure.Start, segments)); + } + + return result; + } + + private static void AddProgram(List figures, Program program, ref Vector position, + Func includeLayer) + { + // Sub-program offsets are relative to the frame origin at entry, not the tool position. + var frameOrigin = position; + var mode = program.Mode; + List current = null; + var figureStart = position; + + // Call before moving past a skipped code so the figure records where its cut ended. + void Flush() + { + if (current != null) + { + figures.Add(new PathFigure(figureStart, current)); + current = null; + } + } + + void Begin(Vector start) + { + if (current == null) + { + current = new List(); + figureStart = start; + } + } + + for (var i = 0; i < program.Length; ++i) + { + switch (program[i]) + { + case ArcMove arc: + { + if (!arc.Suppressed && includeLayer(arc.Layer)) + { + Begin(position); + current.Add(Arc(arc, mode, position)); + } + else + Flush(); + position = mode == Mode.Incremental ? arc.EndPoint + position : arc.EndPoint; + break; + } + + case LinearMove line: + { + var end = mode == Mode.Incremental ? line.EndPoint + position : line.EndPoint; + if (!line.Suppressed && includeLayer(line.Layer)) + { + Begin(position); + current.Add(new LineTo(end)); + } + else + Flush(); + position = end; + break; + } + + case RapidMove rapid: + { + var end = mode == Mode.Incremental ? rapid.EndPoint + position : rapid.EndPoint; + var dx = end.X - position.X; + var dy = end.Y - position.Y; + if (dx * dx + dy * dy > 0.001 * 0.001) + Flush(); + position = end; + break; + } + + case SubProgramCall call: + { + Flush(); + if (call.Program != null) + { + position = new Vector(frameOrigin.X + call.Offset.X, frameOrigin.Y + call.Offset.Y); + AddProgram(figures, call.Program, ref position, includeLayer); + } + break; + } + } + } + + Flush(); + } + + /// An arc move from , as GraphicsHelper.AddArc draws it. + private static ArcTo Arc(ArcMove arc, Mode mode, Vector position) + { + var end = arc.EndPoint; + var center = arc.CenterPoint; + if (mode == Mode.Incremental) + { + end += position; + center += position; + } + + var startAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(position.Y - center.Y, position.X - center.X))); + var endAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(end.Y - center.Y, end.X - center.X))); + var radius = center.DistanceTo(end); + + if (startAngle.IsEqualTo(endAngle)) + return new ArcTo(end, center, radius, startAngle, arc.Rotation == RotationType.CW ? -360.0 : 360.0); + + if (arc.Rotation == RotationType.CCW && endAngle < startAngle) + endAngle += 360.0; + else if (arc.Rotation == RotationType.CW && startAngle < endAngle) + startAngle += 360.0; + + return new ArcTo(end, center, radius, startAngle, endAngle - startAngle); + } + + /// + /// Geometry entities (display-only curves) as figures, one per entity, skipping lead-in and + /// lead-out layers as the WinForms shape path does. + /// + public static List EntityFigures(IEnumerable entities) + { + var figures = new List(); + foreach (var entity in entities) + AddEntity(figures, entity); + return figures; + } + + private static void AddEntity(List figures, Entity entity) + { + if (entity.Layer != null + && (string.Equals(entity.Layer.Name, SpecialLayers.Leadin.Name, StringComparison.OrdinalIgnoreCase) + || string.Equals(entity.Layer.Name, SpecialLayers.Leadout.Name, StringComparison.OrdinalIgnoreCase))) + return; + + switch (entity) + { + case Line line: + figures.Add(new PathFigure(line.StartPoint, new[] { new LineTo(line.EndPoint) })); + break; + + case Arc arc: + { + var endAngle = Angle.NormalizeDeg(Angle.ToDegrees(arc.EndAngle)); + var startAngle = Angle.NormalizeDeg(Angle.ToDegrees(arc.StartAngle)); + if (arc.Rotation == RotationType.CCW && endAngle < startAngle) + endAngle += 360.0; + else if (arc.Rotation == RotationType.CW && startAngle < endAngle) + startAngle += 360.0; + var start = PointOn(arc.Center, arc.Radius, startAngle); + var end = PointOn(arc.Center, arc.Radius, endAngle); + figures.Add(new PathFigure(start, + new[] { new ArcTo(end, arc.Center, arc.Radius, startAngle, endAngle - startAngle) })); + break; + } + + case Circle circle: + { + var start = new Vector(circle.Center.X + circle.Radius, circle.Center.Y); + figures.Add(new PathFigure(start, + new[] { new ArcTo(start, circle.Center, circle.Radius, 0, 360.0) })); + break; + } + + case Polygon polygon when polygon.Vertices.Count > 0: + { + var vertices = polygon.Vertices; + var segments = new List(vertices.Count); + for (var i = 1; i < vertices.Count; i++) + segments.Add(new LineTo(vertices[i])); + // GDI+ AddPolygon closes the figure. + if (vertices[^1].DistanceTo(vertices[0]) > 0) + segments.Add(new LineTo(vertices[0])); + figures.Add(new PathFigure(vertices[0], segments)); + break; + } + + case Shape shape: + foreach (var child in shape.Entities) + AddEntity(figures, child); + break; + } + } + + private static Vector PointOn(Vector center, double radius, double degrees) + { + var radians = Angle.ToRadians(degrees); + return new Vector(center.X + radius * System.Math.Cos(radians), center.Y + radius * System.Math.Sin(radians)); + } + + /// The bounding box of the figures' points and arc extremes, or null when empty. + public static Box Bounds(IEnumerable figures) + { + double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue; + var any = false; + + void Add(Vector p) + { + any = true; + minX = System.Math.Min(minX, p.X); + minY = System.Math.Min(minY, p.Y); + maxX = System.Math.Max(maxX, p.X); + maxY = System.Math.Max(maxY, p.Y); + } + + foreach (var figure in figures) + { + Add(figure.Start); + foreach (var segment in figure.Segments) + { + if (segment is ArcTo arc) + { + Add(PointOn(arc.Center, arc.Radius, arc.StartAngle)); + var from = System.Math.Min(arc.StartAngle, arc.StartAngle + arc.SweepAngle); + var to = System.Math.Max(arc.StartAngle, arc.StartAngle + arc.SweepAngle); + for (var quadrant = System.Math.Ceiling(from / 90.0) * 90.0; quadrant <= to; quadrant += 90.0) + Add(PointOn(arc.Center, arc.Radius, quadrant)); + } + Add(segment.End); + } + } + + return any ? new Box(minX, minY, maxX - minX, maxY - minY) : null; + } +} diff --git a/OpenNest.Desktop/Rendering/ViewTransform.cs b/OpenNest.Desktop/Rendering/ViewTransform.cs new file mode 100644 index 00000000..b7dda14d --- /dev/null +++ b/OpenNest.Desktop/Rendering/ViewTransform.cs @@ -0,0 +1,145 @@ +using System; +using OpenNest.Geometry; + +namespace OpenNest.Desktop.Rendering; + +/// A point in a view's screen (control) pixels, y down. +public readonly record struct ScreenPoint(double X, double Y); + +/// +/// A plate or CAD view's world-to-screen mapping: world Y up, screen Y down, a uniform +/// in pixels per drawing unit and /, the +/// screen position of the world origin. The zoom rules are the WinForms DrawControl's: the +/// scale stays within .., a zoom about a point keeps that +/// point still, and fitting an area leaves pixels of margin in all. +/// +public sealed class ViewTransform +{ + /// The DrawControl's zoom step for one Zoom In. + public const double ZoomInFactor = 1.15; + + /// Total pixels left around a fitted area (half on each side). + public const double FitBorder = 50; + + public double Scale { get; private set; } = 1; + + public double OriginX { get; private set; } = 100; + + public double OriginY { get; private set; } = 100; + + public double MinScale { get; set; } = 0.3; + + public double MaxScale { get; set; } = 10000; + + public double ViewportWidth { get; private set; } + + public double ViewportHeight { get; private set; } + + /// Raised after the scale, origin or viewport changes. + public event EventHandler Changed; + + public ScreenPoint WorldToScreen(Vector world) => WorldToScreen(world.X, world.Y); + + public ScreenPoint WorldToScreen(double x, double y) => new(OriginX + x * Scale, OriginY - y * Scale); + + public Vector ScreenToWorld(ScreenPoint screen) => ScreenToWorld(screen.X, screen.Y); + + public Vector ScreenToWorld(double x, double y) => new((x - OriginX) / Scale, (OriginY - y) / Scale); + + public double LengthToScreen(double worldLength) => worldLength * Scale; + + public double LengthToWorld(double screenLength) => screenLength / Scale; + + /// The world area the viewport shows. + public Box VisibleWorld() + { + var bottomLeft = ScreenToWorld(0, ViewportHeight); + return new Box(bottomLeft.X, bottomLeft.Y, ViewportWidth / Scale, ViewportHeight / Scale); + } + + /// + /// Sets the viewport size. After the first size, a resize keeps the view centred by moving + /// the origin half the change, as the WinForms view does. + /// + public void Resize(double width, double height) + { + if (width == ViewportWidth && height == ViewportHeight) + return; + OriginX += (width - ViewportWidth) * 0.5; + OriginY += (height - ViewportHeight) * 0.5; + ViewportWidth = width; + ViewportHeight = height; + OnChanged(); + } + + /// Moves the view by a screen offset. + public void Pan(double dx, double dy) + { + if (dx == 0 && dy == 0) + return; + OriginX += dx; + OriginY += dy; + OnChanged(); + } + + /// Zooms by about the world point under . + public void ZoomAt(ScreenPoint screen, double factor) => ZoomToPoint(ScreenToWorld(screen), factor); + + /// + /// Zooms by keeping at the same screen point; + /// the factor is cut so the scale stays within its limits. + /// + public void ZoomToPoint(Vector world, double factor) + { + var newScale = Scale * factor; + if (newScale < MinScale) + factor = MinScale / Scale; + else if (newScale > MaxScale) + factor = MaxScale / Scale; + + OriginX -= (world.X * factor - world.X) * Scale; + OriginY += (world.Y * factor - world.Y) * Scale; + Scale *= factor; + OnChanged(); + } + + /// Zooms in or out by one step about the viewport centre. + public void ZoomStep(bool zoomIn) => + ZoomAt(new ScreenPoint(ViewportWidth / 2, ViewportHeight / 2), zoomIn ? ZoomInFactor : 1 / ZoomInFactor); + + /// + /// Fits in the viewport, centred, with pixels of + /// margin in all; an empty area or viewport changes nothing. Returns whether it changed the view. + /// + public bool ZoomToArea(Box area) + { + if (area == null || area.Length <= 0 || area.Width <= 0 || ViewportWidth <= 0 || ViewportHeight <= 0) + return false; + + var a = (ViewportHeight - FitBorder) / area.Width; + var b = (ViewportWidth - FitBorder) / area.Length; + Scale = System.Math.Clamp(a < b ? a : b, MinScale, MaxScale); + + var px = area.X * Scale; + var py = area.Y * Scale; + var pw = area.Length * Scale; + var ph = area.Width * Scale; + OriginX = (ViewportWidth - pw) * 0.5 - px; + OriginY = (ViewportHeight + ph) * 0.5 + py; + OnChanged(); + return true; + } + + /// The current scale and origin, to restore later. + public (double Scale, double OriginX, double OriginY) Capture() => (Scale, OriginX, OriginY); + + public void Restore((double Scale, double OriginX, double OriginY) state) + { + Scale = System.Math.Clamp(state.Scale, MinScale, MaxScale); + OriginX = state.OriginX; + OriginY = state.OriginY; + OnChanged(); + } + + private void OnChanged() => Changed?.Invoke(this, EventArgs.Empty); +}