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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-11 22:18:14 -05:00
1 parent 8fe38398e9
commit 049b45b394
10 files changed
+1749

No files matched your search

@@ -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<LinearMove>())
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<Part>()));
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<string, string>());
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<string, string> { ["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<string, string>
{
["PlateViewDrawRapid"] = "yes",
["PlateViewDrawBounds"] = "",
});
Assert.False(read.DrawRapid);
Assert.True(read.DrawBounds);
}
}
@@ -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<LineTo>(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<ArcTo>(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<ArcTo>(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<ArcTo>(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<ArcTo>(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<ArcTo>(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<ArcTo>(Assert.Single(figures[1].Segments));
Assert.True(circle.IsFullCircle);
Near(new Vector(12, 10), figures[1].Start);
var arc = Assert.IsType<ArcTo>(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<PathFigure>()));
}
}
@@ -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);
}
}
@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Desktop.Rendering;
/// <summary>
/// What the plate view draws for one part, in its program frame before placement, chosen as the
/// WinForms <c>LayoutPart</c> 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
/// <see cref="IsCurrentFor"/> says the part's program, rotation or lead-ins changed.
/// </summary>
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<PathFigure> Cut { get; private init; }
/// <summary>Lead strokes; empty unless the part has manually placed lead-ins.</summary>
public IReadOnlyList<PathFigure> Lead { get; private init; }
public IReadOnlyList<PathFigure> Etch { get; private init; }
/// <summary>The filled material region: the cut outline unless break cuts, tabs or common lines make it differ.</summary>
public IReadOnlyList<PathFigure> Material { get; private init; }
/// <summary>The drawing's display-only geometry, rotated as placed. Never material or cut.</summary>
public IReadOnlyList<PathFigure> Display { get; private init; }
public bool HasOpenCuts { get; private init; }
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<PathFigure> 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<PathFigure> 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<PathFigure>(),
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<PathFigure> DisplayFigures(Part part)
{
var entities = EntityRoles.DisplayGeometry(part.BaseDrawing);
foreach (var entity in entities)
entity.Rotate(part.Rotation);
return ProgramGeometry.EntityFigures(entities);
}
/// <summary>The bounds of every drawn stroke (cut, etch, leads and display), or null when none.</summary>
public Box StrokeBounds() => ProgramGeometry.Bounds(Cut.Concat(Etch).Concat(Lead).Concat(Display));
}
+89
View File
@@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Desktop.Rendering;
/// <summary>
/// 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
/// <c>LayoutPart</c> label rules.
/// </summary>
public static class PartLabels
{
/// <summary>The search budget that keeps any shape from stalling a paint.</summary>
public const int SearchCells = 20000;
/// <summary>The drawing's label point in its program frame.</summary>
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<Polygon> holes = null;
if (profile.Cutouts.Count > 0)
{
holes = new List<Polygon>();
foreach (var cutout in profile.Cutouts)
holes.Add(cutout.ToPolygonWithTolerance(0.1));
}
return PolyLabel.Find(outer, holes, PolyLabel.ThicknessPrecision(outer, holes), maxCells);
}
/// <summary>
/// A drawing label point (from <see cref="LabelPoint"/>) placed with <paramref name="part"/>. The
/// point already carries the drawing program's own rotation, so only the part's rotation past
/// it is applied.
/// </summary>
public static Vector Place(Part part, Vector drawingLabelPoint) =>
drawingLabelPoint.Rotate(part.Rotation - part.BaseDrawing.Program.Rotation) + part.Location;
/// <summary>
/// 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.
/// </summary>
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;
}
}
+46
View File
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Desktop.Rendering;
/// <summary>One drawn piece of a figure, ending at <see cref="End"/>.</summary>
public abstract record PathSegment(Vector End);
/// <summary>A straight line from the previous point to <see cref="PathSegment.End"/>.</summary>
public sealed record LineTo(Vector End) : PathSegment(End);
/// <summary>
/// A circular arc about <paramref name="Center"/>, 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.
/// </summary>
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;
}
/// <summary>
/// One continuous run of drawing moves: a move to <see cref="Start"/> followed by
/// <see cref="Segments"/>. Toolkit-neutral; each desktop app converts figures to its own path type.
/// </summary>
public sealed class PathFigure
{
public PathFigure(Vector start, IReadOnlyList<PathSegment> segments)
{
Start = start;
Segments = segments;
}
public Vector Start { get; }
public IReadOnlyList<PathSegment> Segments { get; }
public Vector End => Segments.Count == 0 ? Start : Segments[Segments.Count - 1].End;
/// <summary>Whether the figure ends where it starts, so a toolkit may close it.</summary>
public bool IsClosed => Start.DistanceTo(End) <= Tolerance.Epsilon;
public override string ToString() => $"{Start} +{Segments.Count} {(IsClosed ? "closed" : "open")}";
}
+205
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public static class PlateOverlays
{
/// <summary>The sheet rectangle, placed in the plate's quadrant about the origin.</summary>
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);
}
/// <summary>The sheet less its edge spacing on each side.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<IReadOnlyList<Vector>> 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<IReadOnlyList<Vector>>();
return outline.Usable(edge, outline.PlacementOrigin(plate.Quadrant))
.Select(path => (IReadOnlyList<Vector>)path.Select(p => new Vector(p.x, p.y)).ToList())
.ToList();
}
/// <summary>A polygon of an irregular sheet placed in the plate's quadrant.</summary>
public static IReadOnlyList<Vector> PlaceOutline(Plate plate, IReadOnlyList<Vector> points)
{
var origin = plate.Outline.PlacementOrigin(plate.Quadrant);
return points.Select(p => new Vector(p.X + origin.X, p.Y + origin.Y)).ToList();
}
/// <summary>The active clamp zones, placed with the sheet.</summary>
public static IReadOnlyList<Box> Clamps(Plate plate)
{
var sheet = Sheet(plate);
return plate.ClampZones.Select(zone => zone.Bounds.Translate(sheet.X, sheet.Y)).ToList();
}
/// <summary>The rail's clamps taken off this sheet, placed with the sheet.</summary>
public static IReadOnlyList<Box> RemovedClamps(Plate plate)
{
if (plate.ClampRail == null || plate.RemovedClamps.Count == 0)
return new List<Box>();
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();
}
/// <summary>Each cut-off definition's cut strokes, including a head-locked overcut past the sheet.</summary>
public static IReadOnlyList<Line> CutOffStrokes(Plate plate)
{
var strokes = new List<Line>();
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;
}
/// <summary>The rapid moves in cutting order, cut-off segments included.</summary>
public static IReadOnlyList<RapidEnumerator.Segment> Rapids(Plate plate) =>
RapidEnumerator.Enumerate(plate.Parts);
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<Vector> PiercePoints(Plate plate)
{
var points = new List<Vector>();
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<Vector> 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;
}
}
/// <summary>
/// Cut-direction arrow positions and travel directions along every part's moves, one every
/// <paramref name="spacing"/> world units (the WinForms view uses 60 screen pixels).
/// </summary>
public static IReadOnlyList<ContourSample> CutDirection(Plate plate, double spacing)
{
var samples = new List<ContourSample>();
foreach (var part in plate.Parts)
ContourSampler.ProgramMoves(part.Program, part.Location, part.Location, spacing, samples);
return samples;
}
/// <summary>The box around <paramref name="parts"/>, or null when there are none.</summary>
public static Box SelectionBounds(IEnumerable<Part> parts)
{
var list = parts.ToList();
return list.Count == 0 ? null : list.GetBoundingBox();
}
/// <summary>
/// The part-spacing outline of <paramref name="part"/> in its program frame: its moves (all but
/// rapids) offset by <paramref name="spacing"/>, as View &gt; Draw Offset shows it.
/// </summary>
public static IReadOnlyList<Polygon> 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();
}
/// <summary>
/// The inside-corner clearance support tab points keep: the plate's tab size, or the live
/// plate's when <paramref name="plate"/> is a proposal with no cutting parameters of its own.
/// </summary>
public static double SupportClearance(Plate plate, Plate livePlate = null)
{
var parameters = plate.CuttingParameters ?? livePlate?.CuttingParameters;
return SupportAnalyzer.InsideCornerClearance(parameters?.TabConfig?.Size ?? 0);
}
}
/// <summary>
/// Support analyses for View &gt; 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.
/// </summary>
public sealed class SupportAnalysisCache
{
private readonly ConditionalWeakTable<Part, Entry> cache = new();
private sealed record Entry(Program Program, Vector Location, double Rotation, SupportBed Bed, int Quadrant,
double Clearance, PartSupport Support);
/// <summary>The part's support on <paramref name="bed"/>; cut-offs are never analysed (null).</summary>
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;
}
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace OpenNest.Desktop.Rendering;
/// <summary>
/// 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 <c>PlateViewDrawRapid</c> and <c>PlateViewDrawBounds</c>:
/// each nest view reads them when it opens and writes its own when it closes.
/// </summary>
public sealed class PlateViewOptions
{
public const string DrawRapidName = "PlateViewDrawRapid";
public const string DrawBoundsName = "PlateViewDrawBounds";
/// <summary>The origin axes through world (0, 0).</summary>
public bool DrawOrigin { get; set; } = true;
public bool DrawRapid { get; set; }
public bool DrawPiercePoints { get; set; }
/// <summary>The box around the selected parts.</summary>
public bool DrawBounds { get; set; } = true;
/// <summary>Each part's outline offset by the plate's part spacing.</summary>
public bool DrawOffset { get; set; }
public bool DrawCutDirection { get; set; }
/// <summary>The support bed, each part's contacts and center of gravity.</summary>
public bool DrawSupport { get; set; }
/// <summary>The chord tolerance of the offset outline.</summary>
public double OffsetTolerance { get; set; } = 0.001;
/// <summary>A view's toggles with the saved rapids and bounds; missing or unreadable values are defaults.</summary>
public static PlateViewOptions Read(IReadOnlyDictionary<string, string> 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;
}
/// <summary>Stores the rapids and bounds toggles in the WinForms format.</summary>
public void WriteTo(IDictionary<string, string> settings)
{
ArgumentNullException.ThrowIfNull(settings);
settings[DrawRapidName] = UserSettingText.Format(DrawRapid);
settings[DrawBoundsName] = UserSettingText.Format(DrawBounds);
}
}
@@ -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;
/// <summary>A program's display layers in its own frame, before placement.</summary>
/// <param name="HasOpenCuts">
/// Whether a cut contour is open, such as a cutout left attached by tabs. Filling
/// <paramref name="Cut"/> would close it with a chord; fill <see cref="ProgramGeometry.Fill"/> instead.
/// </param>
public sealed record ProgramLayers(
IReadOnlyList<PathFigure> Cut,
IReadOnlyList<PathFigure> Lead,
IReadOnlyList<PathFigure> Etch,
bool HasOpenCuts);
/// <summary>
/// A CNC program's moves as toolkit-neutral figures, routed by layer as the WinForms plate view
/// routes them (its <c>GraphicsHelper</c>): 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.
/// </summary>
public static class ProgramGeometry
{
/// <summary>Cut strokes: everything but etches, leads and tabs (tabs are never cut).</summary>
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;
/// <summary>
/// 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.
/// </summary>
public static bool IsFillLayer(LayerType layer) =>
layer is not (LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Break);
/// <summary>The cut, lead and etch figures of <paramref name="program"/>.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<PathFigure> Fill(Program program)
{
ArgumentNullException.ThrowIfNull(program);
return Figures(program, IsFillLayer, bridgeTabs: true, out _);
}
/// <summary>Whether <paramref name="program"/> or a sub-program has a break cut.</summary>
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));
/// <summary>
/// The unsuppressed moves on layers <paramref name="includeLayer"/> accepts, one figure per
/// continuous run. A run ends at a skipped move, a rapid that moves, or a sub-program call.
/// With <paramref name="bridgeTabs"/>, open figures are chained end to start (see <see cref="Combine"/>).
/// </summary>
public static List<PathFigure> Figures(Program program, Func<LayerType, bool> includeLayer, bool bridgeTabs,
out bool hasOpenFigures)
{
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(includeLayer);
var figures = new List<PathFigure>();
var position = Vector.Zero;
AddProgram(figures, program, ref position, includeLayer);
hasOpenFigures = figures.Exists(figure => !figure.IsClosed);
return Combine(figures, bridgeTabs);
}
/// <summary>
/// With <paramref name="bridgeTabs"/>, 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.
/// </summary>
public static List<PathFigure> Combine(IReadOnlyList<PathFigure> figures, bool bridgeTabs)
{
var result = new List<PathFigure>(figures.Count);
var pending = new List<PathFigure>(figures);
while (pending.Count > 0)
{
var figure = pending[0];
pending.RemoveAt(0);
if (!bridgeTabs || figure.IsClosed)
{
result.Add(figure);
continue;
}
var segments = new List<PathSegment>(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<PathFigure> figures, Program program, ref Vector position,
Func<LayerType, bool> includeLayer)
{
// Sub-program offsets are relative to the frame origin at entry, not the tool position.
var frameOrigin = position;
var mode = program.Mode;
List<PathSegment> 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<PathSegment>();
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();
}
/// <summary>An arc move from <paramref name="position"/>, as GraphicsHelper.AddArc draws it.</summary>
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);
}
/// <summary>
/// Geometry entities (display-only curves) as figures, one per entity, skipping lead-in and
/// lead-out layers as the WinForms shape path does.
/// </summary>
public static List<PathFigure> EntityFigures(IEnumerable<Entity> entities)
{
var figures = new List<PathFigure>();
foreach (var entity in entities)
AddEntity(figures, entity);
return figures;
}
private static void AddEntity(List<PathFigure> 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<PathSegment>(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));
}
/// <summary>The bounding box of the figures' points and arc extremes, or null when empty.</summary>
public static Box Bounds(IEnumerable<PathFigure> 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;
}
}
+145
View File
@@ -0,0 +1,145 @@
using System;
using OpenNest.Geometry;
namespace OpenNest.Desktop.Rendering;
/// <summary>A point in a view's screen (control) pixels, y down.</summary>
public readonly record struct ScreenPoint(double X, double Y);
/// <summary>
/// A plate or CAD view's world-to-screen mapping: world Y up, screen Y down, a uniform
/// <see cref="Scale"/> in pixels per drawing unit and <see cref="OriginX"/>/<see cref="OriginY"/>, the
/// screen position of the world origin. The zoom rules are the WinForms <c>DrawControl</c>'s: the
/// scale stays within <see cref="MinScale"/>..<see cref="MaxScale"/>, a zoom about a point keeps that
/// point still, and fitting an area leaves <see cref="FitBorder"/> pixels of margin in all.
/// </summary>
public sealed class ViewTransform
{
/// <summary>The DrawControl's zoom step for one Zoom In.</summary>
public const double ZoomInFactor = 1.15;
/// <summary>Total pixels left around a fitted area (half on each side).</summary>
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; }
/// <summary>Raised after the scale, origin or viewport changes.</summary>
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;
/// <summary>The world area the viewport shows.</summary>
public Box VisibleWorld()
{
var bottomLeft = ScreenToWorld(0, ViewportHeight);
return new Box(bottomLeft.X, bottomLeft.Y, ViewportWidth / Scale, ViewportHeight / Scale);
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>Moves the view by a screen offset.</summary>
public void Pan(double dx, double dy)
{
if (dx == 0 && dy == 0)
return;
OriginX += dx;
OriginY += dy;
OnChanged();
}
/// <summary>Zooms by <paramref name="factor"/> about the world point under <paramref name="screen"/>.</summary>
public void ZoomAt(ScreenPoint screen, double factor) => ZoomToPoint(ScreenToWorld(screen), factor);
/// <summary>
/// Zooms by <paramref name="factor"/> keeping <paramref name="world"/> at the same screen point;
/// the factor is cut so the scale stays within its limits.
/// </summary>
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();
}
/// <summary>Zooms in or out by one step about the viewport centre.</summary>
public void ZoomStep(bool zoomIn) =>
ZoomAt(new ScreenPoint(ViewportWidth / 2, ViewportHeight / 2), zoomIn ? ZoomInFactor : 1 / ZoomInFactor);
/// <summary>
/// Fits <paramref name="area"/> in the viewport, centred, with <see cref="FitBorder"/> pixels of
/// margin in all; an empty area or viewport changes nothing. Returns whether it changed the view.
/// </summary>
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;
}
/// <summary>The current scale and origin, to restore later.</summary>
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);
}