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