mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-13 20:31:11 -05:00
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:
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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")}";
|
||||
}
|
||||
@@ -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 > 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 > 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user