diff --git a/AGENTS.md b/AGENTS.md
index 38cea056..39829361 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,6 +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
- [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
new file mode 100644
index 00000000..344a1788
--- /dev/null
+++ b/OpenNest.Core/Breakoffs/Breakoff.cs
@@ -0,0 +1,45 @@
+using System;
+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.
+ ///
+ 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.
+ public List EdgeIds { get; set; } = new();
+
+ /// Uncut length of each tab along the break 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.
+ ///
+ public List TabCenters { get; set; } = new();
+
+ public Breakoff Clone() =>
+ new()
+ {
+ EdgeIds = new List(EdgeIds),
+ TabWidth = TabWidth,
+ SlotWidth = SlotWidth,
+ TabCenters = new List(TabCenters),
+ };
+ }
+}
diff --git a/OpenNest.Core/Breakoffs/BreakoffBuilder.cs b/OpenNest.Core/Breakoffs/BreakoffBuilder.cs
new file mode 100644
index 00000000..4786e532
--- /dev/null
+++ b/OpenNest.Core/Breakoffs/BreakoffBuilder.cs
@@ -0,0 +1,407 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Geometry;
+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.
+ ///
+ public static class BreakoffBuilder
+ {
+ /// How close the breakoff'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 .
+ ///
+ public static List Apply(IReadOnlyList entities, IReadOnlyList breakoffs,
+ out List errors)
+ {
+ errors = new List();
+ var working = entities.ToList();
+ if (breakoffs == null || breakoffs.Count == 0)
+ return working;
+
+ var allEdges = breakoffs.SelectMany(b => b.EdgeIds).ToHashSet();
+ for (var i = 0; i < breakoffs.Count; i++)
+ {
+ var layout = Layout(working, breakoffs[i], allEdges);
+ if (!layout.IsValid)
+ {
+ errors.Add($"Breakoff {i + 1}: {layout.Error}");
+ continue;
+ }
+
+ 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()));
+ }
+ 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.
+ ///
+ 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.");
+
+ 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.");
+
+ var edges = BreakoffPath.ChainOpen(edgeSources, JoinTolerance);
+ if (edges == null)
+ return Fail("Its edges must form 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]);
+ if (b.X < a.X - JoinTolerance || (System.Math.Abs(b.X - a.X) <= JoinTolerance && b.Y < a.Y))
+ {
+ edges = BreakoffPath.Reversed(edges);
+ (a, b) = (b, a);
+ }
+
+ var excluded = new HashSet(otherEdgeIds ?? Enumerable.Empty());
+ excluded.UnionWith(edgeIds);
+ var sources = new Dictionary(ReferenceEqualityComparer.Instance);
+ var candidates = new List();
+ foreach (var entity in entities)
+ {
+ if (excluded.Contains(entity.Id) || IsMark(entity) || entity is not (Line or Arc))
+ continue;
+ var copy = BreakoffPath.Copy(entity);
+ sources[copy] = entity;
+ candidates.Add(copy);
+ }
+
+ Shape outline = null;
+ var outlineArea = 0.0;
+ foreach (var shape in ShapeBuilder.GetShapes(candidates))
+ {
+ if (shape.Entities.Count < 2 || !shape.IsClosed()
+ || shape.ClosestPointTo(a).DistanceTo(a) > JoinTolerance
+ || shape.ClosestPointTo(b).DistanceTo(b) > JoinTolerance)
+ continue;
+ var area = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(shape.Entities, ArcTolerance)));
+ if (outline == null || area > outlineArea)
+ {
+ outline = shape;
+ outlineArea = area;
+ }
+ }
+ if (outline == null)
+ return Fail("Both ends must touch a closed outline.");
+
+ var loop = 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.");
+ 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 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;
+ }
+
+ 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 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.");
+ 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.");
+ 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);
+ if (side == 0)
+ return Fail("Could not tell which side of the break line the breakoff is on.");
+
+ var slots = 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 replaced = outline.Entities.Select(e => sources[e]).ToList();
+ replaced.AddRange(edgeSources);
+ return new BreakoffLayout
+ {
+ BreakLine = breakLine,
+ Length = length,
+ Outline = merged,
+ Slots = slots,
+ TabCenters = centers,
+ TabWidth = tabWidth,
+ ReplacedEntities = replaced,
+ };
+ }
+
+ /// tab centers spaced evenly with the first and last at the ends.
+ public static List EvenTabCenters(double length, int count, double tabWidth)
+ {
+ count = System.Math.Max(count, Breakoff.MinTabCount);
+ var half = tabWidth / 2;
+ var span = length - tabWidth;
+ return Enumerable.Range(0, count).Select(i => half + span * i / (count - 1)).ToList();
+ }
+
+ ///
+ /// 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.
+ ///
+ public static List FindEdgeChain(IReadOnlyList entities, Entity picked)
+ {
+ var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
+ .ToList();
+ if (!pool.Contains(picked))
+ return new List();
+
+ var chain = new List { picked };
+ Grow(BreakoffPath.End(picked));
+ Grow(BreakoffPath.Start(picked));
+ return chain;
+
+ void Grow(Vector tip)
+ {
+ while (true)
+ {
+ var touching = pool.Where(e => !chain.Contains(e)
+ && (BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
+ || BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance)).ToList();
+ if (touching.Count != 1)
+ return;
+ var next = touching[0];
+ chain.Add(next);
+ tip = BreakoffPath.Start(next).DistanceTo(tip) <= JoinTolerance
+ ? BreakoffPath.End(next)
+ : BreakoffPath.Start(next);
+ }
+ }
+ }
+
+ private static BreakoffLayout Fail(string error) => new(error);
+
+ private static List SnapEnds(List edges, Vector start, Vector end)
+ {
+ var result = new List(edges);
+ var first = result[0];
+ if (BreakoffPath.Start(first).DistanceTo(start) > 0)
+ {
+ if (first is Line line)
+ line.StartPoint = start;
+ else
+ result.Insert(0, Joint(first, start, BreakoffPath.Start(first)));
+ }
+ 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;
+ }
+
+ private static Line Joint(Entity style, Vector from, Vector to) =>
+ new(from, to) { Layer = style.Layer, Color = style.Color, LineTypeName = style.LineTypeName };
+
+ private static bool IsMark(Entity e) =>
+ string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(e.Layer?.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase);
+
+ private static Vector Normal(IReadOnlyList path, double length, double distance)
+ {
+ const double nudge = 1e-6;
+ var before = BreakoffPath.TangentAt(path, System.Math.Max(distance - nudge, 0));
+ 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);
+ 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)
+ {
+ 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);
+ 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
new file mode 100644
index 00000000..ddf28599
--- /dev/null
+++ b/OpenNest.Core/Breakoffs/BreakoffLayout.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+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.
+ ///
+ public sealed class BreakoffLayout
+ {
+ internal BreakoffLayout(string error) => Error = error;
+
+ internal BreakoffLayout() { }
+
+ public string Error { get; }
+
+ public bool IsValid => Error == null;
+
+ /// The outline section shared by the part and the breakoff, 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.
+ public IReadOnlyList Outline { get; internal set; }
+
+ /// One closed contour per cut section of the break line.
+ public IReadOnlyList> Slots { 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.
+ 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 .
+ 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.
+ ///
+ public double ClampTabCenter(int index, double center)
+ {
+ var half = TabWidth / 2;
+ var min = index == 0 ? half : TabCenters[index - 1] + TabWidth + MinGap;
+ var max = index == TabCenters.Count - 1 ? Length - half : TabCenters[index + 1] - TabWidth - MinGap;
+ return max < min ? TabCenters[index] : System.Math.Clamp(center, min, max);
+ }
+
+ // Wider than the builder's overlap tolerance, so a clamped drag always resolves.
+ internal double MinGap => System.Math.Max(TabWidth / 2, 2 * BreakoffBuilder.JoinTolerance);
+ }
+}
diff --git a/OpenNest.Core/Breakoffs/BreakoffPath.cs b/OpenNest.Core/Breakoffs/BreakoffPath.cs
new file mode 100644
index 00000000..3ea6a718
--- /dev/null
+++ b/OpenNest.Core/Breakoffs/BreakoffPath.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Breakoffs
+{
+ ///
+ /// Distance-based operations on a chain of lines and arcs, each oriented in travel order
+ /// (one's end is the next one's start). Splits and copies keep the source entity's style.
+ ///
+ internal static class BreakoffPath
+ {
+ public static Vector Start(Entity e) => e is Line l ? l.StartPoint : ((Arc)e).StartPoint();
+
+ public static Vector End(Entity e) => e is Line l ? l.EndPoint : ((Arc)e).EndPoint();
+
+ public static double Length(IReadOnlyList path) => path.Sum(e => e.Length);
+
+ public static Vector PointAt(IReadOnlyList path, double distance) =>
+ Locate(path, distance, out var entity, out var local) ? PointOn(entity, local) : End(path[^1]);
+
+ public static Vector TangentAt(IReadOnlyList path, double distance)
+ {
+ Locate(path, distance, out var entity, out var local);
+ return TangentOn(entity, local);
+ }
+
+ /// The distance along the path of the path point closest to .
+ public static double DistanceOf(IReadOnlyList path, Vector point)
+ {
+ var best = double.MaxValue;
+ var bestDistance = 0.0;
+ var walked = 0.0;
+ foreach (var e in path)
+ {
+ var closest = e.ClosestPointTo(point);
+ var gap = closest.DistanceTo(point);
+ if (gap < best)
+ {
+ best = gap;
+ bestDistance = walked + LocalDistance(e, closest);
+ }
+ walked += e.Length;
+ }
+ return bestDistance;
+ }
+
+ /// Copies of the part of from to .
+ public static List Sub(IReadOnlyList path, double from, double to)
+ {
+ var result = new List();
+ var walked = 0.0;
+ foreach (var e in path)
+ {
+ var length = e.Length;
+ var a = System.Math.Max(from - walked, 0);
+ var b = System.Math.Min(to - walked, length);
+ walked += length;
+ if (b - a <= Tolerance.Epsilon)
+ continue;
+ result.Add(Piece(e, a, b));
+ }
+ return result;
+ }
+
+ /// Copies of in reverse travel order.
+ public static List Reversed(IEnumerable path)
+ {
+ var result = path.Select(Copy).ToList();
+ result.Reverse();
+ foreach (var e in result)
+ e.Reverse();
+ return result;
+ }
+
+ public static Entity Copy(Entity e)
+ {
+ var copy = e.Clone();
+ copy.Id = e.Id;
+ return copy;
+ }
+
+ /// Points along the path, arcs subdivided so no chord deviates more than .
+ public static List ToPoints(IEnumerable path, double tolerance)
+ {
+ var points = new List();
+ foreach (var e in path)
+ {
+ if (points.Count == 0)
+ points.Add(Start(e));
+ if (e is Arc arc)
+ points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)).Skip(1));
+ else
+ points.Add(End(e));
+ }
+ return points;
+ }
+
+ /// Signed area (counterclockwise positive) of a closed point loop.
+ public static double SignedArea(IReadOnlyList loop)
+ {
+ var sum = 0.0;
+ for (var i = 0; i < loop.Count; i++)
+ {
+ var a = loop[i];
+ var b = loop[(i + 1) % loop.Count];
+ sum += a.X * b.Y - b.X * a.Y;
+ }
+ return sum / 2;
+ }
+
+ public static bool Contains(IReadOnlyList loop, Vector point)
+ {
+ var inside = false;
+ for (int i = 0, j = loop.Count - 1; i < loop.Count; j = i++)
+ {
+ var a = loop[i];
+ var b = loop[j];
+ if ((a.Y > point.Y) != (b.Y > point.Y)
+ && point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
+ inside = !inside;
+ }
+ return inside;
+ }
+
+ private static bool Locate(IReadOnlyList path, double distance, out Entity entity, out double local)
+ {
+ var walked = 0.0;
+ foreach (var e in path)
+ {
+ var length = e.Length;
+ if (distance <= walked + length)
+ {
+ entity = e;
+ local = System.Math.Clamp(distance - walked, 0, length);
+ return true;
+ }
+ walked += length;
+ }
+ entity = path[^1];
+ local = entity.Length;
+ return false;
+ }
+
+ private static Vector PointOn(Entity e, double local)
+ {
+ if (e is Line line)
+ {
+ var t = line.Length <= 0 ? 0 : local / line.Length;
+ return line.StartPoint + (line.EndPoint - line.StartPoint) * t;
+ }
+ var arc = (Arc)e;
+ var angle = ArcAngleAt(arc, local);
+ return new Vector(arc.Center.X + arc.Radius * System.Math.Cos(angle),
+ arc.Center.Y + arc.Radius * System.Math.Sin(angle));
+ }
+
+ private static Vector TangentOn(Entity e, double local)
+ {
+ if (e is Line line)
+ {
+ var d = line.EndPoint - line.StartPoint;
+ var length = line.Length;
+ return length <= 0 ? new Vector(1, 0) : new Vector(d.X / length, d.Y / length);
+ }
+ var arc = (Arc)e;
+ var angle = ArcAngleAt(arc, local);
+ return arc.IsReversed
+ ? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
+ : new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
+ }
+
+ private static double ArcAngleAt(Arc arc, double local) =>
+ arc.StartAngle + (arc.IsReversed ? -1 : 1) * local / arc.Radius;
+
+ private static double LocalDistance(Entity e, Vector point)
+ {
+ if (e is Line line)
+ return line.StartPoint.DistanceTo(point);
+ var arc = (Arc)e;
+ var swept = Angle.NormalizeRad(arc.IsReversed
+ ? arc.StartAngle - arc.Center.AngleTo(point)
+ : arc.Center.AngleTo(point) - arc.StartAngle);
+ var distance = swept * arc.Radius;
+ // A point a hair before the start normalizes to a full turn; it belongs at an end.
+ if (distance > arc.Length)
+ return point.DistanceTo(arc.StartPoint()) <= point.DistanceTo(arc.EndPoint()) ? 0 : arc.Length;
+ return distance;
+ }
+
+ private static Entity Piece(Entity e, double a, double b)
+ {
+ Entity piece;
+ if (e is Line line)
+ {
+ piece = new Line(PointOn(line, a), PointOn(line, b));
+ }
+ else
+ {
+ var arc = (Arc)e;
+ piece = new Arc(arc.Center, arc.Radius, Angle.NormalizeRad(ArcAngleAt(arc, a)),
+ Angle.NormalizeRad(ArcAngleAt(arc, b)), arc.IsReversed);
+ }
+ piece.Layer = e.Layer;
+ piece.Color = e.Color;
+ piece.LineTypeName = e.LineTypeName;
+ return piece;
+ }
+
+ ///
+ /// Orders into one open chain, copied and oriented in travel
+ /// order, or returns null when they do not form exactly one open chain.
+ ///
+ public static List ChainOpen(IReadOnlyList edges, double tolerance)
+ {
+ if (edges.Count == 0 || edges.Any(e => e is not (Line or Arc)))
+ return null;
+
+ int Degree(Vector p) => edges.Count(e => Start(e).DistanceTo(p) <= tolerance)
+ + edges.Count(e => End(e).DistanceTo(p) <= tolerance);
+
+ var first = edges.FirstOrDefault(e => Degree(Start(e)) == 1 || Degree(End(e)) == 1);
+ if (first == null)
+ return null;
+
+ var remaining = edges.ToList();
+ var chain = new List();
+ var current = Copy(first);
+ if (Degree(Start(first)) != 1)
+ current.Reverse();
+ remaining.Remove(first);
+ chain.Add(current);
+
+ while (remaining.Count > 0)
+ {
+ var tip = End(chain[^1]);
+ var next = remaining.FirstOrDefault(e => Start(e).DistanceTo(tip) <= tolerance
+ || End(e).DistanceTo(tip) <= tolerance);
+ if (next == null)
+ return null;
+ var copy = Copy(next);
+ if (Start(next).DistanceTo(tip) > tolerance)
+ copy.Reverse();
+ remaining.Remove(next);
+ chain.Add(copy);
+ }
+
+ return Start(chain[0]).DistanceTo(End(chain[^1])) <= tolerance ? null : chain;
+ }
+ }
+}
diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs
index 471a169c..b83f5bf3 100644
--- a/OpenNest.Core/Drawing.cs
+++ b/OpenNest.Core/Drawing.cs
@@ -125,6 +125,12 @@ namespace OpenNest
///
public List CutPins { get; set; } = new List();
+ ///
+ /// Scrap pieces tabbed to the part, in the source frame. already has
+ /// them applied; they are kept so an editor can change them from .
+ ///
+ public List Breakoffs { get; set; } = new List();
+
///
/// Complete set of source entities with stable GUIDs.
/// Null when the drawing was created from G-code or an older nest file.
diff --git a/OpenNest.IO.Tests/BreakoffPersistenceTests.cs b/OpenNest.IO.Tests/BreakoffPersistenceTests.cs
new file mode 100644
index 00000000..e594dc1f
--- /dev/null
+++ b/OpenNest.IO.Tests/BreakoffPersistenceTests.cs
@@ -0,0 +1,95 @@
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json.Nodes;
+using OpenNest.Breakoffs;
+
+namespace OpenNest.IO.Tests;
+
+public class BreakoffPersistenceTests
+{
+ private static Drawing WithBreakoff()
+ {
+ var drawing = DrawingPackageFixture.Build();
+ drawing.Breakoffs.Add(new Breakoff
+ {
+ EdgeIds = [Guid.NewGuid(), Guid.NewGuid()],
+ TabWidth = 0.05,
+ SlotWidth = 0.3,
+ TabCenters = [0.025, 1.5, 3.2],
+ });
+ return drawing;
+ }
+
+ [Fact]
+ public void Breakoffs_RoundTripEveryField()
+ {
+ var drawing = WithBreakoff();
+ var original = drawing.Breakoffs[0];
+
+ var (restored, warnings) = RoundTrip(Write(drawing));
+
+ 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(original.TabCenters, breakoff.TabCenters);
+ }
+
+ [Fact]
+ public void NoBreakoffs_AreOmittedFromTheFile()
+ {
+ var archive = Write(DrawingPackageFixture.Build());
+
+ Assert.False(NestJson(archive)["drawings"]![0]!.AsObject().ContainsKey("breakoffs"));
+ Assert.Empty(Assert.Single(RoundTrip(archive).Nest.Drawings).Breakoffs);
+ }
+
+ [Fact]
+ public void UnreadableBreakoff_IsDroppedWithAWarning_AndTheDrawingLoads()
+ {
+ var archive = Write(WithBreakoff());
+ var json = NestJson(archive);
+ json["drawings"]![0]!["breakoffs"]![0]!["tabWidth"] = 0;
+
+ var (restored, warnings) = RoundTrip(ReplaceNestJson(archive, json));
+
+ Assert.Empty(Assert.Single(restored.Drawings).Breakoffs);
+ Assert.Contains(warnings, w => w.Contains("breakoff 1 could not be read"));
+ }
+
+ private static byte[] Write(Drawing drawing)
+ {
+ var nest = new Nest(drawing.Name) { Units = Units.Inches };
+ nest.Drawings.Add(drawing);
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ return stream.ToArray();
+ }
+
+ private static (Nest Nest, IReadOnlyList Warnings) RoundTrip(byte[] archive)
+ {
+ var reader = new NestReader(new MemoryStream(archive));
+ return (reader.Read(), reader.Warnings);
+ }
+
+ private static JsonObject NestJson(byte[] archive)
+ {
+ using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
+ using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
+ return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
+ }
+
+ private static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
+ {
+ using var output = new MemoryStream();
+ output.Write(archive);
+ using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
+ {
+ zip.GetEntry("nest.json")!.Delete();
+ using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
+ writer.Write(json.ToJsonString());
+ }
+
+ return output.ToArray();
+ }
+}
diff --git a/OpenNest.IO/CadImporter.cs b/OpenNest.IO/CadImporter.cs
index edf90151..58c9ac33 100644
--- a/OpenNest.IO/CadImporter.cs
+++ b/OpenNest.IO/CadImporter.cs
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Bending;
+using OpenNest.Breakoffs;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
@@ -130,19 +131,27 @@ namespace OpenNest.IO
/// in-place G-code edits). Source.Offset is still populated from the
/// generated program so round-trips stay consistent.
///
+ ///
+ /// Breakoffs to merge into the program and keep on the drawing; ones that cannot be
+ /// resolved against are kept but not applied.
+ ///
public static Drawing BuildDrawing(
CadImportResult result,
IEnumerable entities,
IEnumerable bends,
int quantity,
string customer,
- OpenNest.CNC.Program editedProgram
+ OpenNest.CNC.Program editedProgram,
+ IReadOnlyList breakoffs = null
)
{
var visible = entities as IList ?? new List(entities);
var bendList = bends as IList ?? new List(bends);
- var normalized = ShapeProfile.NormalizeEntities(visible);
+ var cut = breakoffs is { Count: > 0 }
+ ? BreakoffBuilder.Apply(visible.ToList(), breakoffs, out _)
+ : visible;
+ var normalized = ShapeProfile.NormalizeEntities(cut);
var pgm = ConvertGeometry.ToProgram(normalized);
var offset = Vector.Zero;
@@ -166,6 +175,7 @@ namespace OpenNest.IO
drawing.Source.Offset = offset;
drawing.Quantity.Required = quantity;
drawing.Bends.AddRange(bendList);
+ drawing.Breakoffs = breakoffs?.Select(b => b.Clone()).ToList() ?? new List();
drawing.Program = editedProgram ?? pgm;
var bendSources = new HashSet(
@@ -234,6 +244,7 @@ namespace OpenNest.IO
existing.Quantity.Required = edited.Quantity.Required;
existing.Bends.Clear();
existing.Bends.AddRange(edited.Bends);
+ existing.Breakoffs = edited.Breakoffs;
}
private static bool IsRepairMark(Entity e) =>
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index 4ca2a292..dfc7df53 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -202,6 +202,21 @@ namespace OpenNest.IO
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientCutPinListConverter))]
public List CutPins { get; init; }
+
+ ///
+ /// Breakoffs in the source frame, already applied to the stored program. Omitted when
+ /// there are none.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public List Breakoffs { get; init; }
+ }
+
+ public record BreakoffDto
+ {
+ public List EdgeIds { get; init; } = new();
+ public double TabWidth { get; init; }
+ public double SlotWidth { get; init; }
+ public List TabCenters { get; init; } = new();
}
public record CutPinDto
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index 25292411..9bf11694 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -7,6 +7,7 @@ using System.Linq;
using System.Text.Json;
using OpenNest.Bending;
using OpenNest.BestFit;
+using OpenNest.Breakoffs;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Converters;
@@ -276,6 +277,7 @@ namespace OpenNest.IO
drawing.Library = ReadLibraryLink(d);
drawing.CutPins = ReadCutPins(d);
+ drawing.Breakoffs = ReadBreakoffs(d);
map[d.Id] = drawing;
}
return map;
@@ -326,6 +328,38 @@ namespace OpenNest.IO
return pins;
}
+ // The program already has breakoffs applied; a dropped definition only loses editability.
+ private List ReadBreakoffs(DrawingDto d)
+ {
+ var breakoffs = new List();
+ if (d.Breakoffs == null)
+ return breakoffs;
+
+ for (var i = 0; i < d.Breakoffs.Count; i++)
+ {
+ 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(),
+ TabWidth = dto.TabWidth,
+ SlotWidth = dto.SlotWidth,
+ TabCenters = dto.TabCenters.ToList(),
+ });
+ }
+ else
+ {
+ warnings.Add($"Drawing '{d.Name}': breakoff {i + 1} could not be read; its cut geometry "
+ + "is kept, but it can no longer be edited as a breakoff.");
+ }
+ }
+ return breakoffs;
+ }
+
private bool ReadPinsOff(PartDto dto, int plateId, int partIndex, Part part)
{
if (dto.DrawingPinsOff is bool pinsOff)
diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs
index 00dc236a..db488443 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -200,6 +200,15 @@ namespace OpenNest.IO
?? new List(),
Library = ToLibraryLinkDto(d.Library),
CutPins = d.CutPins?.Count > 0 ? d.CutPins.Select(CutPinFormat.ToDto).ToList() : null,
+ Breakoffs = d.Breakoffs?.Count > 0
+ ? d.Breakoffs.Select(b => new BreakoffDto
+ {
+ EdgeIds = b.EdgeIds.ToList(),
+ TabWidth = b.TabWidth,
+ SlotWidth = b.SlotWidth,
+ TabCenters = b.TabCenters.ToList(),
+ }).ToList()
+ : null,
}
);
}
diff --git a/OpenNest.Tests/Breakoffs/BreakoffBuilderTests.cs b/OpenNest.Tests/Breakoffs/BreakoffBuilderTests.cs
new file mode 100644
index 00000000..37478b37
--- /dev/null
+++ b/OpenNest.Tests/Breakoffs/BreakoffBuilderTests.cs
@@ -0,0 +1,274 @@
+using OpenNest.Bending;
+using OpenNest.Breakoffs;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+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.
+///
+public class BreakoffBuilderTests
+{
+ private const double W = Breakoff.DefaultTabWidth;
+
+ private static List Part(bool leftBreakoff = false)
+ {
+ 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),
+ };
+ if (leftBreakoff)
+ entities.AddRange(new[] { new Line(0, 1, -2, 3), new Line(-2, 3, 0, 5) });
+ return entities;
+ }
+
+ private static Breakoff On(params Entity[] edges) => new() { EdgeIds = edges.Select(e => e.Id).ToList() };
+
+ private static Breakoff Right(List part) => On(part[4], part[5]);
+
+ [Fact]
+ public void Layout_MergesTheBreakoffIntoTheOutlineAndSlotsTheBreakLine()
+ {
+ var part = Part();
+
+ var layout = BreakoffBuilder.Layout(part, Right(part));
+
+ 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.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");
+ }
+ // 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()
+ {
+ 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 proposal = CuttingPlanBatch.Capture([plate], new CuttingParameters(), false).Plan();
+
+ 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.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");
+ }
+ }
+
+ [Fact]
+ public void BuildDrawing_KeepsACopyOfTheBreakoffAndCountsItsMaterial()
+ {
+ var part = Part();
+ var breakoff = Right(part);
+
+ var drawing = Build(part, breakoff);
+
+ 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);
+ }
+
+ [Fact]
+ public void ArcBreakoff_IsMergedAndSlotted()
+ {
+ 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 layout = BreakoffBuilder.Layout(part, On(arc));
+
+ 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);
+ }
+
+ [Fact]
+ public void BreakoffEndingExactlyAtAnArcStart_SplitsTheOutlineThere()
+ {
+ // 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 layout = BreakoffBuilder.Layout(part, On(a, b));
+
+ 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);
+ }
+
+ [Fact]
+ public void TwoBreakoffsOnOneOutline_BothApply()
+ {
+ var part = Part(leftBreakoff: true);
+ var breakoffs = new List { Right(part), On(part[6], part[7]) };
+
+ var applied = BreakoffBuilder.Apply(part, breakoffs, 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);
+ }
+
+ [Fact]
+ public void Apply_LeavesItsInputsUntouched()
+ {
+ var part = Part();
+ var before = part.Select(e => (e.Id, Ends(e).ToArray())).ToList();
+
+ BreakoffBuilder.Apply(part, [Right(part)], out _);
+
+ 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()));
+ }
+
+ [Fact]
+ public void BreakoffInsideThePart_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]);
+
+ Assert.Equal("It must sit outside the part outline.", BreakoffBuilder.Layout(part, On(a, b)).Error);
+ }
+
+ [Fact]
+ public void HiddenEdge_IsReportedAndNotApplied()
+ {
+ var part = Part();
+ var breakoff = Right(part);
+ part.RemoveAt(5);
+
+ var applied = BreakoffBuilder.Apply(part, [breakoff], out var errors);
+
+ Assert.Equal("Breakoff 1: Some of its edges are hidden or missing.", Assert.Single(errors));
+ Assert.Equal(part, applied);
+ }
+
+ [Fact]
+ public void OverlappingTabs_AreRefused()
+ {
+ var part = Part();
+ var breakoff = Right(part);
+ breakoff.TabCenters = [W / 2, W, 4 - W / 2];
+
+ Assert.Equal("Two tabs overlap; space the tabs evenly again.", BreakoffBuilder.Layout(part, breakoff).Error);
+ }
+
+ [Fact]
+ public void SlotsWithNoRoom_AreRefused()
+ {
+ var part = Part();
+ var breakoff = Right(part);
+ breakoff.SlotWidth = 10;
+
+ 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());
+ }
+
+ [Fact]
+ public void ClampTabCenter_KeepsTabsOnTheLineAndApart()
+ {
+ var part = Part();
+ var layout = BreakoffBuilder.Layout(part, Right(part));
+
+ Assert.Equal(W / 2, layout.ClampTabCenter(0, -5), 9);
+ Assert.Equal(layout.TabCenters[1] - W - W / 2, layout.ClampTabCenter(0, layout.TabCenters[1]), 9);
+ Assert.Equal(4 - W / 2, layout.ClampTabCenter(layout.TabCenters.Count - 1, 99), 9);
+ }
+
+ private static Drawing Build(List entities, params Breakoff[] breakoffs) =>
+ CadImporter.BuildDrawing(new CadImportResult { Entities = entities, Name = "breakoff" }, entities,
+ new List(), 1, null, null, breakoffs);
+
+ private static (double From, double To) OnBreakLine(IReadOnlyList slot)
+ {
+ 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();
+ 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 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;
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/CadConverterBreakoffTests.cs b/OpenNest.WinForms.Tests/Forms/CadConverterBreakoffTests.cs
new file mode 100644
index 00000000..41e8f96f
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Forms/CadConverterBreakoffTests.cs
@@ -0,0 +1,130 @@
+using System.Drawing;
+using System.Reflection;
+using System.Windows.Forms;
+using OpenNest.Bending;
+using OpenNest.Breakoffs;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Controls;
+using OpenNest.Forms;
+using OpenNest.Geometry;
+using OpenNest.IO;
+
+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.
+///
+public class CadConverterBreakoffTests
+{
+ [Fact]
+ public void PickingAnEdge_AddsABreakoff_DraggingMovesATab_AndAcceptKeepsIt() => ArchUnitsTests.RunSta(() =>
+ {
+ using var form = new BreakoffTestForm();
+ Show(form);
+ form.LoadDrawings([Part()]);
+ var view = Field(form, "entityView1");
+ var item = Field(form, "fileList").SelectedItem!;
+
+ Invoke(form, "OnAddBreakoffClicked", null, EventArgs.Empty);
+ Assert.True(view.IsPickingBreakoff);
+ Mouse(view, "OnMouseClick", view.PointWorldToControl(new Vector(11, 2)));
+
+ Assert.Empty(form.Messages);
+ Assert.False(view.IsPickingBreakoff);
+ var breakoff = Assert.Single(item.Breakoffs);
+ Assert.Equal(2, breakoff.EdgeIds.Count);
+ Assert.Equal(4, breakoff.TabCenters.Count);
+ var layout = Assert.Single(view.BreakoffLayouts);
+ Assert.True(layout.IsValid, layout.Error);
+
+ var before = breakoff.TabCenters[1];
+ Mouse(view, "OnMouseDown", view.PointWorldToControl(layout.PointAt(before)));
+ Mouse(view, "OnMouseMove", view.PointWorldToControl(new Vector(10, 1 + 1.75)));
+ Mouse(view, "OnMouseUp", view.PointWorldToControl(new Vector(10, 1 + 1.75)));
+ Assert.InRange(breakoff.TabCenters[1], 1.6, 1.9);
+ Assert.NotEqual(before, breakoff.TabCenters[1]);
+
+ 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);
+ form.Close();
+ });
+
+ [Fact]
+ public void PickingTheOutline_ExplainsAndAddsNothing() => ArchUnitsTests.RunSta(() =>
+ {
+ using var form = new BreakoffTestForm();
+ Show(form);
+ form.LoadDrawings([Part()]);
+ var view = Field(form, "entityView1");
+
+ 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.Empty(Field(form, "fileList").SelectedItem!.Breakoffs);
+ form.Close();
+ });
+
+ [Fact]
+ 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 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() }]);
+
+ using var form = new BreakoffTestForm();
+ Show(form);
+ form.LoadDrawings([withBreakoff]);
+
+ var layout = Assert.Single(Field(form, "entityView1").BreakoffLayouts);
+ Assert.True(layout.IsValid, layout.Error);
+ form.Close();
+ });
+
+ private static Drawing Part()
+ {
+ 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),
+ };
+ foreach (var e in entities)
+ e.Layer = new Layer("0") { IsVisible = true };
+ return CadImporter.BuildDrawing(new CadImportResult { Entities = entities, Name = "breakoff" }, entities,
+ new List(), 1, null, null);
+ }
+
+ private static void Show(Form form)
+ {
+ form.WindowState = FormWindowState.Normal;
+ form.StartPosition = FormStartPosition.Manual;
+ form.Location = new Point(0, 0);
+ form.Size = new System.Drawing.Size(1200, 800);
+ form.Show();
+ }
+
+ private static T Field(object owner, string name) =>
+ (T)owner.GetType().BaseType!.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
+
+ private static void Invoke(CadConverterForm form, string name, params object?[] args) =>
+ typeof(CadConverterForm).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(form, args);
+
+ private static void Mouse(Control control, string handler, Point at) =>
+ typeof(Control).GetMethod(handler, BindingFlags.Instance | BindingFlags.NonPublic, [typeof(MouseEventArgs)])!
+ .Invoke(control, [new MouseEventArgs(MouseButtons.Left, 1, at.X, at.Y, 0)]);
+
+ private sealed class BreakoffTestForm : CadConverterForm
+ {
+ public List Messages { get; } = new();
+
+ internal override bool AskBreakoffSettings(BreakoffDialog dialog) => true;
+
+ internal override void ShowBreakoffMessage(string message) => Messages.Add(message);
+ }
+}
diff --git a/OpenNest/Controls/EntityView.Breakoffs.cs b/OpenNest/Controls/EntityView.Breakoffs.cs
new file mode 100644
index 00000000..62ec91d5
--- /dev/null
+++ b/OpenNest/Controls/EntityView.Breakoffs.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Windows.Forms;
+using OpenNest.Breakoffs;
+using OpenNest.Geometry;
+
+namespace OpenNest.Controls
+{
+ public sealed class TabDragEventArgs : EventArgs
+ {
+ public TabDragEventArgs(int breakoff, int tab, double center, bool finished)
+ {
+ Breakoff = breakoff;
+ Tab = tab;
+ Center = center;
+ Finished = finished;
+ }
+
+ public int Breakoff { get; }
+ public int Tab { get; }
+
+ /// The new tab center as a distance along the break line, already clamped.
+ public double Center { get; }
+
+ public bool Finished { get; }
+ }
+
+ public partial class EntityView
+ {
+ private const int TabHandleRadius = 7;
+
+ private bool isPickingBreakoff;
+ private (int Breakoff, int Tab)? draggedTab;
+
+ /// Resolved breakoffs to draw, in breakoff order; null entries are not drawn.
+ public IReadOnlyList BreakoffLayouts { get; set; } = Array.Empty();
+
+ public int SelectedBreakoffIndex { get; set; } = -1;
+
+ public event EventHandler BreakoffEdgePicked;
+ public event EventHandler TabDragged;
+
+ public bool IsPickingBreakoff
+ {
+ get => isPickingBreakoff;
+ set
+ {
+ isPickingBreakoff = value;
+ Cursor = value ? Cursors.Hand : Cursors.Cross;
+ }
+ }
+
+ protected override void OnMouseDown(MouseEventArgs e)
+ {
+ base.OnMouseDown(e);
+ if (e.Button == MouseButtons.Left && !IsPickingBendLine && !IsPickingBreakoff)
+ draggedTab = HitTestTab(e.Location);
+ }
+
+ protected override void OnMouseUp(MouseEventArgs e)
+ {
+ base.OnMouseUp(e);
+ if (draggedTab is not { } tab || e.Button != MouseButtons.Left)
+ return;
+ draggedTab = null;
+ RaiseTabDragged(tab, e.Location, finished: true);
+ }
+
+ private void HandleBreakoffMouseMove(MouseEventArgs e)
+ {
+ if (draggedTab is { } tab)
+ RaiseTabDragged(tab, e.Location, finished: false);
+ else if (e.Button == MouseButtons.None && !IsPickingBendLine && !IsPickingBreakoff)
+ Cursor = HitTestTab(e.Location) != null ? Cursors.SizeAll : Cursors.Cross;
+ }
+
+ private bool HandleBreakoffClick(MouseEventArgs e)
+ {
+ if (!IsPickingBreakoff || e.Button != MouseButtons.Left)
+ return false;
+ var entity = HitTestEdge(e.Location);
+ if (entity != null)
+ BreakoffEdgePicked?.Invoke(this, entity);
+ return true;
+ }
+
+ private void RaiseTabDragged((int Breakoff, int Tab) tab, Point location, bool finished)
+ {
+ var layout = BreakoffLayouts.ElementAtOrDefault(tab.Breakoff);
+ if (layout is not { IsValid: true } || tab.Tab >= layout.TabCenters.Count)
+ return;
+ var center = layout.ClampTabCenter(tab.Tab, layout.DistanceOf(PointControlToWorld(location)));
+ TabDragged?.Invoke(this, new TabDragEventArgs(tab.Breakoff, tab.Tab, center, finished));
+ }
+
+ private (int, int)? HitTestTab(Point location)
+ {
+ for (var b = 0; b < BreakoffLayouts.Count; b++)
+ {
+ var layout = BreakoffLayouts[b];
+ if (layout is not { IsValid: true })
+ continue;
+ for (var t = 0; t < layout.TabCenters.Count; t++)
+ {
+ var handle = PointWorldToControl(layout.PointAt(layout.TabCenters[t]));
+ var dx = handle.X - location.X;
+ var dy = handle.Y - location.Y;
+ if (dx * dx + dy * dy <= TabHandleRadius * TabHandleRadius)
+ return (b, t);
+ }
+ }
+ return null;
+ }
+
+ private Entity HitTestEdge(Point controlPoint)
+ {
+ var worldPoint = PointControlToWorld(controlPoint);
+ var tolerance = LengthGuiToWorld(6);
+ Entity best = null;
+ var bestDistance = double.MaxValue;
+ foreach (var entity in Entities)
+ {
+ if (entity is not (Line or Arc) || !entity.IsVisible || entity.Layer?.IsVisible == false)
+ continue;
+ var distance = worldPoint.DistanceTo(entity.ClosestPointTo(worldPoint));
+ if (distance < tolerance && distance < bestDistance)
+ {
+ best = entity;
+ bestDistance = distance;
+ }
+ }
+ return best;
+ }
+
+ private void DrawBreakoffs(Graphics g)
+ {
+ 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 tabPen = new Pen(Color.Red, 3f);
+ using var handleBrush = new SolidBrush(Color.Red);
+ using var handlePen = new Pen(Color.White, 1f);
+
+ for (var b = 0; b < BreakoffLayouts.Count; b++)
+ {
+ var layout = BreakoffLayouts[b];
+ if (layout is not { IsValid: true })
+ continue;
+ var selected = b == SelectedBreakoffIndex;
+
+ foreach (var slot in layout.Slots)
+ {
+ var points = SlotOutline(slot);
+ if (points.Length < 3)
+ continue;
+ g.FillPolygon(slotFill, points);
+ g.DrawPolygon(selected ? selectedSlotPen : slotPen, points);
+ }
+
+ foreach (var center in layout.TabCenters)
+ {
+ var from = PointWorldToGraph(layout.PointAt(center - layout.TabWidth / 2));
+ var to = PointWorldToGraph(layout.PointAt(center + layout.TabWidth / 2));
+ g.DrawLine(tabPen, from, to);
+ var handle = PointWorldToGraph(layout.PointAt(center));
+ var size = selected ? TabHandleRadius : TabHandleRadius - 2;
+ var rect = new RectangleF(handle.X - size, handle.Y - size, size * 2, size * 2);
+ g.FillEllipse(handleBrush, rect);
+ g.DrawEllipse(handlePen, rect);
+ }
+ }
+ }
+
+ private PointF[] SlotOutline(IReadOnlyList slot)
+ {
+ var points = new List();
+ foreach (var entity in slot)
+ {
+ if (entity is Arc arc)
+ points.AddRange(arc.ToPoints(System.Math.Max(2, arc.SegmentsForTolerance(LengthGuiToWorld(0.5f))))
+ .Select(PointWorldToGraph));
+ else if (entity is Line line)
+ {
+ points.Add(PointWorldToGraph(line.StartPoint));
+ points.Add(PointWorldToGraph(line.EndPoint));
+ }
+ }
+ return points.ToArray();
+ }
+ }
+}
diff --git a/OpenNest/Controls/EntityView.cs b/OpenNest/Controls/EntityView.cs
index 8163e4c1..e7457ac8 100644
--- a/OpenNest/Controls/EntityView.cs
+++ b/OpenNest/Controls/EntityView.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
@@ -10,7 +10,7 @@ using OpenNest.Math;
namespace OpenNest.Controls
{
- public class EntityView : DrawControl
+ public partial class EntityView : DrawControl
{
public List Entities;
public List Bends = new List();
@@ -74,6 +74,9 @@ namespace OpenNest.Controls
if (!Focused)
Focus();
+ if (HandleBreakoffClick(e))
+ return;
+
if (IsPickingBendLine && e.Button == MouseButtons.Left)
{
var line = HitTestLine(e.Location);
@@ -123,6 +126,7 @@ namespace OpenNest.Controls
}
DrawBendLines(e.Graphics);
+ DrawBreakoffs(e.Graphics);
foreach (var entity in Entities)
{
@@ -200,6 +204,7 @@ namespace OpenNest.Controls
{
LastPoint = CurrentPoint;
CurrentPoint = PointControlToWorld(e.Location);
+ HandleBreakoffMouseMove(e);
}
lastPoint = e.Location;
@@ -222,9 +227,10 @@ namespace OpenNest.Controls
if (e.KeyCode == Keys.F)
ZoomToFit();
- if (IsPickingBendLine && e.KeyCode == Keys.Escape)
+ if ((IsPickingBendLine || IsPickingBreakoff) && e.KeyCode == Keys.Escape)
{
IsPickingBendLine = false;
+ IsPickingBreakoff = false;
PickCancelled?.Invoke(this, EventArgs.Empty);
}
}
diff --git a/OpenNest/Controls/FileListControl.cs b/OpenNest/Controls/FileListControl.cs
index 75496b47..31ca6e88 100644
--- a/OpenNest/Controls/FileListControl.cs
+++ b/OpenNest/Controls/FileListControl.cs
@@ -19,6 +19,7 @@ namespace OpenNest.Controls
public List Entities { get; set; } = new();
public List OriginalEntities { get; set; }
public List Bends { get; set; } = new();
+ public List Breakoffs { get; set; } = new();
public HashSet SuppressedEntityIds { get; set; }
public Box Bounds { get; set; }
public int EntityCount { get; set; }
diff --git a/OpenNest/Controls/FilterPanel.cs b/OpenNest/Controls/FilterPanel.cs
index 6e6e2da1..4cddcbff 100644
--- a/OpenNest/Controls/FilterPanel.cs
+++ b/OpenNest/Controls/FilterPanel.cs
@@ -20,6 +20,10 @@ namespace OpenNest.Controls
private readonly CheckedListBox lineTypesList;
private readonly ListBox bendLinesList;
private readonly LinkLabel bendAddLink;
+ private readonly CollapsiblePanel breakoffsPanel;
+ private readonly ListBox breakoffsList;
+ private readonly LinkLabel breakoffAddLink;
+ private bool loadingBreakoffs;
private List currentEntities;
private List currentBends;
@@ -29,12 +33,70 @@ namespace OpenNest.Controls
public event EventHandler BendLineRemoved;
public event EventHandler BendLineEdited;
public event EventHandler AddBendLineClicked;
+ public event EventHandler BreakoffSelected;
+ public event EventHandler BreakoffEdited;
+ public event EventHandler BreakoffRemoved;
+ public event EventHandler AddBreakoffClicked;
public FilterPanel()
{
AutoScroll = true;
BackColor = Color.White;
+ // Breakoffs
+ breakoffsPanel = new CollapsiblePanel
+ {
+ HeaderText = "Breakoffs (0)",
+ Dock = DockStyle.Top,
+ ExpandedHeight = 110,
+ IsExpanded = false,
+ };
+ breakoffsList = new ListBox
+ {
+ Dock = DockStyle.Fill,
+ BorderStyle = BorderStyle.None,
+ Font = new Font("Segoe UI", 9f),
+ };
+ breakoffsList.SelectedIndexChanged += (s, e) =>
+ {
+ if (!loadingBreakoffs)
+ BreakoffSelected?.Invoke(this, breakoffsList.SelectedIndex);
+ };
+ breakoffsList.DoubleClick += (s, e) =>
+ {
+ if (breakoffsList.SelectedIndex >= 0)
+ BreakoffEdited?.Invoke(this, breakoffsList.SelectedIndex);
+ };
+
+ breakoffAddLink = new LinkLabel { Text = "Add Breakoff", AutoSize = true, Font = new Font("Segoe UI", 8f) };
+ breakoffAddLink.LinkClicked += (s, e) => AddBreakoffClicked?.Invoke(this, EventArgs.Empty);
+ var breakoffEditLink = new LinkLabel { Text = "Edit", AutoSize = true, Font = new Font("Segoe UI", 8f) };
+ breakoffEditLink.LinkClicked += (s, e) =>
+ {
+ if (breakoffsList.SelectedIndex >= 0)
+ BreakoffEdited?.Invoke(this, breakoffsList.SelectedIndex);
+ };
+ var breakoffRemoveLink = new LinkLabel { Text = "Remove", AutoSize = true, Font = new Font("Segoe UI", 8f) };
+ breakoffRemoveLink.LinkClicked += (s, e) =>
+ {
+ if (breakoffsList.SelectedIndex >= 0)
+ BreakoffRemoved?.Invoke(this, breakoffsList.SelectedIndex);
+ };
+
+ var breakoffLinksPanel = new FlowLayoutPanel
+ {
+ Dock = DockStyle.Top,
+ Height = 20,
+ FlowDirection = FlowDirection.LeftToRight,
+ WrapContents = false,
+ };
+ breakoffLinksPanel.Controls.Add(breakoffAddLink);
+ breakoffLinksPanel.Controls.Add(breakoffEditLink);
+ breakoffLinksPanel.Controls.Add(breakoffRemoveLink);
+
+ breakoffsPanel.ContentPanel.Controls.Add(breakoffsList);
+ breakoffsPanel.ContentPanel.Controls.Add(breakoffLinksPanel);
+
// Bend Lines
bendLinesPanel = new CollapsiblePanel
{
@@ -169,6 +231,7 @@ namespace OpenNest.Controls
layersPanel.ContentPanel.Controls.Add(checkAllPanel);
// Add panels in reverse order (Dock.Top stacks top-down)
+ Controls.Add(breakoffsPanel);
Controls.Add(bendLinesPanel);
Controls.Add(lineTypesPanel);
Controls.Add(colorsPanel);
@@ -339,6 +402,35 @@ namespace OpenNest.Controls
bendAddLink.Text = active ? "Cancel (Esc)" : "Add Bend Line";
bendAddLink.LinkColor = active ? Color.OrangeRed : Color.Empty;
}
+
+ public void SetBreakoffPickMode(bool active)
+ {
+ breakoffAddLink.Text = active ? "Cancel (Esc)" : "Add Breakoff";
+ breakoffAddLink.LinkColor = active ? Color.OrangeRed : Color.Empty;
+ }
+
+ /// Shows one line per breakoff with selected, without raising selection events.
+ public void LoadBreakoffs(IReadOnlyList labels, int selected)
+ {
+ var added = labels.Count > breakoffsList.Items.Count;
+ loadingBreakoffs = true;
+ try
+ {
+ breakoffsList.BeginUpdate();
+ breakoffsList.Items.Clear();
+ foreach (var label in labels)
+ breakoffsList.Items.Add(label);
+ breakoffsList.SelectedIndex = selected >= 0 && selected < labels.Count ? selected : -1;
+ breakoffsList.EndUpdate();
+ }
+ finally
+ {
+ loadingBreakoffs = false;
+ }
+ breakoffsPanel.HeaderText = $"Breakoffs ({labels.Count})";
+ if (added)
+ breakoffsPanel.IsExpanded = true;
+ }
}
public class ColorItem
diff --git a/OpenNest/Forms/BreakoffDialog.cs b/OpenNest/Forms/BreakoffDialog.cs
new file mode 100644
index 00000000..4e2f2fb0
--- /dev/null
+++ b/OpenNest/Forms/BreakoffDialog.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Drawing;
+using System.Windows.Forms;
+using OpenNest.Breakoffs;
+
+namespace OpenNest.Forms
+{
+ public class BreakoffDialog : Form
+ {
+ private readonly NumericUpDown numTabs;
+ private readonly NumericUpDown numTabWidth;
+ private readonly NumericUpDown numSlotWidth;
+
+ public BreakoffDialog()
+ {
+ Text = "Breakoff Tabs";
+ FormBorderStyle = FormBorderStyle.FixedDialog;
+ MaximizeBox = false;
+ MinimizeBox = false;
+ ShowInTaskbar = false;
+ StartPosition = FormStartPosition.CenterParent;
+ Size = new Size(300, 230);
+
+ var font = new Font("Segoe UI", 9f);
+
+ 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);
+
+ Controls.Add(new Label
+ {
+ Text = "Tabs at both ends are included. Slots are cut into the breakoff between tabs.",
+ Location = new Point(12, 108),
+ Size = new Size(260, 34),
+ ForeColor = Color.DimGray,
+ Font = font,
+ });
+
+ var btnOk = new Button
+ {
+ Text = "OK",
+ DialogResult = DialogResult.OK,
+ Location = new Point(102, 150),
+ Size = new Size(80, 28),
+ Font = font,
+ };
+ var btnCancel = new Button
+ {
+ Text = "Cancel",
+ DialogResult = DialogResult.Cancel,
+ Location = new Point(190, 150),
+ Size = new Size(80, 28),
+ Font = font,
+ };
+ AcceptButton = btnOk;
+ CancelButton = btnCancel;
+ Controls.AddRange(new Control[] { btnOk, btnCancel });
+
+ NumericUpDown AddRow(string label, int y, decimal min, decimal max, int decimals, decimal step, decimal value)
+ {
+ Controls.Add(new Label { Text = label, Location = new Point(12, y + 3), AutoSize = true, Font = font });
+ var input = new NumericUpDown
+ {
+ Location = new Point(140, y),
+ Width = 130,
+ Font = font,
+ Minimum = min,
+ Maximum = max,
+ DecimalPlaces = decimals,
+ Increment = step,
+ Value = value,
+ };
+ Controls.Add(input);
+ return input;
+ }
+ }
+
+ public int TabCount => (int)numTabs.Value;
+
+ public double TabWidth => (double)numTabWidth.Value;
+
+ public double SlotWidth => (double)numSlotWidth.Value;
+
+ 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);
+ }
+
+ private static decimal Clamp(NumericUpDown input, double value) =>
+ System.Math.Clamp((decimal)value, input.Minimum, input.Maximum);
+ }
+}
diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs
index 1ba9abcf..61d67b7f 100644
--- a/OpenNest/Forms/CadConverterForm.cs
+++ b/OpenNest/Forms/CadConverterForm.cs
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Bending;
+using OpenNest.Breakoffs;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Converters;
@@ -49,6 +50,12 @@ namespace OpenNest.Forms
filterPanel.BendLineRemoved += OnBendLineRemoved;
filterPanel.BendLineEdited += OnBendLineEdited;
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
+ filterPanel.AddBreakoffClicked += OnAddBreakoffClicked;
+ filterPanel.BreakoffSelected += OnBreakoffSelected;
+ filterPanel.BreakoffEdited += OnBreakoffEdited;
+ filterPanel.BreakoffRemoved += OnBreakoffRemoved;
+ entityView1.BreakoffEdgePicked += OnBreakoffEdgePicked;
+ entityView1.TabDragged += OnTabDragged;
entityView1.LinePicked += OnLinePicked;
entityView1.PickCancelled += OnPickCancelled;
entityView1.TextConvertRequested += OnTextConvertRequested;
@@ -166,6 +173,12 @@ namespace OpenNest.Forms
entityView1.IsPickingBendLine = false;
filterPanel.SetPickMode(false);
}
+ if (entityView1.IsPickingBreakoff)
+ {
+ entityView1.IsPickingBreakoff = false;
+ filterPanel.SetBreakoffPickMode(false);
+ }
+ entityView1.SelectedBreakoffIndex = -1;
entityView1.OriginalEntities = chkShowOriginal.Checked ? item.OriginalEntities : null;
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
@@ -179,6 +192,7 @@ namespace OpenNest.Forms
ReHideSuppressedEntities(item);
filterPanel.LoadItem(item.Entities, item.Bends);
+ RefreshBreakoffs(item);
numQuantity.Value = item.Quantity;
txtCustomer.Text = item.Customer ?? "";
@@ -259,7 +273,7 @@ namespace OpenNest.Forms
filterPanel.ApplyFilters(item.Entities);
ReHidePromotedEntities(item.Bends);
SyncSuppressedState(item);
- entityView1.Invalidate();
+ RefreshBreakoffs(item);
staleProgram = true;
}
@@ -287,7 +301,8 @@ namespace OpenNest.Forms
return;
}
- var normalized = ShapeProfile.NormalizeEntities(entities);
+ var cut = BreakoffBuilder.Apply(entities, item.Breakoffs, out _);
+ var normalized = ShapeProfile.NormalizeEntities(cut);
programEditor.LoadEntities(normalized);
staleProgram = false;
}
@@ -464,6 +479,8 @@ namespace OpenNest.Forms
private void OnAddBendLineClicked(object sender, EventArgs e)
{
+ entityView1.IsPickingBreakoff = false;
+ filterPanel.SetBreakoffPickMode(false);
var active = !entityView1.IsPickingBendLine;
entityView1.IsPickingBendLine = active;
filterPanel.SetPickMode(active);
@@ -503,8 +520,162 @@ namespace OpenNest.Forms
{
entityView1.IsPickingBendLine = false;
filterPanel.SetPickMode(false);
+ entityView1.IsPickingBreakoff = false;
+ filterPanel.SetBreakoffPickMode(false);
}
+ private static List VisibleEntities(FileListItem item) =>
+ item.Entities.Where(en => en.Layer.IsVisible && en.IsVisible).ToList();
+
+ private void RefreshBreakoffs(FileListItem item)
+ {
+ var visible = VisibleEntities(item);
+ var allEdges = item.Breakoffs.SelectMany(b => b.EdgeIds).ToList();
+ var layouts = item.Breakoffs.Select(b => BreakoffBuilder.Layout(visible, b, allEdges)).ToList();
+ entityView1.BreakoffLayouts = layouts;
+ filterPanel.LoadBreakoffs(layouts
+ .Select((l, i) => l.IsValid
+ ? $"Breakoff {i + 1}: {l.TabCenters.Count} tabs"
+ : $"Breakoff {i + 1} (not applied): {l.Error}")
+ .ToList(), entityView1.SelectedBreakoffIndex);
+ entityView1.Invalidate();
+ }
+
+ private void OnAddBreakoffClicked(object sender, EventArgs e)
+ {
+ entityView1.IsPickingBendLine = false;
+ filterPanel.SetPickMode(false);
+ var active = !entityView1.IsPickingBreakoff;
+ entityView1.IsPickingBreakoff = active;
+ filterPanel.SetBreakoffPickMode(active);
+ }
+
+ private void OnBreakoffEdgePicked(object sender, Entity entity)
+ {
+ entityView1.IsPickingBreakoff = false;
+ filterPanel.SetBreakoffPickMode(false);
+ var item = CurrentItem;
+ if (item == null)
+ return;
+
+ var visible = VisibleEntities(item);
+ 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.");
+ return;
+ }
+
+ var breakoff = new Breakoff { EdgeIds = chain.Select(en => en.Id).ToList() };
+ 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.");
+ return;
+ }
+
+ using var dialog = new BreakoffDialog();
+ if (!AskBreakoffSettings(dialog))
+ return;
+ if (!TryConfigure(item, breakoff, dialog, keepTabs: false))
+ return;
+
+ item.Breakoffs.Add(breakoff);
+ entityView1.SelectedBreakoffIndex = item.Breakoffs.Count - 1;
+ RefreshBreakoffs(item);
+ staleProgram = true;
+ }
+
+ private void OnBreakoffEdited(object sender, int index)
+ {
+ var item = CurrentItem;
+ if (item == null || index < 0 || index >= item.Breakoffs.Count)
+ return;
+
+ var current = item.Breakoffs[index];
+ using var dialog = new BreakoffDialog();
+ dialog.LoadBreakoff(current);
+ if (!AskBreakoffSettings(dialog))
+ return;
+
+ var edited = current.Clone();
+ var keepTabs = dialog.TabCount == current.TabCenters.Count
+ && Math.Tolerance.IsEqualTo(dialog.TabWidth, current.TabWidth);
+ if (!TryConfigure(item, edited, dialog, keepTabs))
+ return;
+
+ item.Breakoffs[index] = edited;
+ RefreshBreakoffs(item);
+ staleProgram = true;
+ }
+
+ // Applies the dialog's settings, spacing the tabs evenly unless asked to keep them, and
+ // refuses settings that do not resolve.
+ private bool TryConfigure(FileListItem item, Breakoff breakoff, BreakoffDialog dialog, bool keepTabs)
+ {
+ var visible = VisibleEntities(item);
+ var others = item.Breakoffs.Where(b => !b.EdgeIds.SequenceEqual(breakoff.EdgeIds))
+ .SelectMany(b => b.EdgeIds).ToList();
+
+ breakoff.TabWidth = dialog.TabWidth;
+ breakoff.SlotWidth = dialog.SlotWidth;
+ if (!keepTabs)
+ {
+ var probe = breakoff.Clone();
+ probe.TabCenters.Clear();
+ var length = BreakoffBuilder.Layout(visible, probe, others).Length;
+ breakoff.TabCenters = BreakoffBuilder.EvenTabCenters(length, dialog.TabCount, dialog.TabWidth);
+ }
+
+ var layout = BreakoffBuilder.Layout(visible, breakoff, others);
+ if (layout.IsValid)
+ return true;
+ ShowBreakoffMessage("Those settings do not fit this breakoff: " + layout.Error);
+ return false;
+ }
+
+ private void OnBreakoffRemoved(object sender, int index)
+ {
+ var item = CurrentItem;
+ if (item == null || index < 0 || index >= item.Breakoffs.Count)
+ return;
+
+ item.Breakoffs.RemoveAt(index);
+ entityView1.SelectedBreakoffIndex = -1;
+ RefreshBreakoffs(item);
+ staleProgram = true;
+ }
+
+ private void OnBreakoffSelected(object sender, int index)
+ {
+ entityView1.SelectedBreakoffIndex = index;
+ entityView1.Invalidate();
+ }
+
+ private void OnTabDragged(object sender, TabDragEventArgs e)
+ {
+ var item = CurrentItem;
+ if (item == null || e.Breakoff >= item.Breakoffs.Count)
+ return;
+
+ var breakoff = item.Breakoffs[e.Breakoff];
+ if (e.Tab >= breakoff.TabCenters.Count)
+ return;
+ breakoff.TabCenters[e.Tab] = e.Center;
+ entityView1.SelectedBreakoffIndex = e.Breakoff;
+ RefreshBreakoffs(item);
+ staleProgram = true;
+ }
+
+ internal virtual bool AskBreakoffSettings(BreakoffDialog dialog) =>
+ dialog.ShowDialog(this) == DialogResult.OK;
+
+ internal virtual void ShowBreakoffMessage(string message) =>
+ MessageBox.Show(this, message, "Breakoff", MessageBoxButtons.OK, MessageBoxIcon.Information);
+
private void OnTextConvertRequested(object sender, Controls.CadText text)
{
var item = CurrentItem;
@@ -815,6 +986,7 @@ namespace OpenNest.Forms
Quantity = drawing.Quantity.Required,
Customer = drawing.Customer ?? string.Empty,
Bends = drawing.Bends?.ToList() ?? new List(),
+ Breakoffs = drawing.Breakoffs?.Select(b => b.Clone()).ToList() ?? new List(),
SuppressedEntityIds =
drawing.SuppressedEntityIds.Count > 0
? new HashSet(drawing.SuppressedEntityIds)
@@ -864,7 +1036,8 @@ namespace OpenNest.Forms
result.Bends,
item.Quantity,
item.Customer,
- editedProgram
+ editedProgram,
+ item.Breakoffs
);
drawings.Add(drawing);
diff --git a/docs/breakoffs.md b/docs/breakoffs.md
new file mode 100644
index 00000000..29e77869
--- /dev/null
+++ b/docs/breakoffs.md
@@ -0,0 +1,43 @@
+# 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.
+
+## 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).
+
+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.
+
+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.
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index c9502581..a2b07b8e 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -177,6 +177,16 @@ A placement may write `drawingPinsOff: true` to ignore its drawing's pins on tha
part only; it is omitted when false. Any value other than a boolean loads as
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
+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.
+
## Verification and limits
The automated tests cover save/reopen/re-save, rotated/manual/locked parts,