From 6f555345e3cfd43fb15162f53ca86efcd48653e0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 17 Oct 2026 17:46:35 -0400 Subject: [PATCH] feat(cad): cut breakoff joining lines as tabbed open break cuts Breakoffs are now picked by their joining line, default to the smaller side as scrap (flippable), and cut that line between tabs on a new Break layer instead of slots. Plan Cutting and Assign Lead-ins enter each cut from the scrap with the nest's internal lead-in, or pierce on the line without one; checks treat break cuts as intentional open cuts that are not material. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- OpenNest.Core/Breakoffs/Breakoff.cs | 28 +- OpenNest.Core/Breakoffs/BreakoffBuilder.cs | 403 +++++++++-------- OpenNest.Core/Breakoffs/BreakoffLayout.cs | 27 +- .../CNC/CuttingPlanning/BreakCutMotions.cs | 22 + .../CuttingPlanning/LeadMaterialSnapshot.cs | 3 +- .../CNC/CuttingPlanning/LeadPathValidator.cs | 33 +- .../CNC/CuttingPlanning/PreparedContours.cs | 36 +- .../CuttingPlanning/ReleasedContourState.cs | 18 + .../CuttingStrategy/ContourCuttingStrategy.cs | 130 +++++- OpenNest.Core/CNC/LayerType.cs | 9 +- OpenNest.Core/Converters/ConvertGeometry.cs | 31 +- OpenNest.Core/Converters/ConvertProgram.cs | 5 +- OpenNest.Core/Geometry/ShapeProfile.cs | 11 +- OpenNest.Core/SpecialLayers.cs | 9 +- .../CuttingPlanning/CuttingPlanModels.cs | 9 + OpenNest.IO.Tests/BreakoffPersistenceTests.cs | 20 +- OpenNest.IO/EntitySerializer.cs | 2 + OpenNest.IO/NestFormat.cs | 5 +- OpenNest.IO/NestReader.cs | 3 +- OpenNest.IO/NestWriter.cs | 5 +- OpenNest.IO/ProgramReader.cs | 8 + .../Breakoffs/BreakoffBuilderTests.cs | 408 +++++++++++------- .../ContourEntryCandidateTests.cs | 5 +- .../Forms/CadConverterBreakoffTests.cs | 30 +- OpenNest/Controls/EntityView.Breakoffs.cs | 48 ++- OpenNest/Controls/FilterPanel.cs | 8 + OpenNest/Forms/BreakoffDialog.cs | 21 +- OpenNest/Forms/CadConverterForm.cs | 83 +++- OpenNest/GraphicsHelper.cs | 14 +- OpenNest/LayoutPart.cs | 21 +- .../GravographISPostConfig.cs | 1 + docs/breakoffs.md | 87 ++-- docs/cutting-planner.md | 6 + docs/nest-file-format.md | 15 +- 35 files changed, 1066 insertions(+), 500 deletions(-) create mode 100644 OpenNest.Core/CNC/CuttingPlanning/BreakCutMotions.cs diff --git a/AGENTS.md b/AGENTS.md index 39829361..e84e7534 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Read the relevant contract before changing its behavior: - [Nest file format](docs/nest-file-format.md) - [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md) - [Lead-in placement](docs/geometry/lead-in-placement.md) -- [Breakoffs](docs/breakoffs.md): CAD Converter tabbed scrap, cut as closed slots +- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material - [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md) - [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md) - [Pre-post verification](docs/post-verification.md) diff --git a/OpenNest.Core/Breakoffs/Breakoff.cs b/OpenNest.Core/Breakoffs/Breakoff.cs index 344a1788..5f353d93 100644 --- a/OpenNest.Core/Breakoffs/Breakoff.cs +++ b/OpenNest.Core/Breakoffs/Breakoff.cs @@ -4,32 +4,30 @@ using System.Collections.Generic; namespace OpenNest.Breakoffs { /// - /// A scrap piece drawn outside a part (for example a forming backgauge tab) that stays - /// attached to it by tabs and is snapped off later. The breakoff's outside edges are an open - /// chain of source entities whose ends touch the part outline; the outline section between - /// those ends is the break line. Building the drawing merges the breakoff into the outline and - /// cuts the break line as closed slots carved into the breakoff, leaving the tabs between them - /// uncut, so every pierce lands in scrap. All values are in the drawing's source frame. + /// Scrap drawn on a part (for example a forming backgauge tab) that stays attached by tabs and + /// is snapped off later. It is identified by its joining line: a chain of source entities whose + /// ends touch the outline around part and scrap and which splits that region in two. By + /// default the smaller side is the scrap. Building the drawing cuts the joining line as open + /// break cuts between tabs, entered from the scrap side. All values are in the source frame. /// public sealed class Breakoff { public const double DefaultTabWidth = 0.0625; - public const double DefaultSlotWidth = 0.25; public const int DefaultTabCount = 4; public const int MinTabCount = 2; - /// Source entity IDs of the breakoff's outside edges. + /// Source entity IDs of the joining line between part and scrap. public List EdgeIds { get; set; } = new(); - /// Uncut length of each tab along the break line. + /// True when the larger side is the scrap instead of the smaller one. + public bool Flip { get; set; } + + /// Uncut length of each tab along the joining line. public double TabWidth { get; set; } = DefaultTabWidth; - /// How far each slot reaches into the breakoff from the break line. - public double SlotWidth { get; set; } = DefaultSlotWidth; - /// - /// Tab centers as distances along the break line from its start, ascending. Empty means - /// tabs spaced evenly with the first and last at the ends. + /// Tab centers as distances along the joining line from its start, ascending; the first + /// and last tab sit at the ends. Empty means evenly spaced. /// public List TabCenters { get; set; } = new(); @@ -37,8 +35,8 @@ namespace OpenNest.Breakoffs new() { EdgeIds = new List(EdgeIds), + Flip = Flip, TabWidth = TabWidth, - SlotWidth = SlotWidth, TabCenters = new List(TabCenters), }; } diff --git a/OpenNest.Core/Breakoffs/BreakoffBuilder.cs b/OpenNest.Core/Breakoffs/BreakoffBuilder.cs index 4786e532..04335fd7 100644 --- a/OpenNest.Core/Breakoffs/BreakoffBuilder.cs +++ b/OpenNest.Core/Breakoffs/BreakoffBuilder.cs @@ -7,21 +7,22 @@ using OpenNest.Math; namespace OpenNest.Breakoffs { /// - /// Resolves and applies s. The break line is cut as closed slots on the - /// breakoff side rather than as open cuts, because the cutting planner only prepares closed - /// contours: each slot's pierce and lead-in fall inside the slot, which is breakoff scrap. + /// Resolves and applies s: the joining line becomes open cuts on the + /// break layer between tabs, each travelling with the scrap on its left so the cutting + /// strategies pierce and lead in from the scrap. /// public static class BreakoffBuilder { - /// How close the breakoff's ends must be to the outline. + /// How close the joining line's ends must be to the outline. public const double JoinTolerance = 0.001; private const double ArcTolerance = 0.0005; /// - /// with every valid breakoff merged into its outline and its - /// break line slotted. Inputs are not modified; generated entities are new. Breakoffs that - /// cannot be resolved are skipped and described in . + /// with every valid breakoff applied: its joining line replaced + /// by break cuts and its outline's joints made exact. Inputs are not modified; generated + /// entities are new. Breakoffs that cannot be resolved are skipped and described in + /// . /// public static List Apply(IReadOnlyList entities, IReadOnlyList breakoffs, out List errors) @@ -43,43 +44,40 @@ namespace OpenNest.Breakoffs var replaced = new HashSet(layout.ReplacedEntities, ReferenceEqualityComparer.Instance); working.RemoveAll(replaced.Contains); - // Fresh IDs: the outline holds copies of this breakoff's edges, which later - // breakoffs exclude by ID when they look for the outline. working.AddRange(layout.Outline.Select(e => e.Clone())); - working.AddRange(layout.Slots.SelectMany(s => s).Select(e => e.Clone())); + working.AddRange(layout.Cuts.SelectMany(c => c).Select(e => e.Clone())); } return working; } /// /// Resolves against (the entities that - /// will be cut). are edges of other breakoffs on the same - /// drawing, which are ignored while finding the outline. + /// will be cut). are joining lines of other breakoffs on the + /// same drawing, which are ignored while finding the outline. /// public static BreakoffLayout Layout(IReadOnlyList entities, Breakoff breakoff, IEnumerable otherEdgeIds = null) { ArgumentNullException.ThrowIfNull(entities); ArgumentNullException.ThrowIfNull(breakoff); - if (!(breakoff.TabWidth > 0) || !double.IsFinite(breakoff.TabWidth) - || !(breakoff.SlotWidth > 0) || !double.IsFinite(breakoff.SlotWidth)) - return Fail("Tab and slot widths must be greater than zero."); + if (!(breakoff.TabWidth > 0) || !double.IsFinite(breakoff.TabWidth)) + return Fail("The tab width must be greater than zero."); var edgeIds = breakoff.EdgeIds.ToHashSet(); var edgeSources = entities.Where(e => edgeIds.Contains(e.Id)).ToList(); if (edgeIds.Count == 0 || edgeSources.Count != edgeIds.Count) - return Fail("Some of its edges are hidden or missing."); + return Fail("Its joining line is hidden or missing."); - var edges = BreakoffPath.ChainOpen(edgeSources, JoinTolerance); - if (edges == null) - return Fail("Its edges must form one open chain."); + var line = BreakoffPath.ChainOpen(edgeSources, JoinTolerance); + if (line == null) + return Fail("Its joining line must be one open chain."); // Measure tabs from a fixed end so positions survive re-chaining. - var a = BreakoffPath.Start(edges[0]); - var b = BreakoffPath.End(edges[^1]); + var a = BreakoffPath.Start(line[0]); + var b = BreakoffPath.End(line[^1]); if (b.X < a.X - JoinTolerance || (System.Math.Abs(b.X - a.X) <= JoinTolerance && b.Y < a.Y)) { - edges = BreakoffPath.Reversed(edges); + line = BreakoffPath.Reversed(line); (a, b) = (b, a); } @@ -98,7 +96,7 @@ namespace OpenNest.Breakoffs Shape outline = null; var outlineArea = 0.0; - foreach (var shape in ShapeBuilder.GetShapes(candidates)) + foreach (var shape in ShapeBuilder.GetShapes(WithoutDanglingEnds(candidates))) { if (shape.Entities.Count < 2 || !shape.IsClosed() || shape.ClosestPointTo(a).DistanceTo(a) > JoinTolerance @@ -112,80 +110,70 @@ namespace OpenNest.Breakoffs } } if (outline == null) - return Fail("Both ends must touch a closed outline."); + return Fail("Both ends of the joining line must touch a closed outline."); - var loop = outline.Entities; + var loop = CloseExactly(outline.Entities); var total = BreakoffPath.Length(loop); var atA = BreakoffPath.DistanceOf(loop, a); var rotated = BreakoffPath.Sub(loop, atA, total).Concat(BreakoffPath.Sub(loop, 0, atA)).ToList(); var atB = BreakoffPath.DistanceOf(rotated, b); if (atB <= JoinTolerance || atB >= total - JoinTolerance) - return Fail("Its ends must touch the outline at two different points."); + return Fail("The joining line must touch the outline at two different points."); var toB = BreakoffPath.Sub(rotated, 0, atB); var backToA = BreakoffPath.Sub(rotated, atB, total); - // The planner closes contours far tighter than the join tolerance, so the edges must - // end exactly where the outline was split. - edges = SnapEnds(edges, BreakoffPath.Start(toB[0]), BreakoffPath.Start(backToA[0])); - var viaBack = edges.Concat(backToA).ToList(); - var viaTo = edges.Concat(BreakoffPath.Reversed(toB)).ToList(); - var viaBackArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(viaBack, ArcTolerance))); - var viaToArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(viaTo, ArcTolerance))); + // The two sides: the outline from A to B closed back along the line, and the line + // closed back along the rest of the outline. + var sideTo = toB.Concat(BreakoffPath.Reversed(line)).ToList(); + var sideBack = line.Concat(backToA).ToList(); + var sideToArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideTo, ArcTolerance))); + var sideBackArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideBack, ArcTolerance))); + if (System.Math.Abs(sideToArea + sideBackArea - outlineArea) > 0.01 * outlineArea) + return Fail("The joining line must run inside the outline."); - // The breakoff region is the smaller loop; the merged outline is the other one. - List region, merged, breakLine; - double regionArea, mergedArea; - if (viaBackArea <= viaToArea) - { - (region, regionArea, merged, mergedArea) = (viaBack, viaBackArea, viaTo, viaToArea); - breakLine = BreakoffPath.Reversed(backToA); - } - else - { - (region, regionArea, merged, mergedArea) = (viaTo, viaToArea, viaBack, viaBackArea); - breakLine = toB; - } + var scrap = (sideToArea <= sideBackArea) != breakoff.Flip ? sideTo : sideBack; + var scrapPoints = BreakoffPath.ToPoints(scrap, ArcTolerance); - if (System.Math.Abs(mergedArea - (outlineArea + regionArea)) > 0.01 * mergedArea) - return Fail("It must sit outside the part outline."); - - var length = BreakoffPath.Length(breakLine); + var length = BreakoffPath.Length(line); var tabWidth = breakoff.TabWidth; var centers = breakoff.TabCenters.Count == 0 ? EvenTabCenters(length, Breakoff.DefaultTabCount, tabWidth) : breakoff.TabCenters.OrderBy(c => c).ToList(); if (centers.Count < Breakoff.MinTabCount || length <= tabWidth * centers.Count) - return Fail("The break line is too short for its tabs."); + return Fail("The joining line is too short for its tabs."); if (centers[0] < tabWidth / 2 - JoinTolerance || centers[^1] > length - tabWidth / 2 + JoinTolerance) - return Fail("A tab runs off the break line; space the tabs evenly again."); + return Fail("A tab runs off the joining line; space the tabs evenly again."); for (var i = 1; i < centers.Count; i++) if (centers[i] - centers[i - 1] <= tabWidth + JoinTolerance) return Fail("Two tabs overlap; space the tabs evenly again."); - var regionPoints = BreakoffPath.ToPoints(region, ArcTolerance); - var side = SideOf(breakLine, length, regionPoints, System.Math.Min(breakoff.SlotWidth, length) * 0.05); + var side = SideOf(line, length, scrapPoints); if (side == 0) - return Fail("Could not tell which side of the break line the breakoff is on."); + return Fail("Could not tell which side of the joining line is scrap."); - var slots = new List>(); + var cuts = new List>(); for (var i = 0; i + 1 < centers.Count; i++) { - var slot = BuildSlot(breakLine, centers[i] + tabWidth / 2, centers[i + 1] - tabWidth / 2, - side, breakoff.SlotWidth, tabWidth, edges, regionPoints); - if (slot == null) - return Fail($"There is no room for slot {i + 1} inside the breakoff; " - + "use a narrower slot or move the tabs."); - slots.Add(slot); + var cut = BreakoffPath.Sub(line, centers[i] + tabWidth / 2, centers[i + 1] - tabWidth / 2); + if (side < 0) + cut = BreakoffPath.Reversed(cut); + foreach (var e in cut) + { + e.Id = Guid.NewGuid(); + e.Layer = SpecialLayers.Break; + } + cuts.Add(cut); } var replaced = outline.Entities.Select(e => sources[e]).ToList(); replaced.AddRange(edgeSources); return new BreakoffLayout { - BreakLine = breakLine, + BreakLine = line, Length = length, - Outline = merged, - Slots = slots, + Outline = loop, + Scrap = scrap, + Cuts = cuts, TabCenters = centers, TabWidth = tabWidth, ReplacedEntities = replaced, @@ -202,9 +190,9 @@ namespace OpenNest.Breakoffs } /// - /// The open chain of visible lines and arcs through , grown until it - /// reaches a point where it does not continue as a single path (such as where it meets an - /// outline). Empty when cannot be a breakoff edge. + /// The chain of visible lines and arcs through , grown until it + /// reaches a point where it does not continue as a single path (such as where the joining + /// line meets the outline). Empty when cannot be a joining line. /// public static List FindEdgeChain(IReadOnlyList entities, Entity picked) { @@ -236,37 +224,150 @@ namespace OpenNest.Breakoffs } } - private static BreakoffLayout Fail(string error) => new(error); - - private static List SnapEnds(List edges, Vector start, Vector end) + /// + /// Splits each visible line or arc of where another one ends on + /// its interior (a T-junction), so a joining line can end exactly where it meets other + /// geometry. The first piece keeps the entity's ID. Returns the IDs of every split entity's + /// pieces, keyed by its original ID. + /// + public static Dictionary> SplitAtJunctions(List entities) { - var result = new List(edges); - var first = result[0]; - if (BreakoffPath.Start(first).DistanceTo(start) > 0) + var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e)) + .ToList(); + var ends = pool.SelectMany(e => new[] { BreakoffPath.Start(e), BreakoffPath.End(e) }).ToList(); + var split = new Dictionary>(); + foreach (var host in pool) { - if (first is Line line) - line.StartPoint = start; - else - result.Insert(0, Joint(first, start, BreakoffPath.Start(first))); + var box = host.BoundingBox; + var cuts = new List(); + foreach (var point in ends) + { + if (point.X < box.Left - JoinTolerance || point.X > box.Right + JoinTolerance + || point.Y < box.Bottom - JoinTolerance || point.Y > box.Top + JoinTolerance) + continue; + if (point.DistanceTo(BreakoffPath.Start(host)) <= JoinTolerance + || point.DistanceTo(BreakoffPath.End(host)) <= JoinTolerance + || host.ClosestPointTo(point).DistanceTo(point) > JoinTolerance) + continue; + var at = BreakoffPath.DistanceOf(new[] { host }, point); + if (cuts.All(c => System.Math.Abs(c - at) > JoinTolerance)) + cuts.Add(at); + } + if (cuts.Count == 0) + continue; + + cuts.Sort(); + var bounds = new List { 0 }; + bounds.AddRange(cuts); + bounds.Add(host.Length); + var pieces = new List(); + for (var i = 0; i + 1 < bounds.Count; i++) + pieces.AddRange(BreakoffPath.Sub(new[] { host }, bounds[i], bounds[i + 1])); + pieces[0].Id = host.Id; + foreach (var piece in pieces) + piece.IsVisible = host.IsVisible; + var index = entities.IndexOf(host); + entities.RemoveAt(index); + entities.InsertRange(index, pieces); + split[host.Id] = pieces.Select(p => p.Id).ToList(); } - var last = result[^1]; - if (BreakoffPath.End(last).DistanceTo(end) > 0) - { - if (last is Line line) - line.EndPoint = end; - else - result.Add(Joint(last, BreakoffPath.End(last), end)); - } - return result; + return split; } - private static Line Joint(Entity style, Vector from, Vector to) => - new(from, to) { Layer = style.Layer, Color = style.Color, LineTypeName = style.LineTypeName }; + private static BreakoffLayout Fail(string error) => new(error); + + // A stray open line ending on the outline (an unmarked etch line, say) would otherwise be + // chained into it and keep the loop from closing. Nothing with a free end can be part of a + // closed outline, so such entities are dropped, repeatedly, before chaining. + private static List WithoutDanglingEnds(List entities) + { + // Cluster endpoints that meet within the chain tolerance, using a grid of that size. + const double cell = Tolerance.ChainTolerance; + var grid = new Dictionary<(long, long), List<(Vector Point, int Cluster)>>(); + var clusterOf = new int[entities.Count, 2]; + var sizes = new List(); + for (var i = 0; i < entities.Count; i++) + for (var side = 0; side < 2; side++) + { + var point = side == 0 ? BreakoffPath.Start(entities[i]) : BreakoffPath.End(entities[i]); + var gx = (long)System.Math.Floor(point.X / cell); + var gy = (long)System.Math.Floor(point.Y / cell); + var cluster = -1; + for (var dx = -1; dx <= 1 && cluster < 0; dx++) + for (var dy = -1; dy <= 1 && cluster < 0; dy++) + if (grid.TryGetValue((gx + dx, gy + dy), out var near)) + foreach (var (other, id) in near) + if (other.DistanceTo(point) <= cell) + { + cluster = id; + break; + } + if (cluster < 0) + { + cluster = sizes.Count; + sizes.Add(0); + } + if (!grid.TryGetValue((gx, gy), out var bucket)) + grid[(gx, gy)] = bucket = new List<(Vector, int)>(); + bucket.Add((point, cluster)); + sizes[cluster]++; + clusterOf[i, side] = cluster; + } + + // Peel entities with a free end until none is left; closed loops never peel. + var members = new List[sizes.Count]; + for (var i = 0; i < entities.Count; i++) + for (var side = 0; side < 2; side++) + (members[clusterOf[i, side]] ??= new List()).Add(i); + var removed = new bool[entities.Count]; + var queue = new Queue(Enumerable.Range(0, entities.Count) + .Where(i => sizes[clusterOf[i, 0]] < 2 || sizes[clusterOf[i, 1]] < 2)); + while (queue.Count > 0) + { + var i = queue.Dequeue(); + if (removed[i]) + continue; + removed[i] = true; + for (var side = 0; side < 2; side++) + { + var cluster = clusterOf[i, side]; + if (--sizes[cluster] == 1) + foreach (var other in members[cluster]) + if (!removed[other]) + queue.Enqueue(other); + } + } + return entities.Where((_, i) => !removed[i]).ToList(); + } private static bool IsMark(Entity e) => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase) || string.Equals(e.Layer?.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase); + // The planner closes contours far tighter than the import chain tolerance, and the outline + // is re-chained from any entity when the program is built, so every joint must be exact. + private static List CloseExactly(IReadOnlyList loop) + { + var result = new List(); + for (var i = 0; i < loop.Count; i++) + { + var current = loop[i]; + var next = loop[(i + 1) % loop.Count]; + result.Add(current); + var end = BreakoffPath.End(current); + var start = BreakoffPath.Start(next); + if (end.DistanceTo(start) == 0) + continue; + if (next is Line nextLine) + nextLine.StartPoint = end; + else if (current is Line currentLine) + currentLine.EndPoint = start; + else if (end.DistanceTo(start) > 1e-8) + result.Add(new Line(end, start) { Layer = current.Layer, Color = current.Color, LineTypeName = current.LineTypeName }); + } + return result; + } + private static Vector Normal(IReadOnlyList path, double length, double distance) { const double nudge = 1e-6; @@ -274,134 +375,26 @@ namespace OpenNest.Breakoffs var after = BreakoffPath.TangentAt(path, System.Math.Min(distance + nudge, length)); var t = new Vector(before.X + after.X, before.Y + after.Y); var size = System.Math.Sqrt(t.X * t.X + t.Y * t.Y); - if (size <= Tolerance.Epsilon) - t = after; - else - t = new Vector(t.X / size, t.Y / size); + t = size <= Tolerance.Epsilon ? after : new Vector(t.X / size, t.Y / size); return new Vector(-t.Y, t.X); } - // +1 when the breakoff lies left of the break line's travel, -1 when right, 0 when unclear. - private static int SideOf(IReadOnlyList breakLine, double length, IReadOnlyList region, double probe) + // +1 when the scrap lies left of the line's travel, -1 when right, 0 when unclear. + private static int SideOf(IReadOnlyList line, double length, IReadOnlyList scrap) { + var probe = System.Math.Min(0.01, length * 0.01); var votes = 0; foreach (var fraction in new[] { 0.25, 0.5, 0.75 }) { var d = length * fraction; - var p = BreakoffPath.PointAt(breakLine, d); - var n = Normal(breakLine, length, d); - var left = BreakoffPath.Contains(region, p + n * probe); - var right = BreakoffPath.Contains(region, p - n * probe); + var p = BreakoffPath.PointAt(line, d); + var n = Normal(line, length, d); + var left = BreakoffPath.Contains(scrap, p + n * probe); + var right = BreakoffPath.Contains(scrap, p - n * probe); if (left != right) votes += left ? 1 : -1; } return System.Math.Sign(votes); } - - // The slot's part side is the exact break line from 'from' to 'to'; its scrap side follows - // the break line 'depth' into the breakoff, pulled back near the breakoff's edges so at least - // 'clearance' of breakoff remains beyond it. Its ends move inward where there is no room. - private static List BuildSlot(IReadOnlyList breakLine, double from, double to, int side, - double depth, double clearance, IReadOnlyList edges, IReadOnlyList region) - { - var length = BreakoffPath.Length(breakLine); - var minDepth = depth / 4; - var step = System.Math.Max(depth / 8, (to - from) / 400); - - double Room(double d) - { - var p = BreakoffPath.PointAt(breakLine, d); - var dir = Normal(breakLine, length, d) * side; - var reach = depth + clearance; - var ray = new Line(p, p + dir * reach); - var nearest = reach; - foreach (var edge in edges) - { - if (!edge.Intersects(ray, out var hits)) - continue; - foreach (var hit in hits) - { - var gap = hit.DistanceTo(p); - if (gap > Tolerance.Epsilon) - nearest = System.Math.Min(nearest, gap); - } - } - return System.Math.Min(depth, nearest - clearance); - } - - var start = from; - while (start < to && Room(start) < minDepth) - start += step; - var end = to; - while (end > start && Room(end) < minDepth) - end -= step; - if (end - start < minDepth) - return null; - - var stations = new SortedSet { start, end }; - for (var d = start + depth / 4; d < end; d += depth / 4) - stations.Add(d); - var walked = 0.0; - foreach (var e in breakLine) - { - walked += e.Length; - if (walked > start && walked < end) - stations.Add(walked); - } - - var far = new List(); - foreach (var d in stations) - { - var room = Room(d); - if (room < minDepth / 2) - return null; - var point = BreakoffPath.PointAt(breakLine, d) + Normal(breakLine, length, d) * side * room; - if (!BreakoffPath.Contains(region, point)) - return null; - far.Add(point); - } - far = DropCollinear(far); - - var near = BreakoffPath.Sub(breakLine, start, end); - var style = near[0]; - var slot = new List(near); - var path = new List { BreakoffPath.End(near[^1]) }; - for (var i = far.Count - 1; i >= 0; i--) - path.Add(far[i]); - path.Add(BreakoffPath.Start(near[0])); - for (var i = 1; i < path.Count; i++) - { - if (path[i].DistanceTo(path[i - 1]) <= Tolerance.Epsilon) - continue; - slot.Add(new Line(path[i - 1], path[i]) - { - Layer = style.Layer, - Color = style.Color, - LineTypeName = style.LineTypeName, - }); - } - return slot; - } - - private static List DropCollinear(List points) - { - if (points.Count < 3) - return points; - var result = new List { points[0] }; - for (var i = 1; i < points.Count - 1; i++) - { - var prev = result[^1]; - var u = points[i] - prev; - var v = points[i + 1] - points[i]; - var cross = u.X * v.Y - u.Y * v.X; - var scale = System.Math.Sqrt((u.X * u.X + u.Y * u.Y) * (v.X * v.X + v.Y * v.Y)); - var dot = u.X * v.X + u.Y * v.Y; - if (System.Math.Abs(cross) <= 1e-9 * scale && dot > 0) - continue; - result.Add(points[i]); - } - result.Add(points[^1]); - return result; - } } } diff --git a/OpenNest.Core/Breakoffs/BreakoffLayout.cs b/OpenNest.Core/Breakoffs/BreakoffLayout.cs index ddf28599..d255dbfd 100644 --- a/OpenNest.Core/Breakoffs/BreakoffLayout.cs +++ b/OpenNest.Core/Breakoffs/BreakoffLayout.cs @@ -4,9 +4,8 @@ using OpenNest.Geometry; namespace OpenNest.Breakoffs { /// - /// A resolved against a drawing's entities: the break line, the merged - /// outline and the slots that cut the break line between tabs. When is set - /// nothing else is meaningful and the breakoff is not applied. + /// A resolved against a drawing's entities. When is + /// set nothing else is meaningful and the breakoff is not applied. /// public sealed class BreakoffLayout { @@ -18,35 +17,39 @@ namespace OpenNest.Breakoffs public bool IsValid => Error == null; - /// The outline section shared by the part and the breakoff, in travel order from its start. + /// The joining line in travel order from its start. public IReadOnlyList BreakLine { get; internal set; } public double Length { get; internal set; } - /// The part outline with the break line replaced by the breakoff's edges. + /// The closed outline around part and scrap, with its joints made exact. public IReadOnlyList Outline { get; internal set; } - /// One closed contour per cut section of the break line. - public IReadOnlyList> Slots { get; internal set; } + /// The scrap side's closed boundary (part of the outline plus the joining line). + public IReadOnlyList Scrap { get; internal set; } + + /// + /// One open cut per section of the joining line between tabs, on the break layer and + /// travel-ordered with the scrap to its left, so each is entered from the scrap. + /// + public IReadOnlyList> Cuts { get; internal set; } /// Tab centers used, as distances along . public IReadOnlyList TabCenters { get; internal set; } public double TabWidth { get; internal set; } - /// Entities the outline and slots replace: the original outline and breakoff edges. + /// Entities the outline and cuts replace: the original outline and joining line. internal IReadOnlyList ReplacedEntities { get; set; } public Vector PointAt(double distance) => BreakoffPath.PointAt(BreakLine, distance); - public Vector TangentAt(double distance) => BreakoffPath.TangentAt(BreakLine, distance); - - /// The distance along the break line closest to . + /// The distance along the joining line closest to . public double DistanceOf(Vector point) => BreakoffPath.DistanceOf(BreakLine, point); /// /// for tab limited so the tab stays on the - /// break line and keeps a gap from its neighbours. + /// joining line and keeps a gap from its neighbours. /// public double ClampTabCenter(int index, double center) { diff --git a/OpenNest.Core/CNC/CuttingPlanning/BreakCutMotions.cs b/OpenNest.Core/CNC/CuttingPlanning/BreakCutMotions.cs new file mode 100644 index 00000000..61cdfa31 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/BreakCutMotions.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace OpenNest.CNC.CuttingPlanning; + +/// Which motions of an execution cut a break line: its lead-in and its break moves. +internal static class BreakCutMotions +{ + /// True for every motion and the lead-in run directly before one. + internal static bool[] Mask(IReadOnlyList motions) + { + var mask = new bool[motions.Count]; + for (var i = 0; i < motions.Count; i++) + { + if (motions[i].Rapid || motions[i].Layer != LayerType.Break) + continue; + mask[i] = true; + for (var lead = i - 1; lead >= 0 && !motions[lead].Rapid && motions[lead].Layer == LayerType.Leadin; lead--) + mask[lead] = true; + } + return mask; + } +} diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs index 8579d016..5a337afa 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs @@ -34,7 +34,8 @@ public sealed class LeadMaterialSnapshot foreach (var motion in execution.Motions) { token.ThrowIfCancellationRequested(); - if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout) + // Break cuts run through material that stays attached at tabs; they bound nothing. + if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Break) { Finish(); continue; diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs index 0455f965..4904cb47 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs @@ -79,10 +79,27 @@ public static class LeadPathValidator } var adjacentIndex = groupEdge + step; var genuineJoint = false; + var breakLead = false; if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count) { var adjacent = execution.Motions[adjacentIndex]; - if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display + // A break cut lies inside its part's material, so its lead joins no boundary: + // the joint is the cut's own start (or end for a lead-out). + if (!adjacent.Rapid && adjacent.Layer == LayerType.Break + && adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon) + { + var edge = execution.Motions[groupEdge]; + var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start; + var cutJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End; + if (joint.DistanceTo(cutJoint) <= PostVerificationGeometry.Epsilon) + { + genuineJoint = true; + breakLead = true; + if (groupEdge == i) + allowed = joint; + } + } + else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display && adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon) { var edge = execution.Motions[groupEdge]; @@ -112,9 +129,15 @@ public static class LeadPathValidator if (allowed is { } jointPoint && (move.Layer == LayerType.Leadin ? start : move.End).DistanceTo(jointPoint) <= PostVerificationGeometry.Epsilon) return (new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only."), null); - var failure = CheckMaterial(target, allowed); + // A break cut's lead runs through its own breakoff scrap: entering the target's + // material is expected there, but touching any of its boundaries is not. + var failure = breakLead + ? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true) + : CheckMaterial(target, allowed); if (failure != null) - return (new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null); + return (new(true, false, breakLead + ? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs." + : $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null); for (var other = 0; other < otherMaterials.Count; other++) { token.ThrowIfCancellationRequested(); @@ -143,7 +166,7 @@ public static class LeadPathValidator /// A contact at is allowed. Exceeding the budget throws. /// internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, - Vector? permittedJoint, ref int budget, CancellationToken token) + Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false) { foreach (var boundary in material.Rings.SelectMany(r => r)) { @@ -158,6 +181,6 @@ public static class LeadPathValidator // With all other boundary contacts excluded, the connected open path // has constant material membership. Use the native arc midpoint, not // the chord midpoint or endpoints (which can both lie in scrap). - return material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null; + return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null; } } diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 90f8e589..71a38cab 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -23,23 +23,27 @@ public sealed class PreparedContours private readonly Shape[] shapes; private readonly CuttingParameters parameters; private readonly List scribes; + // Open break cuts in program order, each travel-ordered with its scrap on the left. They are + // not contours: no choices, no material; every full or prefix emission cuts them first. + private readonly List breakCuts; // By contour ordinal; all null without pins. A pinned contour has exactly one entry and // its own owned settings (the pin's lead-in, lead-out and tab). private readonly ContourChoice[] pinned; private readonly CuttingParameters[] contourParameters; private readonly int[] pinNumbers; - private PreparedContours(Shape[] shapes, List scribes, CuttingParameters parameters) + private PreparedContours(Shape[] shapes, List scribes, List breakCuts, CuttingParameters parameters) { this.shapes = shapes; this.scribes = scribes; + this.breakCuts = breakCuts; this.parameters = parameters; pinned = new ContourChoice[shapes.Length]; pinNumbers = new int[shapes.Length]; } private PreparedContours(PreparedContours unpinned, IReadOnlyList pins, CancellationToken token) - : this(unpinned.shapes, unpinned.scribes, unpinned.parameters) + : this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.parameters) { contourParameters = new CuttingParameters[shapes.Length]; var rejected = new List(); @@ -134,12 +138,29 @@ public sealed class PreparedContours var geometryProgram = CopyForGeometry(program, token); var contours = new List(); var scribes = new List(); + var breakCuts = new List(); + Shape breakCut = null; var current = new Shape(); foreach (var entity in geometryProgram.ToGeometry()) { token.ThrowIfCancellationRequested(); if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle)) throw new NotSupportedException("Unsupported native geometry."); + if (entity.Layer == SpecialLayers.Break) + { + if (current.Entities.Count != 0) + throw new ArgumentException("Preparation requires closed execution contours."); + if (entity is Circle || entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length)) + throw new ArgumentException("Degenerate break cut entity."); + if (breakCut == null || End(breakCut.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon) + { + breakCut = new Shape(); + breakCuts.Add(breakCut); + } + breakCut.Entities.Add(entity); + continue; + } + breakCut = null; if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe) { if (current.Entities.Count != 0) @@ -179,7 +200,9 @@ public sealed class PreparedContours var outer = contours[perimeter]; contours.RemoveAt(perimeter); contours.Add(outer); - return new(contours.ToArray(), scribes, ownedParameters); + foreach (var cut in breakCuts) + cut.UpdateBounds(); + return new(contours.ToArray(), scribes, breakCuts, ownedParameters); void FinishContour() { @@ -565,15 +588,18 @@ public sealed class PreparedContours contourParameters); } - // Each prefix is a standalone owned program, including the same scribes once. + // Each prefix is a standalone owned program, including the same scribes and break cuts once. internal Program EmitPrefix(IReadOnlyList choices) { Validate(choices, false); return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared( shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices, - contourParameters); + contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList()); } + /// Open break cuts this preparation cuts before its contours. + public int BreakCutCount => breakCuts.Count; + internal void ValidateCompleteChoices(IReadOnlyList choices) => Validate(choices, true); // Build expected-emission metadata independently of the selected payload BEFORE diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs index 3d97c045..7a55f528 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs @@ -71,11 +71,29 @@ public sealed class ReleasedContourState var unfinished = new List<(PostVerificationGeometry.Curve[] Curves, bool Fixed)>(); var hasLead = false; var contourNumber = 0; + var inBreakCut = false; for (var index = 0; index < moves.Count; index++) { token.ThrowIfCancellationRequested(); var move = moves[index]; var owned = isFixed?.Invoke(index) == true; + if (!move.Rapid && move.Layer == LayerType.Break) + { + // A break cut is intentionally open and retained by its uncut tabs: it is never a + // rapid obstacle and never an unexplained fragment, but it is still pierced. + if (!inBreakCut) + { + Finish(); + if (reportMissingLeadIns && !cutoff && !hasLead) + Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, + "A break cut has no nonzero placed lead-in motion.") + { Location = move.Curve.Start }, owned); + inBreakCut = true; + hasLead = false; + } + continue; + } + inBreakCut = false; if (move.Rapid) { Finish(); diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index c6dec526..c57ac0a7 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -19,6 +19,7 @@ namespace OpenNest.CNC.CuttingStrategy public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart) { var entities = partProgram.ToGeometry(); + var breakCuts = TakeBreakCuts(entities); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe); @@ -77,6 +78,7 @@ namespace OpenNest.CNC.CuttingStrategy var result = new Program(Mode.Absolute); EmitScribeContours(result, scribeEntities); + EmitBreakCuts(result, breakCuts, BreakCutContours(profile)); foreach (var entry in cutoutEntries) { @@ -110,6 +112,7 @@ namespace OpenNest.CNC.CuttingStrategy ) { var entities = partProgram.ToGeometry(); + var breakCuts = TakeBreakCuts(entities); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe); @@ -120,11 +123,12 @@ namespace OpenNest.CNC.CuttingStrategy var result = new Program(Mode.Absolute); EmitScribeContours(result, scribeEntities); + EmitBreakCuts(result, breakCuts, BreakCutContours(profile)); // Find the target shape that contains the clicked entity var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity); - // Emit cutouts — only the target gets lead-in/out (skip open contours) + // Emit cutouts — only the target gets lead-in/out (skip open contours) foreach (var cutout in profile.Cutouts) { if (!cutout.IsClosed()) @@ -238,10 +242,11 @@ namespace OpenNest.CNC.CuttingStrategy /// own lead-in, lead-out and tab); a null list or entry uses . internal Program EmitPrepared(Shape[] shapes, List scribes, IReadOnlyList choices, - IReadOnlyList contourParameters = null) + IReadOnlyList contourParameters = null, IReadOnlyList breakCuts = null) { var result = new Program(Mode.Absolute); EmitScribeContours(result, scribes); + EmitBreakCuts(result, breakCuts, shapes); EmitChosenContours(result, shapes, choices, contourParameters); result.Mode = Mode.Incremental; return result; @@ -249,7 +254,7 @@ namespace OpenNest.CNC.CuttingStrategy // Diagnostic seam for candidate lead validation (PreparedContours // .EmitCandidateForValidation): emit a single already-validated choice with its - // ORIGINAL contour type — the last shape is External even when emitted alone, so a + // ORIGINAL contour type — the last shape is External even when emitted alone, so a // perimeter never degrades to a hole because it was isolated. Output must not be // installed on a Part or accepted as a complete plan. internal Program EmitCandidateIsolated(Shape[] shapes, List scribes, @@ -264,7 +269,7 @@ namespace OpenNest.CNC.CuttingStrategy // One contour per choice in order; the perimeter (last shape) is always External. // Contour emission itself reads only the contour's own geometry and settings, never - // prior choices — the differential tests in ContourCandidateEmissionTests pin this. + // prior choices — the differential tests in ContourCandidateEmissionTests pin this. private void EmitChosenContours(Program result, Shape[] shapes, IReadOnlyList choices, IReadOnlyList contourParameters) { @@ -797,7 +802,7 @@ namespace OpenNest.CNC.CuttingStrategy { var normal = ComputeNormal(point, corner.Outgoing, contourType, winding); var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding); - // Sum unit normals rather than averaging angles (which fails at 0/2π). + // Sum unit normals rather than averaging angles (which fails at 0/2Ï€). // Winding makes this point into the scrap even at reflex corners. var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal); var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal); @@ -854,7 +859,7 @@ namespace OpenNest.CNC.CuttingStrategy if (entity is Line line) { - // Perpendicular to line direction: tangent + π/2 = left side. + // Perpendicular to line direction: tangent + Ï€/2 = left side. // Left side = outward for CW winding; for CCW winding, outward // is on the right side, so flip. var tangent = line.EndPoint.AngleFrom(line.StartPoint); @@ -865,7 +870,7 @@ namespace OpenNest.CNC.CuttingStrategy else if (entity is Arc arc) { // Radial direction from center to point. - // Flip when the arc direction differs from the contour winding — + // Flip when the arc direction differs from the contour winding — // that indicates a concave feature where radial points inward. normal = point.AngleFrom(arc.Center); if (arc.Rotation != winding) @@ -873,7 +878,7 @@ namespace OpenNest.CNC.CuttingStrategy } else if (entity is Circle circle) { - // Radial outward — always correct regardless of winding + // Radial outward — always correct regardless of winding normal = point.AngleFrom(circle.Center); } else @@ -1012,7 +1017,7 @@ namespace OpenNest.CNC.CuttingStrategy break; } - // No intersection — entity is entirely inside circle, remove it + // No intersection — entity is entirely inside circle, remove it if (EntityStartPoint(entity).DistanceTo(center) <= tabSize + Tolerance.Epsilon) { entities.RemoveAt(entities.Count - 1); @@ -1080,6 +1085,113 @@ namespace OpenNest.CNC.CuttingStrategy return moves; } + /// + /// Removes break-cut entities from (in program order) and + /// returns them as travel-ordered open chains, one per uninterrupted run. + /// + internal static List TakeBreakCuts(List entities) + { + var cuts = new List(); + Shape current = null; + foreach (var entity in entities) + { + if (entity.Layer != SpecialLayers.Break) + { + current = null; + continue; + } + if (current == null + || EntityEndPoint(current.Entities[^1]).DistanceTo(EntityStartPoint(entity)) > Tolerance.Epsilon) + { + current = new Shape(); + cuts.Add(current); + } + current.Entities.Add(entity); + } + entities.RemoveAll(e => e.Layer == SpecialLayers.Break); + foreach (var cut in cuts) + cut.UpdateBounds(); + return cuts; + } + + // A clean program's break cut travels with its scrap on the left. It is entered from the + // scrap, with the internal lead-in (holes are pierced in scrap the same way), at whichever + // end has more scrap before the part's contours: near the outline the scrap narrows to a + // wedge. NoLeadIn pierces on the line. There is no lead-out: the cut ends at a tab, which a + // run-out would cut into. Entering at the far end cuts it the other way round. + private void EmitBreakCuts(Program program, IReadOnlyList cuts, IReadOnlyList contours) + { + if (cuts == null) + return; + var leadIn = Parameters?.InternalLeadIn ?? new NoLeadIn(); + foreach (var cut in cuts) + { + var (entry, scrap, fromEnd) = BreakCutEntry(cut, contours); + var path = cut; + if (fromEnd) + { + path = (Shape)cut.Clone(); + path.Reverse(); + } + var normal = Angle.NormalizeRad(System.Math.Atan2(scrap.Y, scrap.X)); + program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW)); + program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break)); + } + } + + /// + /// Where a break cut (travelling with its scrap on the left) is entered: the end with more + /// scrap before , the unit direction into the scrap there, and + /// whether that is the cut's far end. + /// + public static (Vector Entry, Vector ScrapDirection, bool FromEnd) BreakCutEntry(Shape cut, + IReadOnlyList contours) + { + var start = EntityStartPoint(cut.Entities[0]); + var end = EntityEndPoint(cut.Entities[^1]); + var startScrap = LeftOf(Tangent(cut.Entities[0], atEnd: false)); + var endScrap = LeftOf(Tangent(cut.Entities[^1], atEnd: true)); + return Room(end, endScrap, contours) > Room(start, startScrap, contours) + ? (end, endScrap, true) + : (start, startScrap, false); + } + + private static List BreakCutContours(ShapeProfile profile) => + new List(profile.Cutouts) { profile.Perimeter }; + + // Distance from a break cut end along its scrap direction to the nearest contour. + private static double Room(Vector point, Vector direction, IReadOnlyList contours) + { + const double reach = 1e6; + var ray = new Line(point, point + direction * reach); + var nearest = reach; + foreach (var contour in contours ?? Array.Empty()) + foreach (var entity in contour.Entities) + if (entity.Intersects(ray, out var hits)) + foreach (var hit in hits) + nearest = System.Math.Min(nearest, hit.DistanceTo(point)); + return nearest; + } + + private static Vector LeftOf(Vector tangent) + { + var length = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y); + return length <= 0 ? new Vector(0, 1) : new Vector(-tangent.Y / length, tangent.X / length); + } + + private static Vector Tangent(Entity entity, bool atEnd) + { + if (entity is Arc arc) + { + var angle = atEnd ? arc.EndAngle : arc.StartAngle; + return arc.IsReversed + ? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle)) + : new Vector(-System.Math.Sin(angle), System.Math.Cos(angle)); + } + var line = (Line)entity; + return line.EndPoint - line.StartPoint; + } + private static Vector GetShapeStartPoint(Shape shape) { var first = shape.Entities[0]; diff --git a/OpenNest.Core/CNC/LayerType.cs b/OpenNest.Core/CNC/LayerType.cs index 7d4cb77d..db10b6d7 100644 --- a/OpenNest.Core/CNC/LayerType.cs +++ b/OpenNest.Core/CNC/LayerType.cs @@ -1,4 +1,4 @@ -namespace OpenNest.CNC +namespace OpenNest.CNC { public enum LayerType { @@ -7,5 +7,12 @@ Cut, Leadin, Leadout, + + /// + /// An intentionally open cut through part material, left uncut at tabs (a breakoff's + /// break line). It cuts like but is not a material boundary; the scrap + /// it may be pierced from lies to the left of travel. + /// + Break, } } diff --git a/OpenNest.Core/Converters/ConvertGeometry.cs b/OpenNest.Core/Converters/ConvertGeometry.cs index c740fe0e..c0207573 100644 --- a/OpenNest.Core/Converters/ConvertGeometry.cs +++ b/OpenNest.Core/Converters/ConvertGeometry.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using OpenNest.CNC; using OpenNest.Geometry; using OpenNest.Math; @@ -9,7 +9,24 @@ namespace OpenNest.Converters { public static Program ToProgram(IList geometry) { - var shapes = ShapeBuilder.GetShapes(geometry); + // Break cuts keep their order and direction (their scrap side): they are emitted as + // given, one program run per continuous chain, before the contours. + var breakCuts = new List(); + var contours = new List(); + foreach (var entity in geometry) + { + if (entity.Layer != SpecialLayers.Break) + { + contours.Add(entity); + continue; + } + var last = breakCuts.Count == 0 ? null : breakCuts[^1].Entities[^1]; + if (last == null || EndOf(last).DistanceTo(StartOf(entity)) > Tolerance.Epsilon) + breakCuts.Add(new Shape()); + breakCuts[^1].Entities.Add(entity); + } + + var shapes = ShapeBuilder.GetShapes(contours); if (shapes.Count == 0) return null; @@ -34,6 +51,9 @@ namespace OpenNest.Converters var pgm = new Program(); + foreach (var cut in breakCuts) + pgm.Merge(ToProgram(cut)); + foreach (var shape in shapes) { var subpgm = ToProgram(shape); @@ -46,6 +66,10 @@ namespace OpenNest.Converters return pgm; } + private static Vector StartOf(Entity entity) => entity is Arc arc ? arc.StartPoint() : ((Line)entity).StartPoint; + + private static Vector EndOf(Entity entity) => entity is Arc arc ? arc.EndPoint() : ((Line)entity).EndPoint; + public static Program ToProgram(Shape shape) { var pgm = new Program(); @@ -153,6 +177,9 @@ namespace OpenNest.Converters ) return LayerType.Scribe; + if (string.Equals(name, SpecialLayers.Break.Name, System.StringComparison.OrdinalIgnoreCase)) + return LayerType.Break; + return LayerType.Cut; } } diff --git a/OpenNest.Core/Converters/ConvertProgram.cs b/OpenNest.Core/Converters/ConvertProgram.cs index e59dde17..445d6224 100644 --- a/OpenNest.Core/Converters/ConvertProgram.cs +++ b/OpenNest.Core/Converters/ConvertProgram.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using OpenNest.CNC; using OpenNest.Geometry; using OpenNest.Math; @@ -207,6 +207,9 @@ namespace OpenNest.Converters case LayerType.Scribe: return SpecialLayers.Scribe; + case LayerType.Break: + return SpecialLayers.Break; + default: return new Layer(layer.ToString()); } diff --git a/OpenNest.Core/Geometry/ShapeProfile.cs b/OpenNest.Core/Geometry/ShapeProfile.cs index 370e1499..a6ced1fe 100644 --- a/OpenNest.Core/Geometry/ShapeProfile.cs +++ b/OpenNest.Core/Geometry/ShapeProfile.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; namespace OpenNest.Geometry @@ -76,8 +76,15 @@ namespace OpenNest.Geometry public static List NormalizeEntities(IEnumerable entities) { var cloned = entities.CloneAll(); + // Break cuts are open and their direction says which side is scrap: never re-chain or + // re-wind them. They follow the contours, in their original order. + var breakCuts = cloned.Where(e => e.Layer == SpecialLayers.Break).ToList(); + if (breakCuts.Count > 0) + cloned.RemoveAll(e => e.Layer == SpecialLayers.Break); var profile = new ShapeProfile(cloned); - return profile.ToNormalizedEntities(); + var result = profile.ToNormalizedEntities(); + result.AddRange(breakCuts); + return result; } private static void EnsureWinding(Shape shape, RotationType desired) diff --git a/OpenNest.Core/SpecialLayers.cs b/OpenNest.Core/SpecialLayers.cs index b1cbfffe..0ed03a80 100644 --- a/OpenNest.Core/SpecialLayers.cs +++ b/OpenNest.Core/SpecialLayers.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.Drawing; using OpenNest.Geometry; namespace OpenNest @@ -19,11 +19,14 @@ namespace OpenNest public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta }; + public static readonly Layer Break = new Layer("BREAK") { Color = Color.Orange }; + /// /// True when an entity converted from a part program describes part material. Rapids /// and scribe/etch marks are excluded: marks are only on the surface, so they never - /// bound material and must not affect nesting, collision, area, or validation. + /// bound material and must not affect nesting, collision, area, or validation. Break + /// cuts are excluded too: they run through material that stays attached at tabs. /// - public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe; + public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break; } } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 16051954..ddf8c6ad 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -166,6 +166,7 @@ public sealed class FixedProgramPlacement if (Prepared == null || !choices.Any(c => Prepared.IsPinned(c.ContourOrdinal))) return null; var pins = new int?[execution.Motions.Count]; + var breakCuts = BreakCutMotions.Mask(execution.Motions); var group = -1; var opening = true; // Inside the rapids that open a group. int? pin = null; @@ -178,6 +179,14 @@ public sealed class FixedProgramPlacement opening = true; continue; } + // Break cuts are emitted after the scribes and before any contour. + if (breakCuts[index]) + { + if (group >= 0) + return null; + opening = true; + continue; + } if (opening) { opening = false; diff --git a/OpenNest.IO.Tests/BreakoffPersistenceTests.cs b/OpenNest.IO.Tests/BreakoffPersistenceTests.cs index e594dc1f..0d20515c 100644 --- a/OpenNest.IO.Tests/BreakoffPersistenceTests.cs +++ b/OpenNest.IO.Tests/BreakoffPersistenceTests.cs @@ -14,7 +14,7 @@ public class BreakoffPersistenceTests { EdgeIds = [Guid.NewGuid(), Guid.NewGuid()], TabWidth = 0.05, - SlotWidth = 0.3, + Flip = true, TabCenters = [0.025, 1.5, 3.2], }); return drawing; @@ -31,10 +31,26 @@ public class BreakoffPersistenceTests Assert.Empty(warnings); var breakoff = Assert.Single(Assert.Single(restored.Drawings).Breakoffs); Assert.Equal(original.EdgeIds, breakoff.EdgeIds); - Assert.Equal((0.05, 0.3), (breakoff.TabWidth, breakoff.SlotWidth)); + Assert.Equal((0.05, true), (breakoff.TabWidth, breakoff.Flip)); Assert.Equal(original.TabCenters, breakoff.TabCenters); } + [Fact] + public void BreakCutMoves_KeepTheirLayer() + { + var drawing = DrawingPackageFixture.Build(); + drawing.Program.MoveTo(4, 5); + drawing.Program.Codes.Add(new OpenNest.CNC.LinearMove(1, 0) { Layer = OpenNest.CNC.LayerType.Break }); + drawing.Program.Codes.Add(new OpenNest.CNC.ArcMove(1, 1, 0, 1) { Layer = OpenNest.CNC.LayerType.Break }); + + var (restored, _) = RoundTrip(Write(drawing)); + + var moves = Assert.Single(restored.Drawings).Program.Codes.OfType() + .Where(m => m is OpenNest.CNC.LinearMove { Layer: OpenNest.CNC.LayerType.Break } + or OpenNest.CNC.ArcMove { Layer: OpenNest.CNC.LayerType.Break }); + Assert.Equal(2, moves.Count()); + } + [Fact] public void NoBreakoffs_AreOmittedFromTheFile() { diff --git a/OpenNest.IO/EntitySerializer.cs b/OpenNest.IO/EntitySerializer.cs index d9570c73..db1f42a7 100644 --- a/OpenNest.IO/EntitySerializer.cs +++ b/OpenNest.IO/EntitySerializer.cs @@ -135,6 +135,8 @@ namespace OpenNest.IO return SpecialLayers.Leadout; if (string.Equals(name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase)) return SpecialLayers.Scribe; + if (string.Equals(name, SpecialLayers.Break.Name, StringComparison.OrdinalIgnoreCase)) + return SpecialLayers.Break; return new Layer(name); } diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index dfc7df53..00f22ee3 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -214,8 +214,11 @@ namespace OpenNest.IO public record BreakoffDto { public List EdgeIds { get; init; } = new(); + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool Flip { get; init; } + public double TabWidth { get; init; } - public double SlotWidth { get; init; } public List TabCenters { get; init; } = new(); } diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 9bf11694..a6e62c8d 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -340,14 +340,13 @@ namespace OpenNest.IO var dto = d.Breakoffs[i]; if (dto?.EdgeIds is { Count: > 0 } && dto.TabCenters != null && dto.TabWidth > 0 && double.IsFinite(dto.TabWidth) - && dto.SlotWidth > 0 && double.IsFinite(dto.SlotWidth) && dto.TabCenters.All(double.IsFinite)) { breakoffs.Add(new Breakoff { EdgeIds = dto.EdgeIds.ToList(), + Flip = dto.Flip, TabWidth = dto.TabWidth, - SlotWidth = dto.SlotWidth, TabCenters = dto.TabCenters.ToList(), }); } diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index db488443..669057e3 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -204,8 +204,8 @@ namespace OpenNest.IO ? d.Breakoffs.Select(b => new BreakoffDto { EdgeIds = b.EdgeIds.ToList(), + Flip = b.Flip, TabWidth = b.TabWidth, - SlotWidth = b.SlotWidth, TabCenters = b.TabCenters.ToList(), }).ToList() : null, @@ -674,6 +674,9 @@ namespace OpenNest.IO case LayerType.Scribe: return ":SCRIBE"; + case LayerType.Break: + return ":BREAK"; + default: return string.Empty; } diff --git a/OpenNest.IO/ProgramReader.cs b/OpenNest.IO/ProgramReader.cs index 22a63223..ddae48c2 100644 --- a/OpenNest.IO/ProgramReader.cs +++ b/OpenNest.IO/ProgramReader.cs @@ -280,6 +280,10 @@ namespace OpenNest.IO layer = LayerType.Scribe; break; + case "BREAK": + layer = LayerType.Break; + break; + case "SUPPRESSED": suppressed = true; break; @@ -378,6 +382,10 @@ namespace OpenNest.IO layer = LayerType.Scribe; break; + case "BREAK": + layer = LayerType.Break; + break; + case "SUPPRESSED": suppressed = true; break; diff --git a/OpenNest.Tests/Breakoffs/BreakoffBuilderTests.cs b/OpenNest.Tests/Breakoffs/BreakoffBuilderTests.cs index 37478b37..69604eea 100644 --- a/OpenNest.Tests/Breakoffs/BreakoffBuilderTests.cs +++ b/OpenNest.Tests/Breakoffs/BreakoffBuilderTests.cs @@ -3,6 +3,7 @@ using OpenNest.Breakoffs; using OpenNest.CNC; using OpenNest.CNC.CuttingPlanning; using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; using OpenNest.Engine.CuttingPlanning; using OpenNest.Geometry; using OpenNest.IO; @@ -10,235 +11,325 @@ using OpenNest.IO; namespace OpenNest.Tests.Breakoffs; /// -/// A 10 x 6 part with a triangular breakoff (10,1) -> (12,3) -> (10,5) on its right edge; the -/// right edge is one line, so the breakoff's ends split it mid-entity. +/// A 10 x 6 part with a triangular breakoff (10,1) -> (12,3) -> (10,5) on its right edge, joined +/// along x = 10 from y = 1 to y = 5. The triangle (area 4) is the smaller side, so it is the scrap. /// public class BreakoffBuilderTests { private const double W = Breakoff.DefaultTabWidth; - private static List Part(bool leftBreakoff = false) + // The outline drawn around part and scrap, with a separate joining line across it. + private static (List Entities, Line Join) Chord() { + var join = new Line(10, 1, 10, 5); var entities = new List { - new Line(0, 0, 10, 0), new Line(10, 0, 10, 6), new Line(10, 6, 0, 6), new Line(0, 6, 0, 0), - new Line(10, 1, 12, 3), new Line(12, 3, 10, 5), + new Line(0, 0, 10, 0), new Line(10, 0, 10, 1), new Line(10, 1, 12, 3), new Line(12, 3, 10, 5), + new Line(10, 5, 10, 6), new Line(10, 6, 0, 6), new Line(0, 6, 0, 0), join, }; - if (leftBreakoff) - entities.AddRange(new[] { new Line(0, 1, -2, 3), new Line(-2, 3, 0, 5) }); - return entities; + return (entities, join); } - private static Breakoff On(params Entity[] edges) => new() { EdgeIds = edges.Select(e => e.Id).ToList() }; + // The part outline with the scrap's outside edges drawn separately (as in imported nests). + private static (List Entities, Line Join) PartAndScrapEdges() + { + var join = new Line(10, 1, 10, 5); + var entities = new List + { + new Line(0, 0, 10, 0), new Line(10, 0, 10, 1), join, new Line(10, 5, 10, 6), new Line(10, 6, 0, 6), + new Line(0, 6, 0, 0), new Line(10, 1, 12, 3), new Line(12, 3, 10, 5), + }; + return (entities, join); + } - private static Breakoff Right(List part) => On(part[4], part[5]); + private static Breakoff On(params Entity[] join) => new() { EdgeIds = join.Select(e => e.Id).ToList() }; [Fact] - public void Layout_MergesTheBreakoffIntoTheOutlineAndSlotsTheBreakLine() + public void Layout_CutsTheJoiningLineBetweenTabs_TravellingWithTheScrapOnTheLeft() { - var part = Part(); + var (entities, join) = Chord(); - var layout = BreakoffBuilder.Layout(part, Right(part)); + var layout = BreakoffBuilder.Layout(entities, On(join)); Assert.True(layout.IsValid, layout.Error); Assert.Equal(4, layout.Length, 9); - Assert.Equal(new Vector(10, 1), layout.PointAt(0)); - Assert.Equal(new Vector(10, 5), layout.PointAt(4)); Assert.Equal(64, new Shape { Entities = layout.Outline.ToList() }.Area(), 6); - Assert.Equal(0, MaxGap(layout.Outline), 12); - Assert.Equal(BreakoffBuilder.EvenTabCenters(4, Breakoff.DefaultTabCount, W), layout.TabCenters); - Assert.Equal(Breakoff.DefaultTabCount - 1, layout.Slots.Count); - Assert.All(layout.Slots, slot => Assert.Equal(0, MaxGap(slot), 12)); - } - - [Fact] - public void Slots_CutTheBreakLineOnlyBetweenTabs_AndStayInsideTheBreakoff() - { - var part = Part(); - - var layout = BreakoffBuilder.Layout(part, Right(part)); - - var cut = layout.Slots.Select(slot => OnBreakLine(slot)).ToList(); - // Interior tabs keep exactly their width; the end slots also move clear of the breakoff's - // edges, so the outer tabs are at least as wide. - for (var i = 1; i < cut.Count; i++) - Assert.Equal(W, cut[i].From - cut[i - 1].To, 9); - Assert.True(cut[0].From >= 1 + W - 1e-9); - Assert.True(cut[^1].To <= 5 - W + 1e-9); - - foreach (var point in layout.Slots.SelectMany(s => s).SelectMany(Ends)) + Assert.Equal(4, new Shape { Entities = layout.Scrap.ToList() }.Area(), 6); + Assert.Equal(Breakoff.DefaultTabCount - 1, layout.Cuts.Count); + var spans = layout.Cuts.Select(Span).OrderBy(s => s.Low).ToList(); + for (var i = 1; i < spans.Count; i++) + Assert.Equal(W, spans[i].Low - spans[i - 1].High, 9); + Assert.Equal(1 + W, spans[0].Low, 9); + Assert.Equal(5 - W, spans[^1].High, 9); + foreach (var cut in layout.Cuts) { - Assert.True(point.X >= 10 - 1e-9, $"{point} is on the part side"); - Assert.True(System.Math.Abs(point.Y - 3) <= 2 - (point.X - 10) + 1e-9, $"{point} is outside the breakoff"); + Assert.All(cut, e => Assert.Same(SpecialLayers.Break, e.Layer)); + // Heading down x = 10, the scrap (x > 10) is on the left. + Assert.True(Start(cut[0]).Y > End(cut[^1]).Y); } - // The full slot depth is reached away from the breakoff's edges. - Assert.Contains(layout.Slots.SelectMany(s => s).SelectMany(Ends), - p => System.Math.Abs(p.X - (10 + Breakoff.DefaultSlotWidth)) < 1e-9); } [Fact] - public void BuildDrawing_PlansReady_WithEveryPierceInsideTheBreakoff() + public void Flip_MakesTheLargerSideTheScrap() { - var part = Part(); - var drawing = Build(part, Right(part)); - var placed = new Part(drawing, new Vector(5, 5)); - var plate = new Nest().CreatePlate(); - plate.Size = new Size(60, 120); - plate.Parts.Add(placed); + var (entities, join) = Chord(); + var breakoff = On(join); + breakoff.Flip = true; - var proposal = CuttingPlanBatch.Capture([plate], new CuttingParameters(), false).Plan(); + var layout = BreakoffBuilder.Layout(entities, breakoff); - var planned = Assert.Single(proposal.Plates); - Assert.True(planned.IsReady, string.Join("\n", proposal.Describe("in"))); - var motions = ExecutionMotionReader.Read(Assert.Single(planned.Result.ProposedOrder).CopyProgram(), - placed.Location, null, default).Motions; - var leads = motions.Where(m => m.Layer == LayerType.Leadin).ToList(); + Assert.True(layout.IsValid, layout.Error); + Assert.Equal(60, new Shape { Entities = layout.Scrap.ToList() }.Area(), 6); + Assert.All(layout.Cuts, cut => Assert.True(Start(cut[0]).Y < End(cut[^1]).Y)); + } + + [Fact] + public void ScrapEdgesDrawnApart_AreJoinedIntoTheOutline() + { + var (entities, join) = PartAndScrapEdges(); + + var layout = BreakoffBuilder.Layout(entities, On(join)); + + Assert.True(layout.IsValid, layout.Error); + Assert.Equal(64, new Shape { Entities = layout.Outline.ToList() }.Area(), 6); + Assert.Equal(4, new Shape { Entities = layout.Scrap.ToList() }.Area(), 6); + } + + [Fact] + public void OutlineJointsWithinImportTolerance_AreMadeExact() + { + var (entities, join) = PartAndScrapEdges(); + // The scrap edge stops 0.00005 short of the outline corner, as in the reported nest. + entities[6] = new Line(10.00005, 1, 12, 3); + + var drawing = Build(entities, On(join)); + + Assert.Single(PreparedContours.Capture(drawing.Program, new CuttingParameters()).Enumerate()); + } + + [Fact] + public void StrayLinesEndingOnTheOutline_DoNotBreakIt() + { + var (entities, join) = PartAndScrapEdges(); + // Unmarked one-inch bend marks: one starts where the joining line meets the outline, + // one is a two-segment chain starting on an outline corner. + entities.Add(new Line(10, 1, 9.3, 1.7)); + entities.Add(new Line(10, 6, 9.5, 5.5)); + entities.Add(new Line(9.5, 5.5, 9.3, 5.2)); + + var applied = BreakoffBuilder.Apply(entities, [On(join)], out var errors); + + Assert.Empty(errors); + Assert.Equal(Breakoff.DefaultTabCount - 1, applied.Count(e => e.Layer == SpecialLayers.Break)); + Assert.Equal(64, new Shape { Entities = BreakoffBuilder.Layout(entities, On(join)).Outline.ToList() }.Area(), 6); + } + + [Fact] + public void BuildDrawing_CutsBreakMovesAndKeepsTheScrapAsMaterial() + { + var (entities, join) = Chord(); + + var drawing = Build(entities, On(join)); + + var prepared = PreparedContours.Capture(drawing.Program, new CuttingParameters()); + Assert.Equal(1, prepared.Count); + Assert.Equal(Breakoff.DefaultTabCount - 1, prepared.BreakCutCount); + Assert.Equal(64, drawing.Area, 6); + Assert.Equal(4 - Breakoff.DefaultTabCount * W, + Moves(drawing.Program).Where(m => m.Layer == LayerType.Break).Sum(m => m.Length), 9); + Assert.Single(drawing.Breakoffs); + } + + [Fact] + public void PlanCutting_LeadsEveryBreakCutInFromTheScrap() + { + var (entities, join) = Chord(); + var drawing = Build(entities, On(join)); + var (planned, part) = Plan(drawing, new CuttingParameters()); + + Assert.True(planned.IsReady, planned.Describe); + var motions = Moves(planned.Program, part.Location); + var leads = motions.Where((m, i) => m.Layer == LayerType.Leadin + && i + 1 < motions.Count && motions[i + 1].Layer == LayerType.Break).ToList(); Assert.Equal(Breakoff.DefaultTabCount - 1, leads.Count); foreach (var lead in leads) { - var pierce = lead.Start!.Value - placed.Location + drawing.Source.Offset; - Assert.True(pierce.X > 10 && System.Math.Abs(pierce.Y - 3) < 2 - (pierce.X - 10), - $"pierce {pierce} is not in the breakoff"); + var pierce = lead.Start!.Value - part.Location + drawing.Source.Offset; + Assert.True(pierce.X > 10 + 1e-6 && System.Math.Abs(pierce.Y - 3) < 2 - (pierce.X - 10), + $"pierce {pierce} is not in the scrap"); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void MultiSegmentJoiningLine_KeepsItsScrapSideThroughTheProgram(bool flip) + { + // A bent joining line: winding normalization would re-orient a chain like this. + var joins = new[] { new Line(10, 1, 10.1, 2), new Line(10.1, 2, 10.1, 4), new Line(10.1, 4, 10, 5) }; + var entities = new List + { + new Line(0, 0, 10, 0), new Line(10, 0, 10, 1), new Line(10, 1, 12, 3), new Line(12, 3, 10, 5), + new Line(10, 5, 10, 6), new Line(10, 6, 0, 6), new Line(0, 6, 0, 0), + }; + entities.AddRange(joins); + var breakoff = On(joins); + breakoff.Flip = flip; + var drawing = Build(entities, breakoff); + + var (planned, part) = Plan(drawing, new CuttingParameters()); + + Assert.True(planned.IsReady, planned.Describe); + var motions = Moves(planned.Program, part.Location); + var leads = motions.Where((m, i) => m.Layer == LayerType.Leadin + && i + 1 < motions.Count && motions[i + 1].Layer == LayerType.Break).ToList(); + Assert.Equal(Breakoff.DefaultTabCount - 1, leads.Count); + foreach (var lead in leads) + { + var pierce = lead.Start!.Value - part.Location + drawing.Source.Offset; + var entry = lead.End - part.Location + drawing.Source.Offset; + // The triangle is the scrap unless flipped. + Assert.True(flip ? pierce.X < entry.X : pierce.X > entry.X, $"pierce {pierce} entry {entry}"); } } [Fact] - public void BuildDrawing_KeepsACopyOfTheBreakoffAndCountsItsMaterial() + public void PlanCutting_WithoutLeadIns_PiercesOnTheLine() { - var part = Part(); - var breakoff = Right(part); + var (entities, join) = Chord(); + var drawing = Build(entities, On(join)); + var parameters = new CuttingParameters { InternalLeadIn = new NoLeadIn() }; - var drawing = Build(part, breakoff); + var (planned, part) = Plan(drawing, parameters); - var kept = Assert.Single(drawing.Breakoffs); - Assert.NotSame(breakoff, kept); - Assert.Equal(breakoff.EdgeIds, kept.EdgeIds); - Assert.InRange(drawing.Area, 62, 64); - Assert.Equal(Breakoff.DefaultTabCount, PreparedContours.Capture(drawing.Program, new CuttingParameters()).Count); + Assert.True(planned.IsReady, planned.Describe); + var motions = Moves(planned.Program, part.Location); + Assert.DoesNotContain(motions, m => m.Layer == LayerType.Leadin); + var breakStarts = motions.Where((m, i) => m.Layer == LayerType.Break && motions[i - 1].Rapid) + .Select(m => m.Start!.Value - part.Location + drawing.Source.Offset).ToList(); + Assert.Equal(Breakoff.DefaultTabCount - 1, breakStarts.Count); + Assert.All(breakStarts, p => Assert.Equal(10, p.X, 9)); } [Fact] - public void ArcBreakoff_IsMergedAndSlotted() + public void AssignLeadIns_LeadsBreakCutsInFromTheScrap() { - var part = Part().Take(4).ToList(); - var arc = new Arc(new Vector(10, 3), 2, -System.Math.PI / 2, System.Math.PI / 2); - part.Add(arc); + var (entities, join) = Chord(); + var drawing = Build(entities, On(join)); + var part = new Part(drawing, new Vector(5, 5)); - var layout = BreakoffBuilder.Layout(part, On(arc)); + part.ApplyLeadIns(new CuttingParameters(), new Vector(0, 0)); - Assert.True(layout.IsValid, layout.Error); - Assert.Equal(60 + System.Math.PI * 2, new Shape { Entities = layout.Outline.ToList() }.Area(), 3); - var drawing = Build(part, On(arc)); - Assert.Equal(Breakoff.DefaultTabCount, PreparedContours.Capture(drawing.Program, new CuttingParameters()).Count); + var motions = Moves(part.Program, part.Location); + var leads = motions.Where((m, i) => m.Layer == LayerType.Leadin + && i + 1 < motions.Count && motions[i + 1].Layer == LayerType.Break).ToList(); + Assert.Equal(Breakoff.DefaultTabCount - 1, leads.Count); + Assert.All(leads, lead => Assert.True((lead.Start!.Value - part.Location + drawing.Source.Offset).X > 10)); } [Fact] - public void BreakoffEndingExactlyAtAnArcStart_SplitsTheOutlineThere() + public void PrePostReview_DoesNotReportBreakCutsAsLooseFragments() { - // A radius-5 disc of two half arcs; the breakoff ends at (5, 0), the first arc's start, - // and at 30 degrees on that arc. - var upper = new Arc(Vector.Zero, 5, 0, System.Math.PI); - var lower = new Arc(Vector.Zero, 5, System.Math.PI, 0); - var at30 = new Vector(5 * System.Math.Cos(System.Math.PI / 6), 5 * System.Math.Sin(System.Math.PI / 6)); - var a = new Line(new Vector(5, 0), new Vector(7, 1.5)); - var b = new Line(new Vector(7, 1.5), at30); - var part = new List { upper, lower, a, b }; + var (entities, join) = Chord(); + var drawing = Build(entities, On(join)); + var (planned, part) = Plan(drawing, new CuttingParameters()); + Assert.True(planned.IsReady, planned.Describe); + Assert.True(part.RestoreLeadInProgram(planned.Program, false)); - var layout = BreakoffBuilder.Layout(part, On(a, b)); + var findings = new ReleasedContourState().Check( + ExecutionMotionReader.Read(part.Program, part.Location, null, default), Vector.Zero, 1); - Assert.True(layout.IsValid, layout.Error); - Assert.Equal(5 * System.Math.PI / 6, layout.Length, 6); - Assert.Equal(at30.X, layout.PointAt(0).X, 9); - Assert.Equal(0, layout.PointAt(layout.Length).Y, 9); + Assert.DoesNotContain(findings, f => f.Kind == PostVerificationKind.Incomplete); + Assert.DoesNotContain(findings, f => f.Message.Contains("break cut")); + Assert.DoesNotContain(findings, f => f.Kind == PostVerificationKind.RapidCrossing); } [Fact] public void TwoBreakoffsOnOneOutline_BothApply() { - var part = Part(leftBreakoff: true); - var breakoffs = new List { Right(part), On(part[6], part[7]) }; + var (entities, join) = Chord(); + var left = new Line(0, 1, 0, 5); + entities[6] = new Line(0, 6, 0, 5); + entities.AddRange([new Line(0, 5, -2, 3), new Line(-2, 3, 0, 1), new Line(0, 1, 0, 0), left]); - var applied = BreakoffBuilder.Apply(part, breakoffs, out var errors); + var applied = BreakoffBuilder.Apply(entities, [On(join), On(left)], out var errors); Assert.Empty(errors); - var profile = new ShapeProfile(ShapeProfile.NormalizeEntities(applied)); - Assert.Equal(68, profile.Perimeter.Area(), 6); - Assert.Equal(2 * (Breakoff.DefaultTabCount - 1), profile.Cutouts.Count); - Assert.Equal(2 * Breakoff.DefaultTabCount - 1, - PreparedContours.Capture(Build(part, breakoffs.ToArray()).Program, new CuttingParameters()).Count); + Assert.Equal(2 * (Breakoff.DefaultTabCount - 1), applied.Count(e => e.Layer == SpecialLayers.Break) + / 1); + var drawing = Build(entities, On(join), On(left)); + Assert.Equal(68, drawing.Area, 6); } [Fact] - public void Apply_LeavesItsInputsUntouched() + public void JoiningLineEndingAtAnArcStart_SplitsTheOutlineThere() { - var part = Part(); - var before = part.Select(e => (e.Id, Ends(e).ToArray())).ToList(); + // A radius-5 disc of two half arcs, cut by a chord from (5, 0) (the first arc's start) + // to the point at 120 degrees. + var upper = new Arc(Vector.Zero, 5, 0, System.Math.PI); + var lower = new Arc(Vector.Zero, 5, System.Math.PI, 0); + var at120 = new Vector(5 * System.Math.Cos(2 * System.Math.PI / 3), 5 * System.Math.Sin(2 * System.Math.PI / 3)); + var join = new Line(new Vector(5, 0), at120); + var entities = new List { upper, lower, join }; - BreakoffBuilder.Apply(part, [Right(part)], out _); + var layout = BreakoffBuilder.Layout(entities, On(join)); - Assert.Equal(before.Select(b => b.Id), part.Select(e => e.Id)); - Assert.Equal(before.Select(b => b.Item2), part.Select(e => Ends(e).ToArray())); + Assert.True(layout.IsValid, layout.Error); + Assert.Equal(new Vector(5, 0).DistanceTo(at120), layout.Length, 9); + var segment = 25 / 2.0 * (2 * System.Math.PI / 3 - System.Math.Sin(2 * System.Math.PI / 3)); + Assert.Equal(segment, new Shape { Entities = layout.Scrap.ToList() }.Area(), 2); } [Fact] - public void BreakoffInsideThePart_IsRefused() + public void JoiningLineOutsideTheOutline_IsRefused() { - var part = Part().Take(4).ToList(); - var a = new Line(10, 1, 8, 3); - var b = new Line(8, 3, 10, 5); - part.AddRange([a, b]); + var entities = new List + { + new Line(0, 0, 10, 0), new Line(10, 0, 10, 6), new Line(10, 6, 0, 6), new Line(0, 6, 0, 0), + }; + var outside = new[] { new Line(10, 1, 12, 3), new Line(12, 3, 10, 5) }; + entities.AddRange(outside); - Assert.Equal("It must sit outside the part outline.", BreakoffBuilder.Layout(part, On(a, b)).Error); + Assert.Equal("The joining line must run inside the outline.", + BreakoffBuilder.Layout(entities, On(outside)).Error); } [Fact] - public void HiddenEdge_IsReportedAndNotApplied() + public void HiddenJoiningLine_IsReportedAndNotApplied() { - var part = Part(); - var breakoff = Right(part); - part.RemoveAt(5); + var (entities, join) = Chord(); + var breakoff = On(join); + entities.Remove(join); - var applied = BreakoffBuilder.Apply(part, [breakoff], out var errors); + var applied = BreakoffBuilder.Apply(entities, [breakoff], out var errors); - Assert.Equal("Breakoff 1: Some of its edges are hidden or missing.", Assert.Single(errors)); - Assert.Equal(part, applied); + Assert.Equal("Breakoff 1: Its joining line is hidden or missing.", Assert.Single(errors)); + Assert.Equal(entities, applied); } [Fact] public void OverlappingTabs_AreRefused() { - var part = Part(); - var breakoff = Right(part); + var (entities, join) = Chord(); + var breakoff = On(join); breakoff.TabCenters = [W / 2, W, 4 - W / 2]; - Assert.Equal("Two tabs overlap; space the tabs evenly again.", BreakoffBuilder.Layout(part, breakoff).Error); + Assert.Equal("Two tabs overlap; space the tabs evenly again.", BreakoffBuilder.Layout(entities, breakoff).Error); } [Fact] - public void SlotsWithNoRoom_AreRefused() + public void FindEdgeChain_FromTheJoiningLine_StopsAtTheOutline() { - var part = Part(); - var breakoff = Right(part); - breakoff.SlotWidth = 10; + var (entities, join) = PartAndScrapEdges(); - Assert.StartsWith("There is no room for slot 1", BreakoffBuilder.Layout(part, breakoff).Error); - } - - [Fact] - public void FindEdgeChain_FromOneEdge_StopsWhereTheBreakoffMeetsTheOutline() - { - var part = Part(); - - var chain = BreakoffBuilder.FindEdgeChain(part, part[5]); - - Assert.Equal(new[] { part[4], part[5] }.ToHashSet(), chain.ToHashSet()); + Assert.Equal([join], BreakoffBuilder.FindEdgeChain(entities, join)); } [Fact] public void ClampTabCenter_KeepsTabsOnTheLineAndApart() { - var part = Part(); - var layout = BreakoffBuilder.Layout(part, Right(part)); + var (entities, join) = Chord(); + var layout = BreakoffBuilder.Layout(entities, On(join)); Assert.Equal(W / 2, layout.ClampTabCenter(0, -5), 9); Assert.Equal(layout.TabCenters[1] - W - W / 2, layout.ClampTabCenter(0, layout.TabCenters[1]), 9); @@ -249,26 +340,35 @@ public class BreakoffBuilderTests CadImporter.BuildDrawing(new CadImportResult { Entities = entities, Name = "breakoff" }, entities, new List(), 1, null, null, breakoffs); - private static (double From, double To) OnBreakLine(IReadOnlyList slot) + private static (PlannedPart Planned, Part Part) Plan(Drawing drawing, CuttingParameters parameters) { - var ys = slot.OfType() - .Where(l => System.Math.Abs(l.StartPoint.X - 10) < 1e-9 && System.Math.Abs(l.EndPoint.X - 10) < 1e-9) - .SelectMany(l => new[] { l.StartPoint.Y, l.EndPoint.Y }).ToList(); + var part = new Part(drawing, new Vector(5, 5)); + var plate = new Nest().CreatePlate(); + plate.Size = new Size(60, 120); + plate.Parts.Add(part); + var proposal = CuttingPlanBatch.Capture([plate], parameters, false).Plan(); + var result = Assert.Single(proposal.Plates); + return (new PlannedPart(result.IsReady, string.Join("\n", proposal.Describe("in")), + result.IsReady ? Assert.Single(result.Result.ProposedOrder).CopyProgram() : null), part); + } + + private sealed record PlannedPart(bool IsReady, string Describe, Program Program); + + private static IReadOnlyList Moves(Program program, Vector? location = null) => + ExecutionMotionReader.Read(program, location ?? Vector.Zero, null, default).Motions; + + private static (double Low, double High) Span(IReadOnlyList cut) + { + var ys = cut.SelectMany(e => new[] { Start(e).Y, End(e).Y }).ToList(); return (ys.Min(), ys.Max()); } - private static IEnumerable Ends(Entity e) => e switch - { - Line l => [l.StartPoint, l.EndPoint], - Arc a => [a.StartPoint(), a.EndPoint()], - _ => [], - }; + private static Vector Start(Entity e) => e is Line l ? l.StartPoint : ((Arc)e).StartPoint(); - private static double MaxGap(IReadOnlyList loop) - { - var max = 0.0; - for (var i = 0; i < loop.Count; i++) - max = System.Math.Max(max, Ends(loop[i]).Last().DistanceTo(Ends(loop[(i + 1) % loop.Count]).First())); - return max; - } + private static Vector End(Entity e) => e is Line l ? l.EndPoint : ((Arc)e).EndPoint(); +} + +internal static class PreparedContoursTestExtensions +{ + public static IEnumerable Enumerate(this PreparedContours prepared) => Enumerable.Range(0, prepared.Count); } diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs index 7017b3a5..8f809f28 100644 --- a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs +++ b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs @@ -326,10 +326,11 @@ public class ContourEntryCandidateTests // build it directly to characterize its legal, sub-tolerance joint discrepancy. var constructor = typeof(PreparedContours).GetConstructor( System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, - null, [typeof(Shape[]), typeof(List), typeof(OpenNest.CNC.CuttingStrategy.CuttingParameters)], null); + null, [typeof(Shape[]), typeof(List), typeof(List), + typeof(OpenNest.CNC.CuttingStrategy.CuttingParameters)], null); Assert.NotNull(constructor); var prepared = (PreparedContours)constructor.Invoke([ - new[] { shape }, new List(), ExplicitContourTests.Parameters()]); + new[] { shape }, new List(), new List(), ExplicitContourTests.Parameters()]); var near = prepared.AutomaticEntryCandidatesWithFallbacks(0) .Where(c => c.Kind == AutomaticEntryKind.NearCorner).ToList(); Assert.NotEmpty(near); diff --git a/OpenNest.WinForms.Tests/Forms/CadConverterBreakoffTests.cs b/OpenNest.WinForms.Tests/Forms/CadConverterBreakoffTests.cs index 41e8f96f..b0f13ffc 100644 --- a/OpenNest.WinForms.Tests/Forms/CadConverterBreakoffTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CadConverterBreakoffTests.cs @@ -14,12 +14,12 @@ namespace OpenNest.WinForms.Tests.Forms; /// /// Drives the CAD Converter's breakoff tool through its real mouse handling: a 10 x 6 part with a -/// triangular breakoff (10,1) -> (12,3) -> (10,5) on its right edge. +/// triangular breakoff (10,1) -> (12,3) -> (10,5) joined along x = 10. /// public class CadConverterBreakoffTests { [Fact] - public void PickingAnEdge_AddsABreakoff_DraggingMovesATab_AndAcceptKeepsIt() => ArchUnitsTests.RunSta(() => + public void PickingTheJoiningLine_AddsABreakoff_FlipSwapsTheScrap_DraggingMovesATab() => ArchUnitsTests.RunSta(() => { using var form = new BreakoffTestForm(); Show(form); @@ -29,15 +29,23 @@ public class CadConverterBreakoffTests Invoke(form, "OnAddBreakoffClicked", null, EventArgs.Empty); Assert.True(view.IsPickingBreakoff); - Mouse(view, "OnMouseClick", view.PointWorldToControl(new Vector(11, 2))); + Mouse(view, "OnMouseClick", view.PointWorldToControl(new Vector(10, 3))); Assert.Empty(form.Messages); Assert.False(view.IsPickingBreakoff); var breakoff = Assert.Single(item.Breakoffs); - Assert.Equal(2, breakoff.EdgeIds.Count); + Assert.Single(breakoff.EdgeIds); Assert.Equal(4, breakoff.TabCenters.Count); var layout = Assert.Single(view.BreakoffLayouts); Assert.True(layout.IsValid, layout.Error); + Assert.Equal(4, new Shape { Entities = layout.Scrap.ToList() }.Area(), 6); + + Invoke(form, "OnBreakoffFlipped", null, 0); + Assert.True(item.Breakoffs[0].Flip); + Assert.Equal(60, new Shape { Entities = view.BreakoffLayouts[0].Scrap.ToList() }.Area(), 6); + Invoke(form, "OnBreakoffFlipped", null, 0); + breakoff = item.Breakoffs[0]; + layout = view.BreakoffLayouts[0]; var before = breakoff.TabCenters[1]; Mouse(view, "OnMouseDown", view.PointWorldToControl(layout.PointAt(before))); @@ -48,7 +56,9 @@ public class CadConverterBreakoffTests var drawing = Assert.Single(form.GetDrawings()); Assert.Equal(breakoff.TabCenters, Assert.Single(drawing.Breakoffs).TabCenters); - Assert.Equal(4, PreparedContours.Capture(drawing.Program, new CuttingParameters()).Count); + var prepared = PreparedContours.Capture(drawing.Program, new CuttingParameters()); + Assert.Equal(1, prepared.Count); + Assert.Equal(3, prepared.BreakCutCount); form.Close(); }); @@ -63,7 +73,7 @@ public class CadConverterBreakoffTests Invoke(form, "OnAddBreakoffClicked", null, EventArgs.Empty); Mouse(view, "OnMouseClick", view.PointWorldToControl(new Vector(5, 0))); - Assert.StartsWith("Could not make a breakoff from that edge", Assert.Single(form.Messages)); + Assert.StartsWith("Could not make a breakoff from that line", Assert.Single(form.Messages)); Assert.Empty(Field(form, "fileList").SelectedItem!.Breakoffs); form.Close(); }); @@ -72,8 +82,8 @@ public class CadConverterBreakoffTests public void ReopenedDrawing_ShowsItsBreakoff() => ArchUnitsTests.RunSta(() => { var source = Part(); - var edges = source.SourceEntities!.Where(e => e is Line l && l.StartPoint.X >= 10 && l.EndPoint.X >= 10 - && !(l.StartPoint.X == 10 && l.EndPoint.X == 10)).ToList(); + var edges = source.SourceEntities!.Where(e => e is Line { StartPoint.X: 10, EndPoint.X: 10 } l + && l.Length == 4).ToList(); var result = new CadImportResult { Entities = source.SourceEntities!, Name = "reopened" }; var withBreakoff = CadImporter.BuildDrawing(result, source.SourceEntities!, new List(), 1, null, null, [new Breakoff { EdgeIds = edges.Select(e => e.Id).ToList() }]); @@ -91,8 +101,8 @@ public class CadConverterBreakoffTests { var entities = new List { - new Line(0, 0, 10, 0), new Line(10, 0, 10, 6), new Line(10, 6, 0, 6), new Line(0, 6, 0, 0), - new Line(10, 1, 12, 3), new Line(12, 3, 10, 5), + new Line(0, 0, 10, 0), new Line(10, 0, 10, 1), new Line(10, 1, 10, 5), new Line(10, 5, 10, 6), + new Line(10, 6, 0, 6), new Line(0, 6, 0, 0), new Line(10, 1, 12, 3), new Line(12, 3, 10, 5), }; foreach (var e in entities) e.Layer = new Layer("0") { IsVisible = true }; diff --git a/OpenNest/Controls/EntityView.Breakoffs.cs b/OpenNest/Controls/EntityView.Breakoffs.cs index 62ec91d5..5778d871 100644 --- a/OpenNest/Controls/EntityView.Breakoffs.cs +++ b/OpenNest/Controls/EntityView.Breakoffs.cs @@ -39,7 +39,12 @@ namespace OpenNest.Controls public int SelectedBreakoffIndex { get; set; } = -1; - public event EventHandler BreakoffEdgePicked; + /// Where each break cut is entered and the direction into its scrap, in world coordinates. + public IReadOnlyList<(Vector Entry, Vector ScrapDirection)> BreakCutEntries { get; set; } = + Array.Empty<(Vector, Vector)>(); + + /// A line or arc was clicked while picking a joining line; the point is where. + public event EventHandler<(Entity Entity, Vector Point)> BreakoffEdgePicked; public event EventHandler TabDragged; public bool IsPickingBreakoff @@ -82,7 +87,7 @@ namespace OpenNest.Controls return false; var entity = HitTestEdge(e.Location); if (entity != null) - BreakoffEdgePicked?.Invoke(this, entity); + BreakoffEdgePicked?.Invoke(this, (entity, PointControlToWorld(e.Location))); return true; } @@ -139,9 +144,10 @@ namespace OpenNest.Controls if (BreakoffLayouts.Count == 0) return; - using var slotFill = new SolidBrush(Color.FromArgb(70, 0, 200, 255)); - using var slotPen = new Pen(Color.FromArgb(0, 200, 255), 1f); - using var selectedSlotPen = new Pen(Color.White, 1.5f); + using var scrapFill = new SolidBrush(Color.FromArgb(55, 255, 170, 0)); + using var selectedScrapFill = new SolidBrush(Color.FromArgb(95, 255, 170, 0)); + using var cutPen = new Pen(Color.FromArgb(0, 200, 255), 2.5f); + using var leadPen = new Pen(Color.FromArgb(0, 200, 255), 1.5f); using var tabPen = new Pen(Color.Red, 3f); using var handleBrush = new SolidBrush(Color.Red); using var handlePen = new Pen(Color.White, 1f); @@ -153,13 +159,15 @@ namespace OpenNest.Controls continue; var selected = b == SelectedBreakoffIndex; - foreach (var slot in layout.Slots) + var scrap = Outline(layout.Scrap); + if (scrap.Length >= 3) + g.FillPolygon(selected ? selectedScrapFill : scrapFill, scrap); + + foreach (var cut in layout.Cuts) { - var points = SlotOutline(slot); - if (points.Length < 3) - continue; - g.FillPolygon(slotFill, points); - g.DrawPolygon(selected ? selectedSlotPen : slotPen, points); + var points = Outline(cut); + if (points.Length >= 2) + g.DrawLines(cutPen, points); } foreach (var center in layout.TabCenters) @@ -174,12 +182,26 @@ namespace OpenNest.Controls g.DrawEllipse(handlePen, rect); } } + + DrawEntryMarks(g, leadPen); } - private PointF[] SlotOutline(IReadOnlyList slot) + // A short stroke from the pierce side into each cut's entry, a fixed size on screen. + private void DrawEntryMarks(Graphics g, Pen pen) + { + foreach (var (entry, scrap) in BreakCutEntries) + { + var at = PointWorldToGraph(entry); + var pierce = PointWorldToGraph(entry + scrap * LengthGuiToWorld(12)); + g.DrawLine(pen, pierce, at); + g.DrawEllipse(pen, pierce.X - 2.5f, pierce.Y - 2.5f, 5f, 5f); + } + } + + private PointF[] Outline(IReadOnlyList path) { var points = new List(); - foreach (var entity in slot) + foreach (var entity in path) { if (entity is Arc arc) points.AddRange(arc.ToPoints(System.Math.Max(2, arc.SegmentsForTolerance(LengthGuiToWorld(0.5f)))) diff --git a/OpenNest/Controls/FilterPanel.cs b/OpenNest/Controls/FilterPanel.cs index 4cddcbff..4f7e64b3 100644 --- a/OpenNest/Controls/FilterPanel.cs +++ b/OpenNest/Controls/FilterPanel.cs @@ -36,6 +36,7 @@ namespace OpenNest.Controls public event EventHandler BreakoffSelected; public event EventHandler BreakoffEdited; public event EventHandler BreakoffRemoved; + public event EventHandler BreakoffFlipped; public event EventHandler AddBreakoffClicked; public FilterPanel() @@ -76,6 +77,12 @@ namespace OpenNest.Controls if (breakoffsList.SelectedIndex >= 0) BreakoffEdited?.Invoke(this, breakoffsList.SelectedIndex); }; + var breakoffFlipLink = new LinkLabel { Text = "Flip", AutoSize = true, Font = new Font("Segoe UI", 8f) }; + breakoffFlipLink.LinkClicked += (s, e) => + { + if (breakoffsList.SelectedIndex >= 0) + BreakoffFlipped?.Invoke(this, breakoffsList.SelectedIndex); + }; var breakoffRemoveLink = new LinkLabel { Text = "Remove", AutoSize = true, Font = new Font("Segoe UI", 8f) }; breakoffRemoveLink.LinkClicked += (s, e) => { @@ -92,6 +99,7 @@ namespace OpenNest.Controls }; breakoffLinksPanel.Controls.Add(breakoffAddLink); breakoffLinksPanel.Controls.Add(breakoffEditLink); + breakoffLinksPanel.Controls.Add(breakoffFlipLink); breakoffLinksPanel.Controls.Add(breakoffRemoveLink); breakoffsPanel.ContentPanel.Controls.Add(breakoffsList); diff --git a/OpenNest/Forms/BreakoffDialog.cs b/OpenNest/Forms/BreakoffDialog.cs index 4e2f2fb0..47a3d2da 100644 --- a/OpenNest/Forms/BreakoffDialog.cs +++ b/OpenNest/Forms/BreakoffDialog.cs @@ -1,4 +1,3 @@ -using System; using System.Drawing; using System.Windows.Forms; using OpenNest.Breakoffs; @@ -9,7 +8,7 @@ namespace OpenNest.Forms { private readonly NumericUpDown numTabs; private readonly NumericUpDown numTabWidth; - private readonly NumericUpDown numSlotWidth; + private readonly CheckBox chkFlip; public BreakoffDialog() { @@ -25,12 +24,20 @@ namespace OpenNest.Forms numTabs = AddRow("Tabs:", 12, Breakoff.MinTabCount, 50, 0, 1, Breakoff.DefaultTabCount); numTabWidth = AddRow("Tab width:", 44, 0.001m, 10, 4, 0.0625m, (decimal)Breakoff.DefaultTabWidth); - numSlotWidth = AddRow("Slot width:", 76, 0.001m, 10, 4, 0.0625m, (decimal)Breakoff.DefaultSlotWidth); + chkFlip = new CheckBox + { + Text = "Larger side is scrap", + Location = new Point(12, 78), + AutoSize = true, + Font = font, + }; + Controls.Add(chkFlip); Controls.Add(new Label { - Text = "Tabs at both ends are included. Slots are cut into the breakoff between tabs.", - Location = new Point(12, 108), + Text = "Tabs at both ends are included. The joining line is cut between tabs, " + + "with lead-ins on the scrap side.", + Location = new Point(12, 106), Size = new Size(260, 34), ForeColor = Color.DimGray, Font = font, @@ -79,13 +86,13 @@ namespace OpenNest.Forms public double TabWidth => (double)numTabWidth.Value; - public double SlotWidth => (double)numSlotWidth.Value; + public bool Flip => chkFlip.Checked; public void LoadBreakoff(Breakoff breakoff) { numTabs.Value = Clamp(numTabs, breakoff.TabCenters.Count == 0 ? Breakoff.DefaultTabCount : breakoff.TabCenters.Count); numTabWidth.Value = Clamp(numTabWidth, breakoff.TabWidth); - numSlotWidth.Value = Clamp(numSlotWidth, breakoff.SlotWidth); + chkFlip.Checked = breakoff.Flip; } private static decimal Clamp(NumericUpDown input, double value) => diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs index 61d67b7f..2c3d8ef6 100644 --- a/OpenNest/Forms/CadConverterForm.cs +++ b/OpenNest/Forms/CadConverterForm.cs @@ -54,6 +54,7 @@ namespace OpenNest.Forms filterPanel.BreakoffSelected += OnBreakoffSelected; filterPanel.BreakoffEdited += OnBreakoffEdited; filterPanel.BreakoffRemoved += OnBreakoffRemoved; + filterPanel.BreakoffFlipped += OnBreakoffFlipped; entityView1.BreakoffEdgePicked += OnBreakoffEdgePicked; entityView1.TabDragged += OnTabDragged; entityView1.LinePicked += OnLinePicked; @@ -533,6 +534,7 @@ namespace OpenNest.Forms var allEdges = item.Breakoffs.SelectMany(b => b.EdgeIds).ToList(); var layouts = item.Breakoffs.Select(b => BreakoffBuilder.Layout(visible, b, allEdges)).ToList(); entityView1.BreakoffLayouts = layouts; + entityView1.BreakCutEntries = BreakCutEntries(visible, item.Breakoffs, layouts); filterPanel.LoadBreakoffs(layouts .Select((l, i) => l.IsValid ? $"Breakoff {i + 1}: {l.TabCenters.Count} tabs" @@ -541,6 +543,29 @@ namespace OpenNest.Forms entityView1.Invalidate(); } + // Entered where the cutting strategies will enter them: against the part's contours as + // they will be cut, with every breakoff applied. + private static List<(Vector, Vector)> BreakCutEntries(List visible, List breakoffs, + List layouts) + { + var entries = new List<(Vector, Vector)>(); + if (!layouts.Any(l => l.IsValid)) + return entries; + var contours = BreakoffBuilder.Apply(visible, breakoffs, out _) + .Where(en => en.Layer != SpecialLayers.Break && en is Geometry.Line or Arc or Circle + && !string.Equals(en.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase) + && !string.Equals(en.Layer?.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + var shapes = ShapeBuilder.GetShapes(contours); + foreach (var cut in layouts.Where(l => l.IsValid).SelectMany(l => l.Cuts)) + { + var (entry, scrap, _) = CNC.CuttingStrategy.ContourCuttingStrategy.BreakCutEntry( + new Shape { Entities = cut.ToList() }, shapes); + entries.Add((entry, scrap)); + } + return entries; + } + private void OnAddBreakoffClicked(object sender, EventArgs e) { entityView1.IsPickingBendLine = false; @@ -550,7 +575,7 @@ namespace OpenNest.Forms filterPanel.SetBreakoffPickMode(active); } - private void OnBreakoffEdgePicked(object sender, Entity entity) + private void OnBreakoffEdgePicked(object sender, (Entity Entity, Vector Point) pick) { entityView1.IsPickingBreakoff = false; filterPanel.SetBreakoffPickMode(false); @@ -558,12 +583,17 @@ namespace OpenNest.Forms if (item == null) return; + SplitAtJunctions(item); var visible = VisibleEntities(item); + var entity = visible.Where(en => en is Geometry.Line or Arc) + .OrderBy(en => en.ClosestPointTo(pick.Point).DistanceTo(pick.Point)).FirstOrDefault(); + if (entity == null) + return; var chain = BreakoffBuilder.FindEdgeChain(visible, entity); var used = item.Breakoffs.SelectMany(b => b.EdgeIds).ToHashSet(); if (chain.Any(en => used.Contains(en.Id))) { - ShowBreakoffMessage("That edge already belongs to a breakoff."); + ShowBreakoffMessage("That line already belongs to a breakoff."); return; } @@ -571,9 +601,9 @@ namespace OpenNest.Forms var layout = BreakoffBuilder.Layout(visible, breakoff, used); if (!layout.IsValid) { - ShowBreakoffMessage("Could not make a breakoff from that edge: " + layout.Error - + "\n\nClick an outside edge of the scrap piece. Its edges must form one chain whose " - + "ends touch the part outline."); + ShowBreakoffMessage("Could not make a breakoff from that line: " + layout.Error + + "\n\nClick the line that joins the part and the scrap. Its ends must touch the " + + "outline around both."); return; } @@ -589,6 +619,25 @@ namespace OpenNest.Forms staleProgram = true; } + // A joining line often ends partway along an outline segment; splitting there lets it be + // picked exactly. Existing breakoffs keep every piece of a split joining line. + private void SplitAtJunctions(FileListItem item) + { + var split = BreakoffBuilder.SplitAtJunctions(item.Entities); + if (split.Count == 0) + return; + + foreach (var breakoff in item.Breakoffs) + foreach (var id in breakoff.EdgeIds.ToList()) + if (split.TryGetValue(id, out var pieces)) + breakoff.EdgeIds.AddRange(pieces.Where(p => p != id)); + + item.EntityCount = item.Entities.Count; + entityView1.Entities.Clear(); + entityView1.Entities.AddRange(item.Entities); + lblEntityCount.Text = $"{item.EntityCount} entities"; + } + private void OnBreakoffEdited(object sender, int index) { var item = CurrentItem; @@ -621,7 +670,7 @@ namespace OpenNest.Forms .SelectMany(b => b.EdgeIds).ToList(); breakoff.TabWidth = dialog.TabWidth; - breakoff.SlotWidth = dialog.SlotWidth; + breakoff.Flip = dialog.Flip; if (!keepTabs) { var probe = breakoff.Clone(); @@ -649,6 +698,28 @@ namespace OpenNest.Forms staleProgram = true; } + private void OnBreakoffFlipped(object sender, int index) + { + var item = CurrentItem; + if (item == null || index < 0 || index >= item.Breakoffs.Count) + return; + + var flipped = item.Breakoffs[index].Clone(); + flipped.Flip = !flipped.Flip; + var others = item.Breakoffs.Where((_, i) => i != index).SelectMany(b => b.EdgeIds).ToList(); + var layout = BreakoffBuilder.Layout(VisibleEntities(item), flipped, others); + if (!layout.IsValid) + { + ShowBreakoffMessage("Cannot flip this breakoff: " + layout.Error); + return; + } + + item.Breakoffs[index] = flipped; + entityView1.SelectedBreakoffIndex = index; + RefreshBreakoffs(item); + staleProgram = true; + } + private void OnBreakoffSelected(object sender, int index) { entityView1.SelectedBreakoffIndex = index; diff --git a/OpenNest/GraphicsHelper.cs b/OpenNest/GraphicsHelper.cs index 28bb0192..20b2003d 100644 --- a/OpenNest/GraphicsHelper.cs +++ b/OpenNest/GraphicsHelper.cs @@ -54,6 +54,14 @@ namespace OpenNest etchPath = GetLayerPath(pgm, origin, layer => layer == LayerType.Scribe); } + /// + /// The closed material outlines to fill. Break cuts are open lines through material: a + /// fill would close each one with a chord and leave the sliver between them unfilled. + /// + public static GraphicsPath GetFillPath(this Program pgm, Vector origin) => + GetLayerPath(pgm, origin, layer => layer is not (LayerType.Scribe or LayerType.Leadin + or LayerType.Leadout or LayerType.Break)); + private static GraphicsPath GetLayerPath(Program pgm, Vector origin, System.Func includeLayer) { var path = new GraphicsPath(); @@ -115,7 +123,11 @@ namespace OpenNest graphics.TranslateTransform(offset.X, offset.Y); if (brush != null) - graphics.FillPath(brush, path); + { + using var fill = pgm.GetFillPath(Vector.Zero); + fill.Transform(matrix); + graphics.FillPath(brush, fill); + } if (pen == null) pen = Pens.Black; diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index 0548b9a1..b8463524 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -99,7 +99,8 @@ namespace OpenNest RefreshColors(); using var schemeSelectionBrush = IsSelected && !colorScheme.SelectedPartColor.IsEmpty ? new SolidBrush(colorScheme.SelectedPartColor) : null; - g.FillPath(muted ? Brushes.Gainsboro : schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush), Path); + g.FillPath(muted ? Brushes.Gainsboro : schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush), + FillPath ?? Path); DrawEtch(g, muted); // Keep real cuts visible even where an etch overlaps them. g.DrawPath(muted ? Pens.Gray : IsSelected && colorScheme.PartOutlineColor.IsEmpty ? selectedPen : pen, Path); @@ -137,6 +138,8 @@ namespace OpenNest pen = null; Path?.Dispose(); Path = null; + FillPath?.Dispose(); + FillPath = null; LeadInPath?.Dispose(); LeadInPath = null; EtchPath?.Dispose(); @@ -147,6 +150,14 @@ namespace OpenNest public GraphicsPath OffsetPath { get; private set; } + /// The material fill when it differs from Path (break cuts are drawn, not filled). + private GraphicsPath FillPath { get; set; } + + private static bool HasBreakCuts(CNC.Program program) => + program.Codes.Any(code => code is CNC.LinearMove { Layer: CNC.LayerType.Break } + or CNC.ArcMove { Layer: CNC.LayerType.Break } + || code is CNC.SubProgramCall { Program: { } sub } && HasBreakCuts(sub)); + internal void DrawEtch(Graphics g, bool muted = false) { if (EtchPath == null || EtchPath.PointCount == 0) @@ -198,6 +209,14 @@ namespace OpenNest Path?.Dispose(); LeadInPath?.Dispose(); EtchPath?.Dispose(); + FillPath?.Dispose(); + FillPath = null; + // Only programs with break cuts need a fill apart from the outline path. + if (HasBreakCuts(BasePart.Program)) + { + FillPath = BasePart.Program.GetFillPath(BasePart.Location); + FillPath.Transform(plateView.Matrix); + } Path = cutPath; EtchPath = etchPath; LeadInPath = BasePart.HasManualLeadIns ? leadPath : null; diff --git a/Posts/OpenNest.Posts.GravographIS/GravographISPostConfig.cs b/Posts/OpenNest.Posts.GravographIS/GravographISPostConfig.cs index 93222402..532f4449 100644 --- a/Posts/OpenNest.Posts.GravographIS/GravographISPostConfig.cs +++ b/Posts/OpenNest.Posts.GravographIS/GravographISPostConfig.cs @@ -79,6 +79,7 @@ namespace OpenNest.Posts.GravographIS case LayerType.Scribe: return Engrave; case LayerType.Cut: + case LayerType.Break: case LayerType.Leadin: case LayerType.Leadout: return Cut; diff --git a/docs/breakoffs.md b/docs/breakoffs.md index 29e77869..e3404b8a 100644 --- a/docs/breakoffs.md +++ b/docs/breakoffs.md @@ -1,43 +1,64 @@ # Breakoffs -A breakoff is scrap drawn outside a part, such as a forming backgauge tab, that must -stay attached through cutting and forming and is snapped off afterwards. The CAD -Converter's **Breakoffs** panel adds one: **Add Breakoff**, then click any outside edge -of the scrap piece. The clicked edge grows into the open chain of visible lines and -arcs that continues as a single path; both ends of that chain must touch one closed -outline within 0.001. The settings dialog sets the tab count (both end tabs included, -at least 2), tab width and slot width; tabs start evenly spaced and can be dragged -along the break line. Edit re-spaces the tabs only when the count or tab width -changes. +A breakoff is scrap drawn on a part, such as a forming backgauge tab, that must stay +attached through cutting and forming and is snapped off afterwards. In the CAD +Converter's **Breakoffs** panel, **Add Breakoff**, then click the line that joins the +part and the scrap. Lines or arcs that end partway along another visible line or arc +first split it there (the first piece keeps its entity ID), so a joining line can end +exactly where it meets the outline. The clicked entity then grows into the chain that +continues as a single path. Its ends must touch, within 0.001, one closed outline +drawn around both part and scrap, and the chain must run inside it: either a separate +line drawn across the outline, or a section of the part outline with the scrap's +outside edges drawn separately. + +The smaller side is the scrap; **Flip** (or "Larger side is scrap" in the dialog) +swaps it. The dialog sets the tab count (both end tabs included, at least 2) and tab +width; tabs start evenly spaced and can be dragged along the joining line. Edit +re-spaces the tabs only when the count or width changes. The view shades the scrap, +draws the cuts and marks where each cut is pierced. ## Geometry contract `BreakoffBuilder` (`OpenNest.Core/Breakoffs`) resolves each `Drawing.Breakoffs` entry against the entities being cut: -- The **break line** is the outline section between the chain's ends that, with the - chain, bounds the smaller region. The other region must equal the outline plus the - breakoff within 1%, so a chain inside the part is refused. -- The **outline** becomes the original outline with the break line replaced by the - chain. The chain's ends are snapped exactly onto the split points, because the - cutting planner closes contours within 1e-6 (a joining line is added when the end - entity is an arc). -- Between consecutive tabs, the break line is cut by a **closed slot** on the breakoff - side: its part side is the exact break line, and its scrap side runs `slotWidth` - into the breakoff. Near the breakoff's edges the slot is pulled back so at least - `tabWidth` of breakoff remains; its ends move inward until the slot is - `slotWidth / 4` deep. Interior tabs keep exactly their width; end tabs can be wider. -- Tab positions are measured from the chain end with the smaller X (then Y). +- The two sides are the outline from one end of the joining line to the other, closed + back along the joining line. They must add up to the outline within 1%. Tab + positions are measured from the joining line's end with the smaller X (then Y). +- The outline is kept, with every joint snapped exact (lines move their endpoint; a + short connecting line is added between arcs), because the planner closes contours within + 1e-6 and the program is re-chained from any entity. +- The joining line between consecutive tabs becomes open cuts on the `Break` layer + (`LayerType.Break`, `SpecialLayers.Break`), each travelling with the scrap on its + left. The joining line itself is not cut at the tabs; end tabs keep cuts off the + outline. -Slots are closed contours on purpose: `PreparedContours` accepts only closed -contours, so the open cuts this replaces could not get planned lead-ins. As internal -contours, every slot pierces inside itself, which is breakoff scrap. The automatic -entry may still finish its lead-in at a slot corner on the break line, which is a tab -edge. +## Cutting break cuts -Breakoffs that do not resolve (hidden or missing edges, overlapping tabs, no room for -a slot) are listed in the panel with the reason and are not applied, but are kept on -the drawing. `CadImporter.BuildDrawing` applies breakoffs to the program and copies -them to the drawing; `SourceEntities` keep the original edges so a reopened drawing -can be edited. Split pieces get no breakoffs. The breakoff counts as part material -for area, nesting and overlap checks. +Break cuts cut like `Cut` motions but are not material: `SpecialLayers.IsMaterial`, +material capture, overlap checks, nesting and area ignore them. `ShapeProfile` and +`ConvertGeometry` pass them through in order without re-chaining or re-winding. + +Both Plan Cutting (`PreparedContours`, emitted after scribes and before contours in +every full or prefix emission, never in single-contour probes) and Assign Lead-ins +(`ContourCuttingStrategy.Apply`/`ApplySingle`) emit them through +`ContourCuttingStrategy.EmitBreakCuts`: + +- Each cut is entered from its scrap side with the internal lead-in; `NoLeadIn` + pierces on the line. There is no lead-out, since the cut ends at a tab. +- It is entered at whichever end has more scrap before the part's contours along the + scrap direction (`BreakCutEntry`), because the scrap narrows to a wedge near the + outline. Entering at the far end cuts it in reverse. + +Checks treat them as intentional open cuts retained by tabs: `ReleasedContourState` +never makes them rapid obstacles or "open fragment" warnings, but reports a missing +lead-in like any contour. `LeadPathValidator` lets a lead into a break cut run +through its own part's material, and still refuses one that touches any of the part's +boundaries or another part. The posts cut them: Gravograph IS maps `Break` to its cut +settings; CI Fiber and Cincinnati treat any non-lead, non-scribe layer as cut. + +Breakoffs that do not resolve (hidden or missing joining line, overlapping tabs) are +listed with the reason and not applied, but stay on the drawing. +`CadImporter.BuildDrawing` applies breakoffs and copies them to the drawing; +`SourceEntities` keep the original geometry so a reopened drawing can be edited. Split +pieces get no breakoffs. diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 9d06bb1d..d51c614d 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -104,6 +104,12 @@ the final replay enforce them: Being continuous, a separating line passes through no part. A kept order that cuts it earlier is a constraint conflict naming the separating cutoff (user decision, 2026-10-13). +- A drawing's break cuts (`LayerType.Break`, see [breakoffs](breakoffs.md)) are open + cuts inside its material. Preparation keeps them apart from contours (no choices, + no material); every full and prefix emission cuts them after scribes and before any + contour, entered from the scrap with the internal lead-in. Their leads may run + through the part's own material but not touch its boundaries; they are never rapid + obstacles or unexplained open fragments. - A part whose perimeter lies strictly inside a cutout of another part precedes that host. Material bounds (never rapids or scribe marks) only select candidate pairs; containment is proven on native clean material. Touching or crossing boundaries, or material that cannot be diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index a2b07b8e..463033c1 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -180,12 +180,17 @@ false (the drawing's pins apply) with a warning naming the plate and part. ## Drawing breakoffs A drawing may carry a `breakoffs` array, omitted when empty. Each entry has -`edgeIds` (source entity IDs of the breakoff's outside edges), `tabWidth`, -`slotWidth` and `tabCenters` (distances along the break line), all in the source +`edgeIds` (source entity IDs of the joining line), `flip` (omitted when false), +`tabWidth` and `tabCenters` (distances along the joining line), all in the source frame; see [breakoffs](breakoffs.md). The stored program already has them applied, -so readers never rebuild geometry from them. An entry with no edges, a width that is -not positive and finite, or a nonfinite tab center is dropped with a reader warning; -the drawing and its program load unchanged, only the breakoff stops being editable. +so readers never rebuild geometry from them. An entry with no edges, a tab width that +is not positive and finite, or a nonfinite tab center is dropped with a reader +warning; the drawing and its program load unchanged, only the breakoff stops being +editable. + +Break cuts are saved as motions tagged `:BREAK`. In a drawing program each break cut +travels with its scrap on the left; placed programs may cut it either way. Versions +without breakoff support cannot read the tag. ## Verification and limits