feat(cad): cut breakoff joining lines as tabbed open break cuts

Breakoffs are now picked by their joining line, default to the smaller side as scrap
(flippable), and cut that line between tabs on a new Break layer instead of slots.
Plan Cutting and Assign Lead-ins enter each cut from the scrap with the nest's internal
lead-in, or pierce on the line without one; checks treat break cuts as intentional open
cuts that are not material.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-17 17:46:35 -04:00
1 parent 6fd14c5452
commit 6f555345e3
35 files changed
+1066 -500

No files matched your search

+1 -1
View File
@@ -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)
+13 -15
View File
@@ -4,32 +4,30 @@ using System.Collections.Generic;
namespace OpenNest.Breakoffs
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Source entity IDs of the breakoff's outside edges.</summary>
/// <summary>Source entity IDs of the joining line between part and scrap.</summary>
public List<Guid> EdgeIds { get; set; } = new();
/// <summary>Uncut length of each tab along the break line.</summary>
/// <summary>True when the larger side is the scrap instead of the smaller one.</summary>
public bool Flip { get; set; }
/// <summary>Uncut length of each tab along the joining line.</summary>
public double TabWidth { get; set; } = DefaultTabWidth;
/// <summary>How far each slot reaches into the breakoff from the break line.</summary>
public double SlotWidth { get; set; } = DefaultSlotWidth;
/// <summary>
/// Tab centers as distances along the break line from its start, ascending. Empty means
/// <see cref="DefaultTabCount"/> 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 <see cref="DefaultTabCount"/> evenly spaced.
/// </summary>
public List<double> TabCenters { get; set; } = new();
@@ -37,8 +35,8 @@ namespace OpenNest.Breakoffs
new()
{
EdgeIds = new List<Guid>(EdgeIds),
Flip = Flip,
TabWidth = TabWidth,
SlotWidth = SlotWidth,
TabCenters = new List<double>(TabCenters),
};
}
+198 -205
View File
@@ -7,21 +7,22 @@ using OpenNest.Math;
namespace OpenNest.Breakoffs
{
/// <summary>
/// Resolves and applies <see cref="Breakoff"/>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 <see cref="Breakoff"/>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.
/// </summary>
public static class BreakoffBuilder
{
/// <summary>How close the breakoff's ends must be to the outline.</summary>
/// <summary>How close the joining line's ends must be to the outline.</summary>
public const double JoinTolerance = 0.001;
private const double ArcTolerance = 0.0005;
/// <summary>
/// <paramref name="entities"/> 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 <paramref name="errors"/>.
/// <paramref name="entities"/> 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
/// <paramref name="errors"/>.
/// </summary>
public static List<Entity> Apply(IReadOnlyList<Entity> entities, IReadOnlyList<Breakoff> breakoffs,
out List<string> errors)
@@ -43,43 +44,40 @@ namespace OpenNest.Breakoffs
var replaced = new HashSet<Entity>(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;
}
/// <summary>
/// Resolves <paramref name="breakoff"/> against <paramref name="entities"/> (the entities that
/// will be cut). <paramref name="otherEdgeIds"/> are edges of other breakoffs on the same
/// drawing, which are ignored while finding the outline.
/// will be cut). <paramref name="otherEdgeIds"/> are joining lines of other breakoffs on the
/// same drawing, which are ignored while finding the outline.
/// </summary>
public static BreakoffLayout Layout(IReadOnlyList<Entity> entities, Breakoff breakoff,
IEnumerable<Guid> 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<Entity> 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<IReadOnlyList<Entity>>();
var cuts = new List<IReadOnlyList<Entity>>();
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
}
/// <summary>
/// The open chain of visible lines and arcs through <paramref name="picked"/>, grown until it
/// reaches a point where it does not continue as a single path (such as where it meets an
/// outline). Empty when <paramref name="picked"/> cannot be a breakoff edge.
/// The chain of visible lines and arcs through <paramref name="picked"/>, 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 <paramref name="picked"/> cannot be a joining line.
/// </summary>
public static List<Entity> FindEdgeChain(IReadOnlyList<Entity> entities, Entity picked)
{
@@ -236,37 +224,150 @@ namespace OpenNest.Breakoffs
}
}
private static BreakoffLayout Fail(string error) => new(error);
private static List<Entity> SnapEnds(List<Entity> edges, Vector start, Vector end)
/// <summary>
/// Splits each visible line or arc of <paramref name="entities"/> 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.
/// </summary>
public static Dictionary<Guid, List<Guid>> SplitAtJunctions(List<Entity> entities)
{
var result = new List<Entity>(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<Guid, List<Guid>>();
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<double>();
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<double> { 0 };
bounds.AddRange(cuts);
bounds.Add(host.Length);
var pieces = new List<Entity>();
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<Entity> WithoutDanglingEnds(List<Entity> 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<int>();
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<int>[sizes.Count];
for (var i = 0; i < entities.Count; i++)
for (var side = 0; side < 2; side++)
(members[clusterOf[i, side]] ??= new List<int>()).Add(i);
var removed = new bool[entities.Count];
var queue = new Queue<int>(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<Entity> CloseExactly(IReadOnlyList<Entity> loop)
{
var result = new List<Entity>();
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<Entity> 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<Entity> breakLine, double length, IReadOnlyList<Vector> 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<Entity> line, double length, IReadOnlyList<Vector> 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<Entity> BuildSlot(IReadOnlyList<Entity> breakLine, double from, double to, int side,
double depth, double clearance, IReadOnlyList<Entity> edges, IReadOnlyList<Vector> 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<double> { 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<Vector>();
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<Entity>(near);
var path = new List<Vector> { 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<Vector> DropCollinear(List<Vector> points)
{
if (points.Count < 3)
return points;
var result = new List<Vector> { 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;
}
}
}
+15 -12
View File
@@ -4,9 +4,8 @@ using OpenNest.Geometry;
namespace OpenNest.Breakoffs
{
/// <summary>
/// A <see cref="Breakoff"/> resolved against a drawing's entities: the break line, the merged
/// outline and the slots that cut the break line between tabs. When <see cref="Error"/> is set
/// nothing else is meaningful and the breakoff is not applied.
/// A <see cref="Breakoff"/> resolved against a drawing's entities. When <see cref="Error"/> is
/// set nothing else is meaningful and the breakoff is not applied.
/// </summary>
public sealed class BreakoffLayout
{
@@ -18,35 +17,39 @@ namespace OpenNest.Breakoffs
public bool IsValid => Error == null;
/// <summary>The outline section shared by the part and the breakoff, in travel order from its start.</summary>
/// <summary>The joining line in travel order from its start.</summary>
public IReadOnlyList<Entity> BreakLine { get; internal set; }
public double Length { get; internal set; }
/// <summary>The part outline with the break line replaced by the breakoff's edges.</summary>
/// <summary>The closed outline around part and scrap, with its joints made exact.</summary>
public IReadOnlyList<Entity> Outline { get; internal set; }
/// <summary>One closed contour per cut section of the break line.</summary>
public IReadOnlyList<IReadOnlyList<Entity>> Slots { get; internal set; }
/// <summary>The scrap side's closed boundary (part of the outline plus the joining line).</summary>
public IReadOnlyList<Entity> Scrap { get; internal set; }
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<IReadOnlyList<Entity>> Cuts { get; internal set; }
/// <summary>Tab centers used, as distances along <see cref="BreakLine"/>.</summary>
public IReadOnlyList<double> TabCenters { get; internal set; }
public double TabWidth { get; internal set; }
/// <summary>Entities the outline and slots replace: the original outline and breakoff edges.</summary>
/// <summary>Entities the outline and cuts replace: the original outline and joining line.</summary>
internal IReadOnlyList<Entity> ReplacedEntities { get; set; }
public Vector PointAt(double distance) => BreakoffPath.PointAt(BreakLine, distance);
public Vector TangentAt(double distance) => BreakoffPath.TangentAt(BreakLine, distance);
/// <summary>The distance along the break line closest to <paramref name="point"/>.</summary>
/// <summary>The distance along the joining line closest to <paramref name="point"/>.</summary>
public double DistanceOf(Vector point) => BreakoffPath.DistanceOf(BreakLine, point);
/// <summary>
/// <paramref name="center"/> for tab <paramref name="index"/> 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.
/// </summary>
public double ClampTabCenter(int index, double center)
{
@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Which motions of an execution cut a break line: its lead-in and its break moves.</summary>
internal static class BreakCutMotions
{
/// <summary>True for every <see cref="LayerType.Break"/> motion and the lead-in run directly before one.</summary>
internal static bool[] Mask(IReadOnlyList<ExecutionMotion> 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;
}
}
@@ -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;
@@ -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 <paramref name="permittedJoint"/> is allowed. Exceeding the budget throws.
/// </summary>
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;
}
}
@@ -23,23 +23,27 @@ public sealed class PreparedContours
private readonly Shape[] shapes;
private readonly CuttingParameters parameters;
private readonly List<Entity> 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<Shape> 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<Entity> scribes, CuttingParameters parameters)
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> 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<CutPinResolution> 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<CutPinResolution>();
@@ -134,12 +138,29 @@ public sealed class PreparedContours
var geometryProgram = CopyForGeometry(program, token);
var contours = new List<Shape>();
var scribes = new List<Entity>();
var breakCuts = new List<Shape>();
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<ContourChoice> 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());
}
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
public int BreakCutCount => breakCuts.Count;
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
// Build expected-emission metadata independently of the selected payload BEFORE
@@ -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();
@@ -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 <see cref="Parameters"/>.</param>
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
IReadOnlyList<CuttingParameters> contourParameters = null)
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> 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<Entity> 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<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> 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;
}
/// <summary>
/// Removes break-cut entities from <paramref name="entities"/> (in program order) and
/// returns them as travel-ordered open chains, one per uninterrupted run.
/// </summary>
internal static List<Shape> TakeBreakCuts(List<Entity> entities)
{
var cuts = new List<Shape>();
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<Shape> cuts, IReadOnlyList<Shape> 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));
}
}
/// <summary>
/// Where a break cut (travelling with its scrap on the left) is entered: the end with more
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
/// whether that is the cut's far end.
/// </summary>
public static (Vector Entry, Vector ScrapDirection, bool FromEnd) BreakCutEntry(Shape cut,
IReadOnlyList<Shape> 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<Shape> BreakCutContours(ShapeProfile profile) =>
new List<Shape>(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<Shape> contours)
{
const double reach = 1e6;
var ray = new Line(point, point + direction * reach);
var nearest = reach;
foreach (var contour in contours ?? Array.Empty<Shape>())
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];
+8 -1
View File
@@ -1,4 +1,4 @@
namespace OpenNest.CNC
namespace OpenNest.CNC
{
public enum LayerType
{
@@ -7,5 +7,12 @@
Cut,
Leadin,
Leadout,
/// <summary>
/// An intentionally open cut through part material, left uncut at tabs (a breakoff's
/// break line). It cuts like <see cref="Cut"/> but is not a material boundary; the scrap
/// it may be pierced from lies to the left of travel.
/// </summary>
Break,
}
}
+29 -2
View File
@@ -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<Entity> 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<Shape>();
var contours = new List<Entity>();
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;
}
}
+4 -1
View File
@@ -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());
}
+9 -2
View File
@@ -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<Entity> NormalizeEntities(IEnumerable<Entity> 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)
+6 -3
View File
@@ -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 };
/// <summary>
/// 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.
/// </summary>
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe;
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break;
}
}
@@ -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;
+18 -2
View File
@@ -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<OpenNest.CNC.Motion>()
.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()
{
+2
View File
@@ -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);
}
+4 -1
View File
@@ -214,8 +214,11 @@ namespace OpenNest.IO
public record BreakoffDto
{
public List<Guid> 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<double> TabCenters { get; init; } = new();
}
+1 -2
View File
@@ -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(),
});
}
+4 -1
View File
@@ -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;
}
+8
View File
@@ -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;
+254 -154
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class BreakoffBuilderTests
{
private const double W = Breakoff.DefaultTabWidth;
private static List<Entity> Part(bool leftBreakoff = false)
// The outline drawn around part and scrap, with a separate joining line across it.
private static (List<Entity> Entities, Line Join) Chord()
{
var join = new Line(10, 1, 10, 5);
var entities = new List<Entity>
{
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<Entity> Entities, Line Join) PartAndScrapEdges()
{
var join = new Line(10, 1, 10, 5);
var entities = new List<Entity>
{
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<Entity> 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<Entity>
{
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<Entity> { 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<Breakoff> { 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<Entity> { 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<Entity>
{
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<Bend>(), 1, null, null, breakoffs);
private static (double From, double To) OnBreakLine(IReadOnlyList<Entity> slot)
private static (PlannedPart Planned, Part Part) Plan(Drawing drawing, CuttingParameters parameters)
{
var ys = slot.OfType<Line>()
.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<ExecutionMotion> Moves(Program program, Vector? location = null) =>
ExecutionMotionReader.Read(program, location ?? Vector.Zero, null, default).Motions;
private static (double Low, double High) Span(IReadOnlyList<Entity> cut)
{
var ys = cut.SelectMany(e => new[] { Start(e).Y, End(e).Y }).ToList();
return (ys.Min(), ys.Max());
}
private static IEnumerable<Vector> 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<Entity> 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<int> Enumerate(this PreparedContours prepared) => Enumerable.Range(0, prepared.Count);
}
@@ -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<Entity>), typeof(OpenNest.CNC.CuttingStrategy.CuttingParameters)], null);
null, [typeof(Shape[]), typeof(List<Entity>), typeof(List<Shape>),
typeof(OpenNest.CNC.CuttingStrategy.CuttingParameters)], null);
Assert.NotNull(constructor);
var prepared = (PreparedContours)constructor.Invoke([
new[] { shape }, new List<Entity>(), ExplicitContourTests.Parameters()]);
new[] { shape }, new List<Entity>(), new List<Shape>(), ExplicitContourTests.Parameters()]);
var near = prepared.AutomaticEntryCandidatesWithFallbacks(0)
.Where(c => c.Kind == AutomaticEntryKind.NearCorner).ToList();
Assert.NotEmpty(near);
@@ -14,12 +14,12 @@ namespace OpenNest.WinForms.Tests.Forms;
/// <summary>
/// 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.
/// </summary>
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<FileListControl>(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<Bend>(), 1, null, null,
[new Breakoff { EdgeIds = edges.Select(e => e.Id).ToList() }]);
@@ -91,8 +101,8 @@ public class CadConverterBreakoffTests
{
var entities = new List<Entity>
{
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 };
+35 -13
View File
@@ -39,7 +39,12 @@ namespace OpenNest.Controls
public int SelectedBreakoffIndex { get; set; } = -1;
public event EventHandler<Entity> BreakoffEdgePicked;
/// <summary>Where each break cut is entered and the direction into its scrap, in world coordinates.</summary>
public IReadOnlyList<(Vector Entry, Vector ScrapDirection)> BreakCutEntries { get; set; } =
Array.Empty<(Vector, Vector)>();
/// <summary>A line or arc was clicked while picking a joining line; the point is where.</summary>
public event EventHandler<(Entity Entity, Vector Point)> BreakoffEdgePicked;
public event EventHandler<TabDragEventArgs> 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<Entity> 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<Entity> path)
{
var points = new List<PointF>();
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))))
+8
View File
@@ -36,6 +36,7 @@ namespace OpenNest.Controls
public event EventHandler<int> BreakoffSelected;
public event EventHandler<int> BreakoffEdited;
public event EventHandler<int> BreakoffRemoved;
public event EventHandler<int> 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);
+14 -7
View File
@@ -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) =>
+77 -6
View File
@@ -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<Entity> visible, List<Breakoff> breakoffs,
List<BreakoffLayout> 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;
+13 -1
View File
@@ -54,6 +54,14 @@ namespace OpenNest
etchPath = GetLayerPath(pgm, origin, layer => layer == LayerType.Scribe);
}
/// <summary>
/// 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.
/// </summary>
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<LayerType, bool> 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;
+20 -1
View File
@@ -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; }
/// <summary>The material fill when it differs from <c>Path</c> (break cuts are drawn, not filled).</summary>
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;
@@ -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;
+54 -33
View File
@@ -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.
+6
View File
@@ -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
+10 -5
View File
@@ -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