feat(cad): tab breakoff scrap to the part in the CAD Converter

Forming backgauge tabs drawn as open cuts came off the sheet loose and could not be
planned. A breakoff merges the scrap into the outline and cuts the shared edge as
closed slots between tabs, so it stays attached and every pierce lands in scrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-17 15:42:37 -04:00
1 parent ca09c33c38
commit 6fd14c5452
21 files changed
+1965 -8

No files matched your search

+1
View File
@@ -58,6 +58,7 @@ Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md)
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
- [Lead-in placement](docs/geometry/lead-in-placement.md)
- [Breakoffs](docs/breakoffs.md): CAD Converter tabbed scrap, cut as closed slots
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md)
+45
View File
@@ -0,0 +1,45 @@
using System;
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.
/// </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>
public List<Guid> EdgeIds { get; set; } = new();
/// <summary>Uncut length of each tab along the break 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.
/// </summary>
public List<double> TabCenters { get; set; } = new();
public Breakoff Clone() =>
new()
{
EdgeIds = new List<Guid>(EdgeIds),
TabWidth = TabWidth,
SlotWidth = SlotWidth,
TabCenters = new List<double>(TabCenters),
};
}
}
+407
View File
@@ -0,0 +1,407 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
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.
/// </summary>
public static class BreakoffBuilder
{
/// <summary>How close the breakoff'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"/>.
/// </summary>
public static List<Entity> Apply(IReadOnlyList<Entity> entities, IReadOnlyList<Breakoff> breakoffs,
out List<string> errors)
{
errors = new List<string>();
var working = entities.ToList();
if (breakoffs == null || breakoffs.Count == 0)
return working;
var allEdges = breakoffs.SelectMany(b => b.EdgeIds).ToHashSet();
for (var i = 0; i < breakoffs.Count; i++)
{
var layout = Layout(working, breakoffs[i], allEdges);
if (!layout.IsValid)
{
errors.Add($"Breakoff {i + 1}: {layout.Error}");
continue;
}
var replaced = new HashSet<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()));
}
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.
/// </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.");
var edgeIds = breakoff.EdgeIds.ToHashSet();
var edgeSources = entities.Where(e => edgeIds.Contains(e.Id)).ToList();
if (edgeIds.Count == 0 || edgeSources.Count != edgeIds.Count)
return Fail("Some of its edges are hidden or missing.");
var edges = BreakoffPath.ChainOpen(edgeSources, JoinTolerance);
if (edges == null)
return Fail("Its edges must form one open chain.");
// Measure tabs from a fixed end so positions survive re-chaining.
var a = BreakoffPath.Start(edges[0]);
var b = BreakoffPath.End(edges[^1]);
if (b.X < a.X - JoinTolerance || (System.Math.Abs(b.X - a.X) <= JoinTolerance && b.Y < a.Y))
{
edges = BreakoffPath.Reversed(edges);
(a, b) = (b, a);
}
var excluded = new HashSet<Guid>(otherEdgeIds ?? Enumerable.Empty<Guid>());
excluded.UnionWith(edgeIds);
var sources = new Dictionary<Entity, Entity>(ReferenceEqualityComparer.Instance);
var candidates = new List<Entity>();
foreach (var entity in entities)
{
if (excluded.Contains(entity.Id) || IsMark(entity) || entity is not (Line or Arc))
continue;
var copy = BreakoffPath.Copy(entity);
sources[copy] = entity;
candidates.Add(copy);
}
Shape outline = null;
var outlineArea = 0.0;
foreach (var shape in ShapeBuilder.GetShapes(candidates))
{
if (shape.Entities.Count < 2 || !shape.IsClosed()
|| shape.ClosestPointTo(a).DistanceTo(a) > JoinTolerance
|| shape.ClosestPointTo(b).DistanceTo(b) > JoinTolerance)
continue;
var area = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(shape.Entities, ArcTolerance)));
if (outline == null || area > outlineArea)
{
outline = shape;
outlineArea = area;
}
}
if (outline == null)
return Fail("Both ends must touch a closed outline.");
var loop = outline.Entities;
var total = BreakoffPath.Length(loop);
var atA = BreakoffPath.DistanceOf(loop, a);
var rotated = BreakoffPath.Sub(loop, atA, total).Concat(BreakoffPath.Sub(loop, 0, atA)).ToList();
var atB = BreakoffPath.DistanceOf(rotated, b);
if (atB <= JoinTolerance || atB >= total - JoinTolerance)
return Fail("Its ends must touch the outline at two different points.");
var toB = BreakoffPath.Sub(rotated, 0, atB);
var backToA = BreakoffPath.Sub(rotated, atB, total);
// The planner closes contours far tighter than the join tolerance, so the edges must
// end exactly where the outline was split.
edges = SnapEnds(edges, BreakoffPath.Start(toB[0]), BreakoffPath.Start(backToA[0]));
var viaBack = edges.Concat(backToA).ToList();
var viaTo = edges.Concat(BreakoffPath.Reversed(toB)).ToList();
var viaBackArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(viaBack, ArcTolerance)));
var viaToArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(viaTo, ArcTolerance)));
// The breakoff region is the smaller loop; the merged outline is the other one.
List<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;
}
if (System.Math.Abs(mergedArea - (outlineArea + regionArea)) > 0.01 * mergedArea)
return Fail("It must sit outside the part outline.");
var length = BreakoffPath.Length(breakLine);
var tabWidth = breakoff.TabWidth;
var centers = breakoff.TabCenters.Count == 0
? EvenTabCenters(length, Breakoff.DefaultTabCount, tabWidth)
: breakoff.TabCenters.OrderBy(c => c).ToList();
if (centers.Count < Breakoff.MinTabCount || length <= tabWidth * centers.Count)
return Fail("The break line is too short for its tabs.");
if (centers[0] < tabWidth / 2 - JoinTolerance || centers[^1] > length - tabWidth / 2 + JoinTolerance)
return Fail("A tab runs off the break line; space the tabs evenly again.");
for (var i = 1; i < centers.Count; i++)
if (centers[i] - centers[i - 1] <= tabWidth + JoinTolerance)
return Fail("Two tabs overlap; space the tabs evenly again.");
var regionPoints = BreakoffPath.ToPoints(region, ArcTolerance);
var side = SideOf(breakLine, length, regionPoints, System.Math.Min(breakoff.SlotWidth, length) * 0.05);
if (side == 0)
return Fail("Could not tell which side of the break line the breakoff is on.");
var slots = new List<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 replaced = outline.Entities.Select(e => sources[e]).ToList();
replaced.AddRange(edgeSources);
return new BreakoffLayout
{
BreakLine = breakLine,
Length = length,
Outline = merged,
Slots = slots,
TabCenters = centers,
TabWidth = tabWidth,
ReplacedEntities = replaced,
};
}
/// <summary><paramref name="count"/> tab centers spaced evenly with the first and last at the ends.</summary>
public static List<double> EvenTabCenters(double length, int count, double tabWidth)
{
count = System.Math.Max(count, Breakoff.MinTabCount);
var half = tabWidth / 2;
var span = length - tabWidth;
return Enumerable.Range(0, count).Select(i => half + span * i / (count - 1)).ToList();
}
/// <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.
/// </summary>
public static List<Entity> FindEdgeChain(IReadOnlyList<Entity> entities, Entity picked)
{
var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
.ToList();
if (!pool.Contains(picked))
return new List<Entity>();
var chain = new List<Entity> { picked };
Grow(BreakoffPath.End(picked));
Grow(BreakoffPath.Start(picked));
return chain;
void Grow(Vector tip)
{
while (true)
{
var touching = pool.Where(e => !chain.Contains(e)
&& (BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
|| BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance)).ToList();
if (touching.Count != 1)
return;
var next = touching[0];
chain.Add(next);
tip = BreakoffPath.Start(next).DistanceTo(tip) <= JoinTolerance
? BreakoffPath.End(next)
: BreakoffPath.Start(next);
}
}
}
private static BreakoffLayout Fail(string error) => new(error);
private static List<Entity> SnapEnds(List<Entity> edges, Vector start, Vector end)
{
var result = new List<Entity>(edges);
var first = result[0];
if (BreakoffPath.Start(first).DistanceTo(start) > 0)
{
if (first is Line line)
line.StartPoint = start;
else
result.Insert(0, Joint(first, start, BreakoffPath.Start(first)));
}
var last = result[^1];
if (BreakoffPath.End(last).DistanceTo(end) > 0)
{
if (last is Line line)
line.EndPoint = end;
else
result.Add(Joint(last, BreakoffPath.End(last), end));
}
return result;
}
private static Line Joint(Entity style, Vector from, Vector to) =>
new(from, to) { Layer = style.Layer, Color = style.Color, LineTypeName = style.LineTypeName };
private static bool IsMark(Entity e) =>
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Layer?.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase);
private static Vector Normal(IReadOnlyList<Entity> path, double length, double distance)
{
const double nudge = 1e-6;
var before = BreakoffPath.TangentAt(path, System.Math.Max(distance - nudge, 0));
var after = BreakoffPath.TangentAt(path, System.Math.Min(distance + nudge, length));
var t = new Vector(before.X + after.X, before.Y + after.Y);
var size = System.Math.Sqrt(t.X * t.X + t.Y * t.Y);
if (size <= Tolerance.Epsilon)
t = after;
else
t = new Vector(t.X / size, t.Y / size);
return new Vector(-t.Y, t.X);
}
// +1 when the breakoff lies left of the break line's travel, -1 when right, 0 when unclear.
private static int SideOf(IReadOnlyList<Entity> breakLine, double length, IReadOnlyList<Vector> region, double probe)
{
var votes = 0;
foreach (var fraction in new[] { 0.25, 0.5, 0.75 })
{
var d = length * fraction;
var p = BreakoffPath.PointAt(breakLine, d);
var n = Normal(breakLine, length, d);
var left = BreakoffPath.Contains(region, p + n * probe);
var right = BreakoffPath.Contains(region, p - n * probe);
if (left != right)
votes += left ? 1 : -1;
}
return System.Math.Sign(votes);
}
// The slot's part side is the exact break line from 'from' to 'to'; its scrap side follows
// the break line 'depth' into the breakoff, pulled back near the breakoff's edges so at least
// 'clearance' of breakoff remains beyond it. Its ends move inward where there is no room.
private static List<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;
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using System.Collections.Generic;
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.
/// </summary>
public sealed class BreakoffLayout
{
internal BreakoffLayout(string error) => Error = error;
internal BreakoffLayout() { }
public string Error { get; }
public bool IsValid => Error == null;
/// <summary>The outline section shared by the part and the breakoff, 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>
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>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>
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>
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.
/// </summary>
public double ClampTabCenter(int index, double center)
{
var half = TabWidth / 2;
var min = index == 0 ? half : TabCenters[index - 1] + TabWidth + MinGap;
var max = index == TabCenters.Count - 1 ? Length - half : TabCenters[index + 1] - TabWidth - MinGap;
return max < min ? TabCenters[index] : System.Math.Clamp(center, min, max);
}
// Wider than the builder's overlap tolerance, so a clamped drag always resolves.
internal double MinGap => System.Math.Max(TabWidth / 2, 2 * BreakoffBuilder.JoinTolerance);
}
}
+253
View File
@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Breakoffs
{
/// <summary>
/// Distance-based operations on a chain of lines and arcs, each oriented in travel order
/// (one's end is the next one's start). Splits and copies keep the source entity's style.
/// </summary>
internal static class BreakoffPath
{
public static Vector Start(Entity e) => e is Line l ? l.StartPoint : ((Arc)e).StartPoint();
public static Vector End(Entity e) => e is Line l ? l.EndPoint : ((Arc)e).EndPoint();
public static double Length(IReadOnlyList<Entity> path) => path.Sum(e => e.Length);
public static Vector PointAt(IReadOnlyList<Entity> path, double distance) =>
Locate(path, distance, out var entity, out var local) ? PointOn(entity, local) : End(path[^1]);
public static Vector TangentAt(IReadOnlyList<Entity> path, double distance)
{
Locate(path, distance, out var entity, out var local);
return TangentOn(entity, local);
}
/// <summary>The distance along the path of the path point closest to <paramref name="point"/>.</summary>
public static double DistanceOf(IReadOnlyList<Entity> path, Vector point)
{
var best = double.MaxValue;
var bestDistance = 0.0;
var walked = 0.0;
foreach (var e in path)
{
var closest = e.ClosestPointTo(point);
var gap = closest.DistanceTo(point);
if (gap < best)
{
best = gap;
bestDistance = walked + LocalDistance(e, closest);
}
walked += e.Length;
}
return bestDistance;
}
/// <summary>Copies of the part of <paramref name="path"/> from <paramref name="from"/> to <paramref name="to"/>.</summary>
public static List<Entity> Sub(IReadOnlyList<Entity> path, double from, double to)
{
var result = new List<Entity>();
var walked = 0.0;
foreach (var e in path)
{
var length = e.Length;
var a = System.Math.Max(from - walked, 0);
var b = System.Math.Min(to - walked, length);
walked += length;
if (b - a <= Tolerance.Epsilon)
continue;
result.Add(Piece(e, a, b));
}
return result;
}
/// <summary>Copies of <paramref name="path"/> in reverse travel order.</summary>
public static List<Entity> Reversed(IEnumerable<Entity> path)
{
var result = path.Select(Copy).ToList();
result.Reverse();
foreach (var e in result)
e.Reverse();
return result;
}
public static Entity Copy(Entity e)
{
var copy = e.Clone();
copy.Id = e.Id;
return copy;
}
/// <summary>Points along the path, arcs subdivided so no chord deviates more than <paramref name="tolerance"/>.</summary>
public static List<Vector> ToPoints(IEnumerable<Entity> path, double tolerance)
{
var points = new List<Vector>();
foreach (var e in path)
{
if (points.Count == 0)
points.Add(Start(e));
if (e is Arc arc)
points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)).Skip(1));
else
points.Add(End(e));
}
return points;
}
/// <summary>Signed area (counterclockwise positive) of a closed point loop.</summary>
public static double SignedArea(IReadOnlyList<Vector> loop)
{
var sum = 0.0;
for (var i = 0; i < loop.Count; i++)
{
var a = loop[i];
var b = loop[(i + 1) % loop.Count];
sum += a.X * b.Y - b.X * a.Y;
}
return sum / 2;
}
public static bool Contains(IReadOnlyList<Vector> loop, Vector point)
{
var inside = false;
for (int i = 0, j = loop.Count - 1; i < loop.Count; j = i++)
{
var a = loop[i];
var b = loop[j];
if ((a.Y > point.Y) != (b.Y > point.Y)
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
inside = !inside;
}
return inside;
}
private static bool Locate(IReadOnlyList<Entity> path, double distance, out Entity entity, out double local)
{
var walked = 0.0;
foreach (var e in path)
{
var length = e.Length;
if (distance <= walked + length)
{
entity = e;
local = System.Math.Clamp(distance - walked, 0, length);
return true;
}
walked += length;
}
entity = path[^1];
local = entity.Length;
return false;
}
private static Vector PointOn(Entity e, double local)
{
if (e is Line line)
{
var t = line.Length <= 0 ? 0 : local / line.Length;
return line.StartPoint + (line.EndPoint - line.StartPoint) * t;
}
var arc = (Arc)e;
var angle = ArcAngleAt(arc, local);
return new Vector(arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static Vector TangentOn(Entity e, double local)
{
if (e is Line line)
{
var d = line.EndPoint - line.StartPoint;
var length = line.Length;
return length <= 0 ? new Vector(1, 0) : new Vector(d.X / length, d.Y / length);
}
var arc = (Arc)e;
var angle = ArcAngleAt(arc, local);
return arc.IsReversed
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
}
private static double ArcAngleAt(Arc arc, double local) =>
arc.StartAngle + (arc.IsReversed ? -1 : 1) * local / arc.Radius;
private static double LocalDistance(Entity e, Vector point)
{
if (e is Line line)
return line.StartPoint.DistanceTo(point);
var arc = (Arc)e;
var swept = Angle.NormalizeRad(arc.IsReversed
? arc.StartAngle - arc.Center.AngleTo(point)
: arc.Center.AngleTo(point) - arc.StartAngle);
var distance = swept * arc.Radius;
// A point a hair before the start normalizes to a full turn; it belongs at an end.
if (distance > arc.Length)
return point.DistanceTo(arc.StartPoint()) <= point.DistanceTo(arc.EndPoint()) ? 0 : arc.Length;
return distance;
}
private static Entity Piece(Entity e, double a, double b)
{
Entity piece;
if (e is Line line)
{
piece = new Line(PointOn(line, a), PointOn(line, b));
}
else
{
var arc = (Arc)e;
piece = new Arc(arc.Center, arc.Radius, Angle.NormalizeRad(ArcAngleAt(arc, a)),
Angle.NormalizeRad(ArcAngleAt(arc, b)), arc.IsReversed);
}
piece.Layer = e.Layer;
piece.Color = e.Color;
piece.LineTypeName = e.LineTypeName;
return piece;
}
/// <summary>
/// Orders <paramref name="edges"/> into one open chain, copied and oriented in travel
/// order, or returns null when they do not form exactly one open chain.
/// </summary>
public static List<Entity> ChainOpen(IReadOnlyList<Entity> edges, double tolerance)
{
if (edges.Count == 0 || edges.Any(e => e is not (Line or Arc)))
return null;
int Degree(Vector p) => edges.Count(e => Start(e).DistanceTo(p) <= tolerance)
+ edges.Count(e => End(e).DistanceTo(p) <= tolerance);
var first = edges.FirstOrDefault(e => Degree(Start(e)) == 1 || Degree(End(e)) == 1);
if (first == null)
return null;
var remaining = edges.ToList();
var chain = new List<Entity>();
var current = Copy(first);
if (Degree(Start(first)) != 1)
current.Reverse();
remaining.Remove(first);
chain.Add(current);
while (remaining.Count > 0)
{
var tip = End(chain[^1]);
var next = remaining.FirstOrDefault(e => Start(e).DistanceTo(tip) <= tolerance
|| End(e).DistanceTo(tip) <= tolerance);
if (next == null)
return null;
var copy = Copy(next);
if (Start(next).DistanceTo(tip) > tolerance)
copy.Reverse();
remaining.Remove(next);
chain.Add(copy);
}
return Start(chain[0]).DistanceTo(End(chain[^1])) <= tolerance ? null : chain;
}
}
}
+6
View File
@@ -125,6 +125,12 @@ namespace OpenNest
/// </summary>
public List<CNC.CuttingStrategy.ContourCutPin> CutPins { get; set; } = new List<CNC.CuttingStrategy.ContourCutPin>();
/// <summary>
/// Scrap pieces tabbed to the part, in the source frame. <see cref="Program"/> already has
/// them applied; they are kept so an editor can change them from <see cref="SourceEntities"/>.
/// </summary>
public List<Breakoffs.Breakoff> Breakoffs { get; set; } = new List<Breakoffs.Breakoff>();
/// <summary>
/// Complete set of source entities with stable GUIDs.
/// Null when the drawing was created from G-code or an older nest file.
@@ -0,0 +1,95 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
using OpenNest.Breakoffs;
namespace OpenNest.IO.Tests;
public class BreakoffPersistenceTests
{
private static Drawing WithBreakoff()
{
var drawing = DrawingPackageFixture.Build();
drawing.Breakoffs.Add(new Breakoff
{
EdgeIds = [Guid.NewGuid(), Guid.NewGuid()],
TabWidth = 0.05,
SlotWidth = 0.3,
TabCenters = [0.025, 1.5, 3.2],
});
return drawing;
}
[Fact]
public void Breakoffs_RoundTripEveryField()
{
var drawing = WithBreakoff();
var original = drawing.Breakoffs[0];
var (restored, warnings) = RoundTrip(Write(drawing));
Assert.Empty(warnings);
var breakoff = Assert.Single(Assert.Single(restored.Drawings).Breakoffs);
Assert.Equal(original.EdgeIds, breakoff.EdgeIds);
Assert.Equal((0.05, 0.3), (breakoff.TabWidth, breakoff.SlotWidth));
Assert.Equal(original.TabCenters, breakoff.TabCenters);
}
[Fact]
public void NoBreakoffs_AreOmittedFromTheFile()
{
var archive = Write(DrawingPackageFixture.Build());
Assert.False(NestJson(archive)["drawings"]![0]!.AsObject().ContainsKey("breakoffs"));
Assert.Empty(Assert.Single(RoundTrip(archive).Nest.Drawings).Breakoffs);
}
[Fact]
public void UnreadableBreakoff_IsDroppedWithAWarning_AndTheDrawingLoads()
{
var archive = Write(WithBreakoff());
var json = NestJson(archive);
json["drawings"]![0]!["breakoffs"]![0]!["tabWidth"] = 0;
var (restored, warnings) = RoundTrip(ReplaceNestJson(archive, json));
Assert.Empty(Assert.Single(restored.Drawings).Breakoffs);
Assert.Contains(warnings, w => w.Contains("breakoff 1 could not be read"));
}
private static byte[] Write(Drawing drawing)
{
var nest = new Nest(drawing.Name) { Units = Units.Inches };
nest.Drawings.Add(drawing);
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
return stream.ToArray();
}
private static (Nest Nest, IReadOnlyList<string> Warnings) RoundTrip(byte[] archive)
{
var reader = new NestReader(new MemoryStream(archive));
return (reader.Read(), reader.Warnings);
}
private static JsonObject NestJson(byte[] archive)
{
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8);
return JsonNode.Parse(reader.ReadToEnd())!.AsObject();
}
private static byte[] ReplaceNestJson(byte[] archive, JsonObject json)
{
using var output = new MemoryStream();
output.Write(archive);
using (var zip = new ZipArchive(output, ZipArchiveMode.Update, leaveOpen: true))
{
zip.GetEntry("nest.json")!.Delete();
using var writer = new StreamWriter(zip.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
writer.Write(json.ToJsonString());
}
return output.ToArray();
}
}
+13 -2
View File
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Bending;
using OpenNest.Breakoffs;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
@@ -130,19 +131,27 @@ namespace OpenNest.IO
/// in-place G-code edits). Source.Offset is still populated from the
/// generated program so round-trips stay consistent.
/// </param>
/// <param name="breakoffs">
/// Breakoffs to merge into the program and keep on the drawing; ones that cannot be
/// resolved against <paramref name="entities"/> are kept but not applied.
/// </param>
public static Drawing BuildDrawing(
CadImportResult result,
IEnumerable<Entity> entities,
IEnumerable<Bend> bends,
int quantity,
string customer,
OpenNest.CNC.Program editedProgram
OpenNest.CNC.Program editedProgram,
IReadOnlyList<Breakoff> breakoffs = null
)
{
var visible = entities as IList<Entity> ?? new List<Entity>(entities);
var bendList = bends as IList<Bend> ?? new List<Bend>(bends);
var normalized = ShapeProfile.NormalizeEntities(visible);
var cut = breakoffs is { Count: > 0 }
? BreakoffBuilder.Apply(visible.ToList(), breakoffs, out _)
: visible;
var normalized = ShapeProfile.NormalizeEntities(cut);
var pgm = ConvertGeometry.ToProgram(normalized);
var offset = Vector.Zero;
@@ -166,6 +175,7 @@ namespace OpenNest.IO
drawing.Source.Offset = offset;
drawing.Quantity.Required = quantity;
drawing.Bends.AddRange(bendList);
drawing.Breakoffs = breakoffs?.Select(b => b.Clone()).ToList() ?? new List<Breakoff>();
drawing.Program = editedProgram ?? pgm;
var bendSources = new HashSet<Entity>(
@@ -234,6 +244,7 @@ namespace OpenNest.IO
existing.Quantity.Required = edited.Quantity.Required;
existing.Bends.Clear();
existing.Bends.AddRange(edited.Bends);
existing.Breakoffs = edited.Breakoffs;
}
private static bool IsRepairMark(Entity e) =>
+15
View File
@@ -202,6 +202,21 @@ namespace OpenNest.IO
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientCutPinListConverter))]
public List<CutPinDto> CutPins { get; init; }
/// <summary>
/// Breakoffs in the source frame, already applied to the stored program. Omitted when
/// there are none.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<BreakoffDto> Breakoffs { get; init; }
}
public record BreakoffDto
{
public List<Guid> EdgeIds { get; init; } = new();
public double TabWidth { get; init; }
public double SlotWidth { get; init; }
public List<double> TabCenters { get; init; } = new();
}
public record CutPinDto
+34
View File
@@ -7,6 +7,7 @@ using System.Linq;
using System.Text.Json;
using OpenNest.Bending;
using OpenNest.BestFit;
using OpenNest.Breakoffs;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Converters;
@@ -276,6 +277,7 @@ namespace OpenNest.IO
drawing.Library = ReadLibraryLink(d);
drawing.CutPins = ReadCutPins(d);
drawing.Breakoffs = ReadBreakoffs(d);
map[d.Id] = drawing;
}
return map;
@@ -326,6 +328,38 @@ namespace OpenNest.IO
return pins;
}
// The program already has breakoffs applied; a dropped definition only loses editability.
private List<Breakoff> ReadBreakoffs(DrawingDto d)
{
var breakoffs = new List<Breakoff>();
if (d.Breakoffs == null)
return breakoffs;
for (var i = 0; i < d.Breakoffs.Count; i++)
{
var dto = d.Breakoffs[i];
if (dto?.EdgeIds is { Count: > 0 } && dto.TabCenters != null
&& dto.TabWidth > 0 && double.IsFinite(dto.TabWidth)
&& dto.SlotWidth > 0 && double.IsFinite(dto.SlotWidth)
&& dto.TabCenters.All(double.IsFinite))
{
breakoffs.Add(new Breakoff
{
EdgeIds = dto.EdgeIds.ToList(),
TabWidth = dto.TabWidth,
SlotWidth = dto.SlotWidth,
TabCenters = dto.TabCenters.ToList(),
});
}
else
{
warnings.Add($"Drawing '{d.Name}': breakoff {i + 1} could not be read; its cut geometry "
+ "is kept, but it can no longer be edited as a breakoff.");
}
}
return breakoffs;
}
private bool ReadPinsOff(PartDto dto, int plateId, int partIndex, Part part)
{
if (dto.DrawingPinsOff is bool pinsOff)
+9
View File
@@ -200,6 +200,15 @@ namespace OpenNest.IO
?? new List<BendDto>(),
Library = ToLibraryLinkDto(d.Library),
CutPins = d.CutPins?.Count > 0 ? d.CutPins.Select(CutPinFormat.ToDto).ToList() : null,
Breakoffs = d.Breakoffs?.Count > 0
? d.Breakoffs.Select(b => new BreakoffDto
{
EdgeIds = b.EdgeIds.ToList(),
TabWidth = b.TabWidth,
SlotWidth = b.SlotWidth,
TabCenters = b.TabCenters.ToList(),
}).ToList()
: null,
}
);
}
@@ -0,0 +1,274 @@
using OpenNest.Bending;
using OpenNest.Breakoffs;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Breakoffs;
/// <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.
/// </summary>
public class BreakoffBuilderTests
{
private const double W = Breakoff.DefaultTabWidth;
private static List<Entity> Part(bool leftBreakoff = false)
{
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),
};
if (leftBreakoff)
entities.AddRange(new[] { new Line(0, 1, -2, 3), new Line(-2, 3, 0, 5) });
return entities;
}
private static Breakoff On(params Entity[] edges) => new() { EdgeIds = edges.Select(e => e.Id).ToList() };
private static Breakoff Right(List<Entity> part) => On(part[4], part[5]);
[Fact]
public void Layout_MergesTheBreakoffIntoTheOutlineAndSlotsTheBreakLine()
{
var part = Part();
var layout = BreakoffBuilder.Layout(part, Right(part));
Assert.True(layout.IsValid, layout.Error);
Assert.Equal(4, layout.Length, 9);
Assert.Equal(new Vector(10, 1), layout.PointAt(0));
Assert.Equal(new Vector(10, 5), layout.PointAt(4));
Assert.Equal(64, new Shape { Entities = layout.Outline.ToList() }.Area(), 6);
Assert.Equal(0, MaxGap(layout.Outline), 12);
Assert.Equal(BreakoffBuilder.EvenTabCenters(4, Breakoff.DefaultTabCount, W), layout.TabCenters);
Assert.Equal(Breakoff.DefaultTabCount - 1, layout.Slots.Count);
Assert.All(layout.Slots, slot => Assert.Equal(0, MaxGap(slot), 12));
}
[Fact]
public void Slots_CutTheBreakLineOnlyBetweenTabs_AndStayInsideTheBreakoff()
{
var part = Part();
var layout = BreakoffBuilder.Layout(part, Right(part));
var cut = layout.Slots.Select(slot => OnBreakLine(slot)).ToList();
// Interior tabs keep exactly their width; the end slots also move clear of the breakoff's
// edges, so the outer tabs are at least as wide.
for (var i = 1; i < cut.Count; i++)
Assert.Equal(W, cut[i].From - cut[i - 1].To, 9);
Assert.True(cut[0].From >= 1 + W - 1e-9);
Assert.True(cut[^1].To <= 5 - W + 1e-9);
foreach (var point in layout.Slots.SelectMany(s => s).SelectMany(Ends))
{
Assert.True(point.X >= 10 - 1e-9, $"{point} is on the part side");
Assert.True(System.Math.Abs(point.Y - 3) <= 2 - (point.X - 10) + 1e-9, $"{point} is outside the breakoff");
}
// The full slot depth is reached away from the breakoff's edges.
Assert.Contains(layout.Slots.SelectMany(s => s).SelectMany(Ends),
p => System.Math.Abs(p.X - (10 + Breakoff.DefaultSlotWidth)) < 1e-9);
}
[Fact]
public void BuildDrawing_PlansReady_WithEveryPierceInsideTheBreakoff()
{
var part = Part();
var drawing = Build(part, Right(part));
var placed = new Part(drawing, new Vector(5, 5));
var plate = new Nest().CreatePlate();
plate.Size = new Size(60, 120);
plate.Parts.Add(placed);
var proposal = CuttingPlanBatch.Capture([plate], new CuttingParameters(), false).Plan();
var planned = Assert.Single(proposal.Plates);
Assert.True(planned.IsReady, string.Join("\n", proposal.Describe("in")));
var motions = ExecutionMotionReader.Read(Assert.Single(planned.Result.ProposedOrder).CopyProgram(),
placed.Location, null, default).Motions;
var leads = motions.Where(m => m.Layer == LayerType.Leadin).ToList();
Assert.Equal(Breakoff.DefaultTabCount - 1, leads.Count);
foreach (var lead in leads)
{
var pierce = lead.Start!.Value - placed.Location + drawing.Source.Offset;
Assert.True(pierce.X > 10 && System.Math.Abs(pierce.Y - 3) < 2 - (pierce.X - 10),
$"pierce {pierce} is not in the breakoff");
}
}
[Fact]
public void BuildDrawing_KeepsACopyOfTheBreakoffAndCountsItsMaterial()
{
var part = Part();
var breakoff = Right(part);
var drawing = Build(part, breakoff);
var kept = Assert.Single(drawing.Breakoffs);
Assert.NotSame(breakoff, kept);
Assert.Equal(breakoff.EdgeIds, kept.EdgeIds);
Assert.InRange(drawing.Area, 62, 64);
Assert.Equal(Breakoff.DefaultTabCount, PreparedContours.Capture(drawing.Program, new CuttingParameters()).Count);
}
[Fact]
public void ArcBreakoff_IsMergedAndSlotted()
{
var part = Part().Take(4).ToList();
var arc = new Arc(new Vector(10, 3), 2, -System.Math.PI / 2, System.Math.PI / 2);
part.Add(arc);
var layout = BreakoffBuilder.Layout(part, On(arc));
Assert.True(layout.IsValid, layout.Error);
Assert.Equal(60 + System.Math.PI * 2, new Shape { Entities = layout.Outline.ToList() }.Area(), 3);
var drawing = Build(part, On(arc));
Assert.Equal(Breakoff.DefaultTabCount, PreparedContours.Capture(drawing.Program, new CuttingParameters()).Count);
}
[Fact]
public void BreakoffEndingExactlyAtAnArcStart_SplitsTheOutlineThere()
{
// A radius-5 disc of two half arcs; the breakoff ends at (5, 0), the first arc's start,
// and at 30 degrees on that arc.
var upper = new Arc(Vector.Zero, 5, 0, System.Math.PI);
var lower = new Arc(Vector.Zero, 5, System.Math.PI, 0);
var at30 = new Vector(5 * System.Math.Cos(System.Math.PI / 6), 5 * System.Math.Sin(System.Math.PI / 6));
var a = new Line(new Vector(5, 0), new Vector(7, 1.5));
var b = new Line(new Vector(7, 1.5), at30);
var part = new List<Entity> { upper, lower, a, b };
var layout = BreakoffBuilder.Layout(part, On(a, b));
Assert.True(layout.IsValid, layout.Error);
Assert.Equal(5 * System.Math.PI / 6, layout.Length, 6);
Assert.Equal(at30.X, layout.PointAt(0).X, 9);
Assert.Equal(0, layout.PointAt(layout.Length).Y, 9);
}
[Fact]
public void TwoBreakoffsOnOneOutline_BothApply()
{
var part = Part(leftBreakoff: true);
var breakoffs = new List<Breakoff> { Right(part), On(part[6], part[7]) };
var applied = BreakoffBuilder.Apply(part, breakoffs, out var errors);
Assert.Empty(errors);
var profile = new ShapeProfile(ShapeProfile.NormalizeEntities(applied));
Assert.Equal(68, profile.Perimeter.Area(), 6);
Assert.Equal(2 * (Breakoff.DefaultTabCount - 1), profile.Cutouts.Count);
Assert.Equal(2 * Breakoff.DefaultTabCount - 1,
PreparedContours.Capture(Build(part, breakoffs.ToArray()).Program, new CuttingParameters()).Count);
}
[Fact]
public void Apply_LeavesItsInputsUntouched()
{
var part = Part();
var before = part.Select(e => (e.Id, Ends(e).ToArray())).ToList();
BreakoffBuilder.Apply(part, [Right(part)], out _);
Assert.Equal(before.Select(b => b.Id), part.Select(e => e.Id));
Assert.Equal(before.Select(b => b.Item2), part.Select(e => Ends(e).ToArray()));
}
[Fact]
public void BreakoffInsideThePart_IsRefused()
{
var part = Part().Take(4).ToList();
var a = new Line(10, 1, 8, 3);
var b = new Line(8, 3, 10, 5);
part.AddRange([a, b]);
Assert.Equal("It must sit outside the part outline.", BreakoffBuilder.Layout(part, On(a, b)).Error);
}
[Fact]
public void HiddenEdge_IsReportedAndNotApplied()
{
var part = Part();
var breakoff = Right(part);
part.RemoveAt(5);
var applied = BreakoffBuilder.Apply(part, [breakoff], out var errors);
Assert.Equal("Breakoff 1: Some of its edges are hidden or missing.", Assert.Single(errors));
Assert.Equal(part, applied);
}
[Fact]
public void OverlappingTabs_AreRefused()
{
var part = Part();
var breakoff = Right(part);
breakoff.TabCenters = [W / 2, W, 4 - W / 2];
Assert.Equal("Two tabs overlap; space the tabs evenly again.", BreakoffBuilder.Layout(part, breakoff).Error);
}
[Fact]
public void SlotsWithNoRoom_AreRefused()
{
var part = Part();
var breakoff = Right(part);
breakoff.SlotWidth = 10;
Assert.StartsWith("There is no room for slot 1", BreakoffBuilder.Layout(part, breakoff).Error);
}
[Fact]
public void FindEdgeChain_FromOneEdge_StopsWhereTheBreakoffMeetsTheOutline()
{
var part = Part();
var chain = BreakoffBuilder.FindEdgeChain(part, part[5]);
Assert.Equal(new[] { part[4], part[5] }.ToHashSet(), chain.ToHashSet());
}
[Fact]
public void ClampTabCenter_KeepsTabsOnTheLineAndApart()
{
var part = Part();
var layout = BreakoffBuilder.Layout(part, Right(part));
Assert.Equal(W / 2, layout.ClampTabCenter(0, -5), 9);
Assert.Equal(layout.TabCenters[1] - W - W / 2, layout.ClampTabCenter(0, layout.TabCenters[1]), 9);
Assert.Equal(4 - W / 2, layout.ClampTabCenter(layout.TabCenters.Count - 1, 99), 9);
}
private static Drawing Build(List<Entity> entities, params Breakoff[] breakoffs) =>
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)
{
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();
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 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;
}
}
@@ -0,0 +1,130 @@
using System.Drawing;
using System.Reflection;
using System.Windows.Forms;
using OpenNest.Bending;
using OpenNest.Breakoffs;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Controls;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.WinForms.Tests.Forms;
/// <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.
/// </summary>
public class CadConverterBreakoffTests
{
[Fact]
public void PickingAnEdge_AddsABreakoff_DraggingMovesATab_AndAcceptKeepsIt() => ArchUnitsTests.RunSta(() =>
{
using var form = new BreakoffTestForm();
Show(form);
form.LoadDrawings([Part()]);
var view = Field<EntityView>(form, "entityView1");
var item = Field<FileListControl>(form, "fileList").SelectedItem!;
Invoke(form, "OnAddBreakoffClicked", null, EventArgs.Empty);
Assert.True(view.IsPickingBreakoff);
Mouse(view, "OnMouseClick", view.PointWorldToControl(new Vector(11, 2)));
Assert.Empty(form.Messages);
Assert.False(view.IsPickingBreakoff);
var breakoff = Assert.Single(item.Breakoffs);
Assert.Equal(2, breakoff.EdgeIds.Count);
Assert.Equal(4, breakoff.TabCenters.Count);
var layout = Assert.Single(view.BreakoffLayouts);
Assert.True(layout.IsValid, layout.Error);
var before = breakoff.TabCenters[1];
Mouse(view, "OnMouseDown", view.PointWorldToControl(layout.PointAt(before)));
Mouse(view, "OnMouseMove", view.PointWorldToControl(new Vector(10, 1 + 1.75)));
Mouse(view, "OnMouseUp", view.PointWorldToControl(new Vector(10, 1 + 1.75)));
Assert.InRange(breakoff.TabCenters[1], 1.6, 1.9);
Assert.NotEqual(before, breakoff.TabCenters[1]);
var drawing = Assert.Single(form.GetDrawings());
Assert.Equal(breakoff.TabCenters, Assert.Single(drawing.Breakoffs).TabCenters);
Assert.Equal(4, PreparedContours.Capture(drawing.Program, new CuttingParameters()).Count);
form.Close();
});
[Fact]
public void PickingTheOutline_ExplainsAndAddsNothing() => ArchUnitsTests.RunSta(() =>
{
using var form = new BreakoffTestForm();
Show(form);
form.LoadDrawings([Part()]);
var view = Field<EntityView>(form, "entityView1");
Invoke(form, "OnAddBreakoffClicked", null, EventArgs.Empty);
Mouse(view, "OnMouseClick", view.PointWorldToControl(new Vector(5, 0)));
Assert.StartsWith("Could not make a breakoff from that edge", Assert.Single(form.Messages));
Assert.Empty(Field<FileListControl>(form, "fileList").SelectedItem!.Breakoffs);
form.Close();
});
[Fact]
public void ReopenedDrawing_ShowsItsBreakoff() => ArchUnitsTests.RunSta(() =>
{
var source = Part();
var edges = source.SourceEntities!.Where(e => e is Line l && l.StartPoint.X >= 10 && l.EndPoint.X >= 10
&& !(l.StartPoint.X == 10 && l.EndPoint.X == 10)).ToList();
var result = new CadImportResult { Entities = source.SourceEntities!, Name = "reopened" };
var withBreakoff = CadImporter.BuildDrawing(result, source.SourceEntities!, new List<Bend>(), 1, null, null,
[new Breakoff { EdgeIds = edges.Select(e => e.Id).ToList() }]);
using var form = new BreakoffTestForm();
Show(form);
form.LoadDrawings([withBreakoff]);
var layout = Assert.Single(Field<EntityView>(form, "entityView1").BreakoffLayouts);
Assert.True(layout.IsValid, layout.Error);
form.Close();
});
private static Drawing Part()
{
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),
};
foreach (var e in entities)
e.Layer = new Layer("0") { IsVisible = true };
return CadImporter.BuildDrawing(new CadImportResult { Entities = entities, Name = "breakoff" }, entities,
new List<Bend>(), 1, null, null);
}
private static void Show(Form form)
{
form.WindowState = FormWindowState.Normal;
form.StartPosition = FormStartPosition.Manual;
form.Location = new Point(0, 0);
form.Size = new System.Drawing.Size(1200, 800);
form.Show();
}
private static T Field<T>(object owner, string name) =>
(T)owner.GetType().BaseType!.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
private static void Invoke(CadConverterForm form, string name, params object?[] args) =>
typeof(CadConverterForm).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(form, args);
private static void Mouse(Control control, string handler, Point at) =>
typeof(Control).GetMethod(handler, BindingFlags.Instance | BindingFlags.NonPublic, [typeof(MouseEventArgs)])!
.Invoke(control, [new MouseEventArgs(MouseButtons.Left, 1, at.X, at.Y, 0)]);
private sealed class BreakoffTestForm : CadConverterForm
{
public List<string> Messages { get; } = new();
internal override bool AskBreakoffSettings(BreakoffDialog dialog) => true;
internal override void ShowBreakoffMessage(string message) => Messages.Add(message);
}
}
+196
View File
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Breakoffs;
using OpenNest.Geometry;
namespace OpenNest.Controls
{
public sealed class TabDragEventArgs : EventArgs
{
public TabDragEventArgs(int breakoff, int tab, double center, bool finished)
{
Breakoff = breakoff;
Tab = tab;
Center = center;
Finished = finished;
}
public int Breakoff { get; }
public int Tab { get; }
/// <summary>The new tab center as a distance along the break line, already clamped.</summary>
public double Center { get; }
public bool Finished { get; }
}
public partial class EntityView
{
private const int TabHandleRadius = 7;
private bool isPickingBreakoff;
private (int Breakoff, int Tab)? draggedTab;
/// <summary>Resolved breakoffs to draw, in breakoff order; null entries are not drawn.</summary>
public IReadOnlyList<BreakoffLayout> BreakoffLayouts { get; set; } = Array.Empty<BreakoffLayout>();
public int SelectedBreakoffIndex { get; set; } = -1;
public event EventHandler<Entity> BreakoffEdgePicked;
public event EventHandler<TabDragEventArgs> TabDragged;
public bool IsPickingBreakoff
{
get => isPickingBreakoff;
set
{
isPickingBreakoff = value;
Cursor = value ? Cursors.Hand : Cursors.Cross;
}
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (e.Button == MouseButtons.Left && !IsPickingBendLine && !IsPickingBreakoff)
draggedTab = HitTestTab(e.Location);
}
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
if (draggedTab is not { } tab || e.Button != MouseButtons.Left)
return;
draggedTab = null;
RaiseTabDragged(tab, e.Location, finished: true);
}
private void HandleBreakoffMouseMove(MouseEventArgs e)
{
if (draggedTab is { } tab)
RaiseTabDragged(tab, e.Location, finished: false);
else if (e.Button == MouseButtons.None && !IsPickingBendLine && !IsPickingBreakoff)
Cursor = HitTestTab(e.Location) != null ? Cursors.SizeAll : Cursors.Cross;
}
private bool HandleBreakoffClick(MouseEventArgs e)
{
if (!IsPickingBreakoff || e.Button != MouseButtons.Left)
return false;
var entity = HitTestEdge(e.Location);
if (entity != null)
BreakoffEdgePicked?.Invoke(this, entity);
return true;
}
private void RaiseTabDragged((int Breakoff, int Tab) tab, Point location, bool finished)
{
var layout = BreakoffLayouts.ElementAtOrDefault(tab.Breakoff);
if (layout is not { IsValid: true } || tab.Tab >= layout.TabCenters.Count)
return;
var center = layout.ClampTabCenter(tab.Tab, layout.DistanceOf(PointControlToWorld(location)));
TabDragged?.Invoke(this, new TabDragEventArgs(tab.Breakoff, tab.Tab, center, finished));
}
private (int, int)? HitTestTab(Point location)
{
for (var b = 0; b < BreakoffLayouts.Count; b++)
{
var layout = BreakoffLayouts[b];
if (layout is not { IsValid: true })
continue;
for (var t = 0; t < layout.TabCenters.Count; t++)
{
var handle = PointWorldToControl(layout.PointAt(layout.TabCenters[t]));
var dx = handle.X - location.X;
var dy = handle.Y - location.Y;
if (dx * dx + dy * dy <= TabHandleRadius * TabHandleRadius)
return (b, t);
}
}
return null;
}
private Entity HitTestEdge(Point controlPoint)
{
var worldPoint = PointControlToWorld(controlPoint);
var tolerance = LengthGuiToWorld(6);
Entity best = null;
var bestDistance = double.MaxValue;
foreach (var entity in Entities)
{
if (entity is not (Line or Arc) || !entity.IsVisible || entity.Layer?.IsVisible == false)
continue;
var distance = worldPoint.DistanceTo(entity.ClosestPointTo(worldPoint));
if (distance < tolerance && distance < bestDistance)
{
best = entity;
bestDistance = distance;
}
}
return best;
}
private void DrawBreakoffs(Graphics g)
{
if (BreakoffLayouts.Count == 0)
return;
using var slotFill = new SolidBrush(Color.FromArgb(70, 0, 200, 255));
using var slotPen = new Pen(Color.FromArgb(0, 200, 255), 1f);
using var selectedSlotPen = new Pen(Color.White, 1.5f);
using var tabPen = new Pen(Color.Red, 3f);
using var handleBrush = new SolidBrush(Color.Red);
using var handlePen = new Pen(Color.White, 1f);
for (var b = 0; b < BreakoffLayouts.Count; b++)
{
var layout = BreakoffLayouts[b];
if (layout is not { IsValid: true })
continue;
var selected = b == SelectedBreakoffIndex;
foreach (var slot in layout.Slots)
{
var points = SlotOutline(slot);
if (points.Length < 3)
continue;
g.FillPolygon(slotFill, points);
g.DrawPolygon(selected ? selectedSlotPen : slotPen, points);
}
foreach (var center in layout.TabCenters)
{
var from = PointWorldToGraph(layout.PointAt(center - layout.TabWidth / 2));
var to = PointWorldToGraph(layout.PointAt(center + layout.TabWidth / 2));
g.DrawLine(tabPen, from, to);
var handle = PointWorldToGraph(layout.PointAt(center));
var size = selected ? TabHandleRadius : TabHandleRadius - 2;
var rect = new RectangleF(handle.X - size, handle.Y - size, size * 2, size * 2);
g.FillEllipse(handleBrush, rect);
g.DrawEllipse(handlePen, rect);
}
}
}
private PointF[] SlotOutline(IReadOnlyList<Entity> slot)
{
var points = new List<PointF>();
foreach (var entity in slot)
{
if (entity is Arc arc)
points.AddRange(arc.ToPoints(System.Math.Max(2, arc.SegmentsForTolerance(LengthGuiToWorld(0.5f))))
.Select(PointWorldToGraph));
else if (entity is Line line)
{
points.Add(PointWorldToGraph(line.StartPoint));
points.Add(PointWorldToGraph(line.EndPoint));
}
}
return points.ToArray();
}
}
}
+9 -3
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
@@ -10,7 +10,7 @@ using OpenNest.Math;
namespace OpenNest.Controls
{
public class EntityView : DrawControl
public partial class EntityView : DrawControl
{
public List<Entity> Entities;
public List<Bend> Bends = new List<Bend>();
@@ -74,6 +74,9 @@ namespace OpenNest.Controls
if (!Focused)
Focus();
if (HandleBreakoffClick(e))
return;
if (IsPickingBendLine && e.Button == MouseButtons.Left)
{
var line = HitTestLine(e.Location);
@@ -123,6 +126,7 @@ namespace OpenNest.Controls
}
DrawBendLines(e.Graphics);
DrawBreakoffs(e.Graphics);
foreach (var entity in Entities)
{
@@ -200,6 +204,7 @@ namespace OpenNest.Controls
{
LastPoint = CurrentPoint;
CurrentPoint = PointControlToWorld(e.Location);
HandleBreakoffMouseMove(e);
}
lastPoint = e.Location;
@@ -222,9 +227,10 @@ namespace OpenNest.Controls
if (e.KeyCode == Keys.F)
ZoomToFit();
if (IsPickingBendLine && e.KeyCode == Keys.Escape)
if ((IsPickingBendLine || IsPickingBreakoff) && e.KeyCode == Keys.Escape)
{
IsPickingBendLine = false;
IsPickingBreakoff = false;
PickCancelled?.Invoke(this, EventArgs.Empty);
}
}
+1
View File
@@ -19,6 +19,7 @@ namespace OpenNest.Controls
public List<Entity> Entities { get; set; } = new();
public List<Entity> OriginalEntities { get; set; }
public List<Bend> Bends { get; set; } = new();
public List<Breakoffs.Breakoff> Breakoffs { get; set; } = new();
public HashSet<Guid> SuppressedEntityIds { get; set; }
public Box Bounds { get; set; }
public int EntityCount { get; set; }
+92
View File
@@ -20,6 +20,10 @@ namespace OpenNest.Controls
private readonly CheckedListBox lineTypesList;
private readonly ListBox bendLinesList;
private readonly LinkLabel bendAddLink;
private readonly CollapsiblePanel breakoffsPanel;
private readonly ListBox breakoffsList;
private readonly LinkLabel breakoffAddLink;
private bool loadingBreakoffs;
private List<Entity> currentEntities;
private List<Bend> currentBends;
@@ -29,12 +33,70 @@ namespace OpenNest.Controls
public event EventHandler<int> BendLineRemoved;
public event EventHandler<int> BendLineEdited;
public event EventHandler AddBendLineClicked;
public event EventHandler<int> BreakoffSelected;
public event EventHandler<int> BreakoffEdited;
public event EventHandler<int> BreakoffRemoved;
public event EventHandler AddBreakoffClicked;
public FilterPanel()
{
AutoScroll = true;
BackColor = Color.White;
// Breakoffs
breakoffsPanel = new CollapsiblePanel
{
HeaderText = "Breakoffs (0)",
Dock = DockStyle.Top,
ExpandedHeight = 110,
IsExpanded = false,
};
breakoffsList = new ListBox
{
Dock = DockStyle.Fill,
BorderStyle = BorderStyle.None,
Font = new Font("Segoe UI", 9f),
};
breakoffsList.SelectedIndexChanged += (s, e) =>
{
if (!loadingBreakoffs)
BreakoffSelected?.Invoke(this, breakoffsList.SelectedIndex);
};
breakoffsList.DoubleClick += (s, e) =>
{
if (breakoffsList.SelectedIndex >= 0)
BreakoffEdited?.Invoke(this, breakoffsList.SelectedIndex);
};
breakoffAddLink = new LinkLabel { Text = "Add Breakoff", AutoSize = true, Font = new Font("Segoe UI", 8f) };
breakoffAddLink.LinkClicked += (s, e) => AddBreakoffClicked?.Invoke(this, EventArgs.Empty);
var breakoffEditLink = new LinkLabel { Text = "Edit", AutoSize = true, Font = new Font("Segoe UI", 8f) };
breakoffEditLink.LinkClicked += (s, e) =>
{
if (breakoffsList.SelectedIndex >= 0)
BreakoffEdited?.Invoke(this, breakoffsList.SelectedIndex);
};
var breakoffRemoveLink = new LinkLabel { Text = "Remove", AutoSize = true, Font = new Font("Segoe UI", 8f) };
breakoffRemoveLink.LinkClicked += (s, e) =>
{
if (breakoffsList.SelectedIndex >= 0)
BreakoffRemoved?.Invoke(this, breakoffsList.SelectedIndex);
};
var breakoffLinksPanel = new FlowLayoutPanel
{
Dock = DockStyle.Top,
Height = 20,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
};
breakoffLinksPanel.Controls.Add(breakoffAddLink);
breakoffLinksPanel.Controls.Add(breakoffEditLink);
breakoffLinksPanel.Controls.Add(breakoffRemoveLink);
breakoffsPanel.ContentPanel.Controls.Add(breakoffsList);
breakoffsPanel.ContentPanel.Controls.Add(breakoffLinksPanel);
// Bend Lines
bendLinesPanel = new CollapsiblePanel
{
@@ -169,6 +231,7 @@ namespace OpenNest.Controls
layersPanel.ContentPanel.Controls.Add(checkAllPanel);
// Add panels in reverse order (Dock.Top stacks top-down)
Controls.Add(breakoffsPanel);
Controls.Add(bendLinesPanel);
Controls.Add(lineTypesPanel);
Controls.Add(colorsPanel);
@@ -339,6 +402,35 @@ namespace OpenNest.Controls
bendAddLink.Text = active ? "Cancel (Esc)" : "Add Bend Line";
bendAddLink.LinkColor = active ? Color.OrangeRed : Color.Empty;
}
public void SetBreakoffPickMode(bool active)
{
breakoffAddLink.Text = active ? "Cancel (Esc)" : "Add Breakoff";
breakoffAddLink.LinkColor = active ? Color.OrangeRed : Color.Empty;
}
/// <summary>Shows one line per breakoff with <paramref name="selected"/> selected, without raising selection events.</summary>
public void LoadBreakoffs(IReadOnlyList<string> labels, int selected)
{
var added = labels.Count > breakoffsList.Items.Count;
loadingBreakoffs = true;
try
{
breakoffsList.BeginUpdate();
breakoffsList.Items.Clear();
foreach (var label in labels)
breakoffsList.Items.Add(label);
breakoffsList.SelectedIndex = selected >= 0 && selected < labels.Count ? selected : -1;
breakoffsList.EndUpdate();
}
finally
{
loadingBreakoffs = false;
}
breakoffsPanel.HeaderText = $"Breakoffs ({labels.Count})";
if (added)
breakoffsPanel.IsExpanded = true;
}
}
public class ColorItem
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.Drawing;
using System.Windows.Forms;
using OpenNest.Breakoffs;
namespace OpenNest.Forms
{
public class BreakoffDialog : Form
{
private readonly NumericUpDown numTabs;
private readonly NumericUpDown numTabWidth;
private readonly NumericUpDown numSlotWidth;
public BreakoffDialog()
{
Text = "Breakoff Tabs";
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent;
Size = new Size(300, 230);
var font = new Font("Segoe UI", 9f);
numTabs = AddRow("Tabs:", 12, Breakoff.MinTabCount, 50, 0, 1, Breakoff.DefaultTabCount);
numTabWidth = AddRow("Tab width:", 44, 0.001m, 10, 4, 0.0625m, (decimal)Breakoff.DefaultTabWidth);
numSlotWidth = AddRow("Slot width:", 76, 0.001m, 10, 4, 0.0625m, (decimal)Breakoff.DefaultSlotWidth);
Controls.Add(new Label
{
Text = "Tabs at both ends are included. Slots are cut into the breakoff between tabs.",
Location = new Point(12, 108),
Size = new Size(260, 34),
ForeColor = Color.DimGray,
Font = font,
});
var btnOk = new Button
{
Text = "OK",
DialogResult = DialogResult.OK,
Location = new Point(102, 150),
Size = new Size(80, 28),
Font = font,
};
var btnCancel = new Button
{
Text = "Cancel",
DialogResult = DialogResult.Cancel,
Location = new Point(190, 150),
Size = new Size(80, 28),
Font = font,
};
AcceptButton = btnOk;
CancelButton = btnCancel;
Controls.AddRange(new Control[] { btnOk, btnCancel });
NumericUpDown AddRow(string label, int y, decimal min, decimal max, int decimals, decimal step, decimal value)
{
Controls.Add(new Label { Text = label, Location = new Point(12, y + 3), AutoSize = true, Font = font });
var input = new NumericUpDown
{
Location = new Point(140, y),
Width = 130,
Font = font,
Minimum = min,
Maximum = max,
DecimalPlaces = decimals,
Increment = step,
Value = value,
};
Controls.Add(input);
return input;
}
}
public int TabCount => (int)numTabs.Value;
public double TabWidth => (double)numTabWidth.Value;
public double SlotWidth => (double)numSlotWidth.Value;
public void LoadBreakoff(Breakoff breakoff)
{
numTabs.Value = Clamp(numTabs, breakoff.TabCenters.Count == 0 ? Breakoff.DefaultTabCount : breakoff.TabCenters.Count);
numTabWidth.Value = Clamp(numTabWidth, breakoff.TabWidth);
numSlotWidth.Value = Clamp(numSlotWidth, breakoff.SlotWidth);
}
private static decimal Clamp(NumericUpDown input, double value) =>
System.Math.Clamp((decimal)value, input.Minimum, input.Maximum);
}
}
+176 -3
View File
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Bending;
using OpenNest.Breakoffs;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Converters;
@@ -49,6 +50,12 @@ namespace OpenNest.Forms
filterPanel.BendLineRemoved += OnBendLineRemoved;
filterPanel.BendLineEdited += OnBendLineEdited;
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
filterPanel.AddBreakoffClicked += OnAddBreakoffClicked;
filterPanel.BreakoffSelected += OnBreakoffSelected;
filterPanel.BreakoffEdited += OnBreakoffEdited;
filterPanel.BreakoffRemoved += OnBreakoffRemoved;
entityView1.BreakoffEdgePicked += OnBreakoffEdgePicked;
entityView1.TabDragged += OnTabDragged;
entityView1.LinePicked += OnLinePicked;
entityView1.PickCancelled += OnPickCancelled;
entityView1.TextConvertRequested += OnTextConvertRequested;
@@ -166,6 +173,12 @@ namespace OpenNest.Forms
entityView1.IsPickingBendLine = false;
filterPanel.SetPickMode(false);
}
if (entityView1.IsPickingBreakoff)
{
entityView1.IsPickingBreakoff = false;
filterPanel.SetBreakoffPickMode(false);
}
entityView1.SelectedBreakoffIndex = -1;
entityView1.OriginalEntities = chkShowOriginal.Checked ? item.OriginalEntities : null;
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
@@ -179,6 +192,7 @@ namespace OpenNest.Forms
ReHideSuppressedEntities(item);
filterPanel.LoadItem(item.Entities, item.Bends);
RefreshBreakoffs(item);
numQuantity.Value = item.Quantity;
txtCustomer.Text = item.Customer ?? "";
@@ -259,7 +273,7 @@ namespace OpenNest.Forms
filterPanel.ApplyFilters(item.Entities);
ReHidePromotedEntities(item.Bends);
SyncSuppressedState(item);
entityView1.Invalidate();
RefreshBreakoffs(item);
staleProgram = true;
}
@@ -287,7 +301,8 @@ namespace OpenNest.Forms
return;
}
var normalized = ShapeProfile.NormalizeEntities(entities);
var cut = BreakoffBuilder.Apply(entities, item.Breakoffs, out _);
var normalized = ShapeProfile.NormalizeEntities(cut);
programEditor.LoadEntities(normalized);
staleProgram = false;
}
@@ -464,6 +479,8 @@ namespace OpenNest.Forms
private void OnAddBendLineClicked(object sender, EventArgs e)
{
entityView1.IsPickingBreakoff = false;
filterPanel.SetBreakoffPickMode(false);
var active = !entityView1.IsPickingBendLine;
entityView1.IsPickingBendLine = active;
filterPanel.SetPickMode(active);
@@ -503,8 +520,162 @@ namespace OpenNest.Forms
{
entityView1.IsPickingBendLine = false;
filterPanel.SetPickMode(false);
entityView1.IsPickingBreakoff = false;
filterPanel.SetBreakoffPickMode(false);
}
private static List<Entity> VisibleEntities(FileListItem item) =>
item.Entities.Where(en => en.Layer.IsVisible && en.IsVisible).ToList();
private void RefreshBreakoffs(FileListItem item)
{
var visible = VisibleEntities(item);
var allEdges = item.Breakoffs.SelectMany(b => b.EdgeIds).ToList();
var layouts = item.Breakoffs.Select(b => BreakoffBuilder.Layout(visible, b, allEdges)).ToList();
entityView1.BreakoffLayouts = layouts;
filterPanel.LoadBreakoffs(layouts
.Select((l, i) => l.IsValid
? $"Breakoff {i + 1}: {l.TabCenters.Count} tabs"
: $"Breakoff {i + 1} (not applied): {l.Error}")
.ToList(), entityView1.SelectedBreakoffIndex);
entityView1.Invalidate();
}
private void OnAddBreakoffClicked(object sender, EventArgs e)
{
entityView1.IsPickingBendLine = false;
filterPanel.SetPickMode(false);
var active = !entityView1.IsPickingBreakoff;
entityView1.IsPickingBreakoff = active;
filterPanel.SetBreakoffPickMode(active);
}
private void OnBreakoffEdgePicked(object sender, Entity entity)
{
entityView1.IsPickingBreakoff = false;
filterPanel.SetBreakoffPickMode(false);
var item = CurrentItem;
if (item == null)
return;
var visible = VisibleEntities(item);
var chain = BreakoffBuilder.FindEdgeChain(visible, entity);
var used = item.Breakoffs.SelectMany(b => b.EdgeIds).ToHashSet();
if (chain.Any(en => used.Contains(en.Id)))
{
ShowBreakoffMessage("That edge already belongs to a breakoff.");
return;
}
var breakoff = new Breakoff { EdgeIds = chain.Select(en => en.Id).ToList() };
var layout = BreakoffBuilder.Layout(visible, breakoff, used);
if (!layout.IsValid)
{
ShowBreakoffMessage("Could not make a breakoff from that edge: " + layout.Error
+ "\n\nClick an outside edge of the scrap piece. Its edges must form one chain whose "
+ "ends touch the part outline.");
return;
}
using var dialog = new BreakoffDialog();
if (!AskBreakoffSettings(dialog))
return;
if (!TryConfigure(item, breakoff, dialog, keepTabs: false))
return;
item.Breakoffs.Add(breakoff);
entityView1.SelectedBreakoffIndex = item.Breakoffs.Count - 1;
RefreshBreakoffs(item);
staleProgram = true;
}
private void OnBreakoffEdited(object sender, int index)
{
var item = CurrentItem;
if (item == null || index < 0 || index >= item.Breakoffs.Count)
return;
var current = item.Breakoffs[index];
using var dialog = new BreakoffDialog();
dialog.LoadBreakoff(current);
if (!AskBreakoffSettings(dialog))
return;
var edited = current.Clone();
var keepTabs = dialog.TabCount == current.TabCenters.Count
&& Math.Tolerance.IsEqualTo(dialog.TabWidth, current.TabWidth);
if (!TryConfigure(item, edited, dialog, keepTabs))
return;
item.Breakoffs[index] = edited;
RefreshBreakoffs(item);
staleProgram = true;
}
// Applies the dialog's settings, spacing the tabs evenly unless asked to keep them, and
// refuses settings that do not resolve.
private bool TryConfigure(FileListItem item, Breakoff breakoff, BreakoffDialog dialog, bool keepTabs)
{
var visible = VisibleEntities(item);
var others = item.Breakoffs.Where(b => !b.EdgeIds.SequenceEqual(breakoff.EdgeIds))
.SelectMany(b => b.EdgeIds).ToList();
breakoff.TabWidth = dialog.TabWidth;
breakoff.SlotWidth = dialog.SlotWidth;
if (!keepTabs)
{
var probe = breakoff.Clone();
probe.TabCenters.Clear();
var length = BreakoffBuilder.Layout(visible, probe, others).Length;
breakoff.TabCenters = BreakoffBuilder.EvenTabCenters(length, dialog.TabCount, dialog.TabWidth);
}
var layout = BreakoffBuilder.Layout(visible, breakoff, others);
if (layout.IsValid)
return true;
ShowBreakoffMessage("Those settings do not fit this breakoff: " + layout.Error);
return false;
}
private void OnBreakoffRemoved(object sender, int index)
{
var item = CurrentItem;
if (item == null || index < 0 || index >= item.Breakoffs.Count)
return;
item.Breakoffs.RemoveAt(index);
entityView1.SelectedBreakoffIndex = -1;
RefreshBreakoffs(item);
staleProgram = true;
}
private void OnBreakoffSelected(object sender, int index)
{
entityView1.SelectedBreakoffIndex = index;
entityView1.Invalidate();
}
private void OnTabDragged(object sender, TabDragEventArgs e)
{
var item = CurrentItem;
if (item == null || e.Breakoff >= item.Breakoffs.Count)
return;
var breakoff = item.Breakoffs[e.Breakoff];
if (e.Tab >= breakoff.TabCenters.Count)
return;
breakoff.TabCenters[e.Tab] = e.Center;
entityView1.SelectedBreakoffIndex = e.Breakoff;
RefreshBreakoffs(item);
staleProgram = true;
}
internal virtual bool AskBreakoffSettings(BreakoffDialog dialog) =>
dialog.ShowDialog(this) == DialogResult.OK;
internal virtual void ShowBreakoffMessage(string message) =>
MessageBox.Show(this, message, "Breakoff", MessageBoxButtons.OK, MessageBoxIcon.Information);
private void OnTextConvertRequested(object sender, Controls.CadText text)
{
var item = CurrentItem;
@@ -815,6 +986,7 @@ namespace OpenNest.Forms
Quantity = drawing.Quantity.Required,
Customer = drawing.Customer ?? string.Empty,
Bends = drawing.Bends?.ToList() ?? new List<Bend>(),
Breakoffs = drawing.Breakoffs?.Select(b => b.Clone()).ToList() ?? new List<Breakoff>(),
SuppressedEntityIds =
drawing.SuppressedEntityIds.Count > 0
? new HashSet<Guid>(drawing.SuppressedEntityIds)
@@ -864,7 +1036,8 @@ namespace OpenNest.Forms
result.Bends,
item.Quantity,
item.Customer,
editedProgram
editedProgram,
item.Breakoffs
);
drawings.Add(drawing);
+43
View File
@@ -0,0 +1,43 @@
# Breakoffs
A breakoff is scrap drawn outside a part, such as a forming backgauge tab, that must
stay attached through cutting and forming and is snapped off afterwards. The CAD
Converter's **Breakoffs** panel adds one: **Add Breakoff**, then click any outside edge
of the scrap piece. The clicked edge grows into the open chain of visible lines and
arcs that continues as a single path; both ends of that chain must touch one closed
outline within 0.001. The settings dialog sets the tab count (both end tabs included,
at least 2), tab width and slot width; tabs start evenly spaced and can be dragged
along the break line. Edit re-spaces the tabs only when the count or tab width
changes.
## Geometry contract
`BreakoffBuilder` (`OpenNest.Core/Breakoffs`) resolves each `Drawing.Breakoffs` entry
against the entities being cut:
- The **break line** is the outline section between the chain's ends that, with the
chain, bounds the smaller region. The other region must equal the outline plus the
breakoff within 1%, so a chain inside the part is refused.
- The **outline** becomes the original outline with the break line replaced by the
chain. The chain's ends are snapped exactly onto the split points, because the
cutting planner closes contours within 1e-6 (a joining line is added when the end
entity is an arc).
- Between consecutive tabs, the break line is cut by a **closed slot** on the breakoff
side: its part side is the exact break line, and its scrap side runs `slotWidth`
into the breakoff. Near the breakoff's edges the slot is pulled back so at least
`tabWidth` of breakoff remains; its ends move inward until the slot is
`slotWidth / 4` deep. Interior tabs keep exactly their width; end tabs can be wider.
- Tab positions are measured from the chain end with the smaller X (then Y).
Slots are closed contours on purpose: `PreparedContours` accepts only closed
contours, so the open cuts this replaces could not get planned lead-ins. As internal
contours, every slot pierces inside itself, which is breakoff scrap. The automatic
entry may still finish its lead-in at a slot corner on the break line, which is a tab
edge.
Breakoffs that do not resolve (hidden or missing edges, overlapping tabs, no room for
a slot) are listed in the panel with the reason and are not applied, but are kept on
the drawing. `CadImporter.BuildDrawing` applies breakoffs to the program and copies
them to the drawing; `SourceEntities` keep the original edges so a reopened drawing
can be edited. Split pieces get no breakoffs. The breakoff counts as part material
for area, nesting and overlap checks.
+10
View File
@@ -177,6 +177,16 @@ A placement may write `drawingPinsOff: true` to ignore its drawing's pins on tha
part only; it is omitted when false. Any value other than a boolean loads as
false (the drawing's pins apply) with a warning naming the plate and part.
## Drawing breakoffs
A drawing may carry a `breakoffs` array, omitted when empty. Each entry has
`edgeIds` (source entity IDs of the breakoff's outside edges), `tabWidth`,
`slotWidth` and `tabCenters` (distances along the break line), all in the source
frame; see [breakoffs](breakoffs.md). The stored program already has them applied,
so readers never rebuild geometry from them. An entry with no edges, a width that is
not positive and finite, or a nonfinite tab center is dropped with a reader warning;
the drawing and its program load unchanged, only the breakoff stops being editable.
## Verification and limits
The automated tests cover save/reopen/re-save, rotated/manual/locked parts,