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