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