mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 00:21:06 -04:00
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:
1 parent
ca09c33c38
commit
6fd14c5452
21 files changed
+1965
-8
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user