diff --git a/AGENTS.md b/AGENTS.md index ada49cdd..730e6b6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,7 @@ Read the relevant contract before changing its behavior: - [Lead-in placement](docs/geometry/lead-in-placement.md) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback - [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material - [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them +- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level - [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/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs index 110d5b7f..69687cc7 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs @@ -36,6 +36,12 @@ internal enum AutomaticEntryKind /// A drawing pin's own entry: the one and only candidate of a pinned contour. Pinned = 7, + + /// + /// The perimeter point where a tab best keeps the part level on its support bed; only a + /// perimeter tabbed for support has it (see ). + /// + SupportTab = 8, } /// diff --git a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs index 1dd731d3..a8a8c793 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs @@ -45,6 +45,7 @@ internal static class OwnedCuttingParameters ? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null : CopyTab(source.TabConfig), TabsEnabled = source.TabsEnabled, + SupportTabs = source.SupportTabs, Sequencing = new SequenceParameters { Method = sequence.Method, diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index b8fd211b..1dcaf876 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -5,6 +5,7 @@ using System.Threading; using OpenNest.CNC.CuttingStrategy; using OpenNest.Diagnostics; using OpenNest.Geometry; +using OpenNest.Support; namespace OpenNest.CNC.CuttingPlanning; @@ -41,6 +42,10 @@ public sealed class PreparedContours private readonly ContourChoice[] pinned; private readonly CuttingParameters[] contourParameters; private readonly int[] pinNumbers; + // Set when the support bed cannot hold the part level: the perimeter is tabbed and its + // automatic entries keep to points where the tab steadies the part. World = local + origin. + private readonly PartSupport support; + private readonly Vector supportOrigin; private PreparedContours(Shape[] shapes, List scribes, List breakCuts, List tabbedCutouts, CuttingParameters parameters) @@ -87,6 +92,47 @@ public sealed class PreparedContours RejectedPins = rejected.AsReadOnly(); } + // A copy that tabs the perimeter for support; every pin keeps its entry, now owned here. + private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab) + : this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters) + { + for (var contour = 0; contour < Count; contour++) + { + pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null; + pinNumbers[contour] = source.pinNumbers[contour]; + } + contourParameters = source.contourParameters == null + ? new CuttingParameters[Count] + : (CuttingParameters[])source.contourParameters.Clone(); + var perimeter = OwnedCuttingParameters.Copy(source.ParametersFor(PerimeterOrdinal)); + perimeter.TabsEnabled = true; + perimeter.TabConfig = OwnedCuttingParameters.CopyTab(tab); + contourParameters[PerimeterOrdinal] = perimeter; + RejectedPins = source.RejectedPins; + this.support = support; + supportOrigin = location; + } + + /// + /// A copy whose perimeter is tabbed with where it steadies the part + /// on its support bed: analyzed this part's material placed at + /// , and the perimeter's automatic entries keep to points it + /// accepts () plus its best tab point. A pinned + /// perimeter is returned unchanged: the drawing wins. + /// + public PreparedContours WithSupportTab(PartSupport support, Vector location, Tab tab) + { + ArgumentNullException.ThrowIfNull(support); + ArgumentNullException.ThrowIfNull(tab); + PostVerificationGeometry.Validate(location); + if (!support.IsComplete || support.BestTabPoint == null) + throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support)); + return IsPinned(PerimeterOrdinal) ? this : new PreparedContours(this, support, location, tab); + } + + /// The support analysis that tabs this perimeter, or null when it is not tabbed for support. + public PartSupport Support => support; + public int Count => shapes.Length; /// The pierce zone every lead-in pierce of these contours keeps; 0 when off. @@ -355,7 +401,7 @@ public sealed class PreparedContours return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) }; if (IsSingleCircle(shape)) throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue."); - return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token)); + return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), false); } /// @@ -372,7 +418,8 @@ public sealed class PreparedContours internal IReadOnlyList AutomaticEntryCandidatesWithFallbacks( int contourOrdinal, Vector? lookAhead = null, - CancellationToken token = default) + CancellationToken token = default, + bool ignoreSupport = false) { token.ThrowIfCancellationRequested(); var shape = GetShape(contourOrdinal); @@ -382,7 +429,28 @@ public sealed class PreparedContours ? new List() : PreferredCandidates(shape, contourOrdinal, token); all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token)); - return MergeByGeometry(all); + return SupportFiltered(contourOrdinal, all, ignoreSupport); + } + + /// + /// Whether this preparation tabs its perimeter for support (see ): + /// its automatic catalogue then keeps to steadying points unless asked to ignore support. + /// + internal bool HasSupportTab => support != null; + + // A perimeter tabbed for support keeps the candidates whose tab steadies the part, and gains + // the outline's best tab point; with ignoreSupport (the search's fallback when none of them + // has a lead-in that fits) every candidate stays, still tabbed. + private IReadOnlyList SupportFiltered(int contourOrdinal, + List candidates, bool ignoreSupport) + { + if (support == null || contourOrdinal != PerimeterOrdinal || ignoreSupport) + return MergeByGeometry(candidates); + var kept = candidates.Where(c => support.AcceptsTabAt(c.Choice.Point + supportOrigin)).ToList(); + var best = ClosestEntry(PerimeterOrdinal, support.BestTabPoint!.Value - supportOrigin); + if (!IsForbiddenVertex(GetShape(PerimeterOrdinal), best.Point)) + kept.Add(new ContourEntryCandidate(best, AutomaticEntryKind.SupportTab)); + return MergeByGeometry(kept); } private static bool IsSingleCircle(Shape shape) => diff --git a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs index 735513e6..9312b127 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs @@ -93,6 +93,7 @@ internal static class StateFingerprint Number(p.AutoTabMaxSize); TabSettings(p.TabConfig); Flag(p.TabsEnabled); + Flag(p.SupportTabs); Sequence(p.Sequencing); Assignment(p.Assignment); text.Append('}'); diff --git a/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs b/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs index 881770af..9dbd1187 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs @@ -46,6 +46,13 @@ namespace OpenNest.CNC.CuttingStrategy public Tab TabConfig { get; set; } public bool TabsEnabled { get; set; } + /// + /// Tabs each part its plate's support bed cannot hold level once it is cut free, at the + /// perimeter point that best keeps its center of gravity over the bed's contacts (see + /// docs/support-tabs.md). Needs for the tab size. + /// + public bool SupportTabs { get; set; } + public SequenceParameters Sequencing { get; set; } = new SequenceParameters(); public AssignmentParameters Assignment { get; set; } = new AssignmentParameters(); } diff --git a/OpenNest.Core/Nest.cs b/OpenNest.Core/Nest.cs index 8ac26428..6fc60299 100644 --- a/OpenNest.Core/Nest.cs +++ b/OpenNest.Core/Nest.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using OpenNest.Collections; using OpenNest.Geometry; +using OpenNest.Support; namespace OpenNest { @@ -18,6 +19,7 @@ namespace OpenNest Name = name; Plates = new ObservableList(); Plates.ItemRemoved += Plates_PlateRemoved; + Plates.ItemAdded += Plates_PlateAdded; Drawings = new DrawingCollection(); PlateDefaults = new PlateSettings(); Material = new Material(); @@ -30,6 +32,13 @@ namespace OpenNest e.Item.Parts.Clear(); } + // Every sheet of the nest rests on the same machine bed. + private void Plates_PlateAdded(object sender, ItemAddedEventArgs e) + { + if (e.Item != null) + e.Item.SupportBed = SupportBed; + } + public string Name { get; set; } public string Customer { get; set; } @@ -80,6 +89,20 @@ namespace OpenNest plate.SetClampRail(rail); } + /// + /// The machine support bed every sheet of this nest rests on, or null for none. Set + /// through ; plates added later follow it too. + /// + public SupportBed SupportBed { get; private set; } + + /// Makes this nest's support bed and every plate's. + public void SetSupportBed(SupportBed bed) + { + SupportBed = bed; + foreach (var plate in Plates) + plate.SupportBed = bed; + } + public void UpdateDrawingQuantities() { foreach (var drawing in Drawings) diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 2c1aa62c..c92b5670 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -6,6 +6,7 @@ using OpenNest.Collections; using OpenNest.Geometry; using OpenNest.Math; using OpenNest.Shapes; +using OpenNest.Support; namespace OpenNest { @@ -142,6 +143,13 @@ namespace OpenNest /// public ClampRail ClampRail { get; private set; } + /// + /// The machine support bed this sheet rests on (its nest's, see + /// ), or null for none. Display, analysis and support + /// tabs only: never material, spacing or a keep-out. + /// + public SupportBed SupportBed { get; set; } + private static readonly IReadOnlySet NoRemovedClamps = ImmutableHashSet.Empty; /// diff --git a/OpenNest.Core/Support/SupportAnalyzer.cs b/OpenNest.Core/Support/SupportAnalyzer.cs new file mode 100644 index 00000000..94248151 --- /dev/null +++ b/OpenNest.Core/Support/SupportAnalyzer.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Support +{ + /// + /// Decides whether a cut part stays level on its support bed. Plate is of even thickness, + /// so the part's center of gravity is its area centroid; the part rests on the bed's + /// contacts under its material and stays level while that center lies inside their convex + /// hull. The margin is how far inside (positive) or outside (negative) it lies; a part with + /// fewer than three contacts that are not in line can only balance, so its margin is at + /// most zero. With a , the bed is also + /// checked shifted by that much along each axis and diagonally, keeping the worst margin. + /// A perimeter tab holds the part at the tab, so a tab point counts as one more contact. + /// + public static class SupportAnalyzer + { + /// Points sampled along the outline, besides its vertices, to find the best tab point. + public const int TabSamples = 256; + + public static PartSupport Analyze(SupportBed bed, int quadrant, Part part) => + Analyze(bed, quadrant, SupportMaterial.FromPart(part)); + + public static PartSupport Analyze(SupportBed bed, int quadrant, SupportMaterial material) + { + ArgumentNullException.ThrowIfNull(bed); + ArgumentNullException.ThrowIfNull(material); + if (!material.IsComplete) + return new PartSupport(material.Reason); + if (!material.TryGetCenterOfGravity(out var center, out _)) + return new PartSupport("The part encloses no area."); + + var hulls = Shifts(bed.RegistrationTolerance) + .Select(shift => Hull(bed.Contacts(material, quadrant, shift, extremesOnly: true))) + .ToArray(); + var margin = hulls.Min(hull => Margin(center, hull)); + var contacts = bed.Contacts(material, quadrant, Vector.Zero); + + Vector? best = null; + var bestMargin = double.NegativeInfinity; + foreach (var point in OutlineSamples(material.Outline)) + { + var withTab = MarginWith(center, hulls, point); + if (withTab > bestMargin) + { + best = point; + bestMargin = withTab; + } + } + + return new PartSupport(center, contacts, margin, bed.StabilityMargin, hulls, best, bestMargin); + } + + /// The worst margin of over every shifted hull, each with added. + internal static double MarginWith(Vector center, IReadOnlyList hulls, Vector tab) + { + var worst = double.PositiveInfinity; + foreach (var hull in hulls) + { + var points = new Vector[hull.Length + 1]; + hull.CopyTo(points, 0); + points[^1] = tab; + worst = System.Math.Min(worst, Margin(center, Hull(points))); + } + return worst; + } + + /// + /// Signed distance of inside the convex polygon + /// (counter-clockwise): positive inside, negative outside, and + /// never positive for a point or segment hull. An empty hull is negative infinity. + /// + internal static double Margin(Vector point, IReadOnlyList hull) + { + switch (hull.Count) + { + case 0: + return double.NegativeInfinity; + case 1: + return -point.DistanceTo(hull[0]); + case 2: + return -SegmentDistance(point, hull[0], hull[1]); + } + + var inside = double.PositiveInfinity; + var outside = double.PositiveInfinity; + var isInside = true; + for (var i = 0; i < hull.Count; i++) + { + var a = hull[i]; + var b = hull[(i + 1) % hull.Count]; + var edge = b - a; + var length = System.Math.Sqrt(edge.X * edge.X + edge.Y * edge.Y); + var side = (edge.X * (point.Y - a.Y) - edge.Y * (point.X - a.X)) / length; + if (side < 0) + isInside = false; + inside = System.Math.Min(inside, side); + outside = System.Math.Min(outside, SegmentDistance(point, a, b)); + } + return isInside ? inside : -outside; + } + + /// The convex hull, counter-clockwise, without repeated or collinear points (monotone chain). + internal static Vector[] Hull(IEnumerable points) + { + var sorted = points.Distinct().OrderBy(p => p.X).ThenBy(p => p.Y).ToArray(); + if (sorted.Length < 3) + return sorted; + + var hull = new Vector[sorted.Length * 2]; + var count = 0; + foreach (var point in sorted) + { + while (count >= 2 && Cross(hull[count - 2], hull[count - 1], point) <= 0) + count--; + hull[count++] = point; + } + var lower = count + 1; + for (var i = sorted.Length - 2; i >= 0; i--) + { + while (count >= lower && Cross(hull[count - 2], hull[count - 1], sorted[i]) <= 0) + count--; + hull[count++] = sorted[i]; + } + // The last point repeats the first. + return hull.Take(count - 1).ToArray(); + } + + private static double Cross(Vector o, Vector a, Vector b) => + (a.X - o.X) * (b.Y - o.Y) - (a.Y - o.Y) * (b.X - o.X); + + private static double SegmentDistance(Vector point, Vector a, Vector b) + { + var edge = b - a; + var lengthSquared = edge.X * edge.X + edge.Y * edge.Y; + if (lengthSquared <= 0) + return point.DistanceTo(a); + var t = System.Math.Clamp(((point.X - a.X) * edge.X + (point.Y - a.Y) * edge.Y) / lengthSquared, 0, 1); + return point.DistanceTo(a + edge * t); + } + + private static IEnumerable Shifts(double tolerance) + { + if (tolerance <= 0) + return new[] { Vector.Zero }; + var steps = new[] { -tolerance, 0, tolerance }; + return steps.SelectMany(x => steps.Select(y => new Vector(x, y))); + } + + // Every outline vertex, then points spaced evenly along the outline. + private static IEnumerable OutlineSamples(IReadOnlyList outline) + { + foreach (var vertex in outline) + yield return vertex; + + var perimeter = 0.0; + for (var i = 0; i < outline.Count; i++) + perimeter += outline[i].DistanceTo(outline[(i + 1) % outline.Count]); + if (!(perimeter > 0)) + yield break; + + var step = perimeter / TabSamples; + var travelled = 0.0; + var next = step; + for (var i = 0; i < outline.Count; i++) + { + var a = outline[i]; + var b = outline[(i + 1) % outline.Count]; + var length = a.DistanceTo(b); + while (length > 0 && next < travelled + length) + { + yield return a + (b - a) * ((next - travelled) / length); + next += step; + } + travelled += length; + } + } + } + + /// + /// How one placed part sits on its support bed (world coordinates): see + /// . Immutable. + /// + public sealed class PartSupport + { + private readonly IReadOnlyList hulls = Array.Empty(); + + internal PartSupport(string reason) + { + Reason = reason ?? "The part's support could not be analyzed."; + Contacts = Array.Empty(); + } + + internal PartSupport(Vector centerOfGravity, IReadOnlyList contacts, double margin, + double requiredMargin, IReadOnlyList hulls, Vector? bestTabPoint, double bestTabMargin) + { + CenterOfGravity = centerOfGravity; + Contacts = contacts; + Margin = margin; + RequiredMargin = requiredMargin; + this.hulls = hulls; + BestTabPoint = bestTabPoint; + BestTabMargin = bestTabMargin; + } + + /// True when the part was analyzed; otherwise says why not. + public bool IsComplete => Reason == null; + + public string Reason { get; } + + public Vector CenterOfGravity { get; } = Vector.Invalid; + + /// Every bed contact under the part at the bed's nominal position. + public IReadOnlyList Contacts { get; } + + /// The worst signed distance of the center of gravity inside the contacts' hull. + public double Margin { get; } = double.NegativeInfinity; + + /// The bed's . + public double RequiredMargin { get; } + + /// True when the bed alone holds the part level by at least the required margin. + public bool IsSupported => IsComplete && Meets(Margin); + + /// The outline point where one tab steadies the part most, or null when not analyzed. + public Vector? BestTabPoint { get; } + + /// The margin with a tab at . + public double BestTabMargin { get; } = double.NegativeInfinity; + + /// True when a tab at holds the part level by the required margin. + public bool TabCanSupport => IsComplete && Meets(BestTabMargin); + + /// The worst margin with a tab holding the part at . + public double MarginWith(Vector tab) => + IsComplete ? SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab) : double.NegativeInfinity; + + /// + /// Whether a tab at is good enough: it holds the part level by the + /// required margin, or, where no single tab can, it is as good as the best one found. + /// + public bool AcceptsTabAt(Vector tab) + { + if (!IsComplete) + return false; + var margin = MarginWith(tab); + return TabCanSupport ? Meets(margin) : margin >= BestTabMargin - Math.Tolerance.Epsilon; + } + + private bool Meets(double margin) => margin > 0 && margin >= RequiredMargin; + } +} diff --git a/OpenNest.Core/Support/SupportBed.cs b/OpenNest.Core/Support/SupportBed.cs new file mode 100644 index 00000000..59f5d0ca --- /dev/null +++ b/OpenNest.Core/Support/SupportBed.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Support +{ + /// + /// What a cut part rests on: the machine's support bed, fixed to the machine and measured + /// from the plate origin into the sheet, so its position on any sheet follows from the + /// sheet's quadrant alone. A layout only reports where it touches material; the + /// decides from those contacts whether a part stays level. + /// The laser grid () is the built-in layout; straight or curved + /// slats imported from a CAD layout can follow as further subclasses. + /// + public abstract class SupportBed : IEquatable + { + protected SupportBed(double registrationTolerance, double stabilityMargin) + { + if (!double.IsFinite(registrationTolerance) || registrationTolerance < 0) + throw new ArgumentOutOfRangeException(nameof(registrationTolerance), + "Registration tolerance must be finite and zero or more."); + if (!double.IsFinite(stabilityMargin) || stabilityMargin < 0) + throw new ArgumentOutOfRangeException(nameof(stabilityMargin), + "Stability margin must be finite and zero or more."); + RegistrationTolerance = registrationTolerance; + StabilityMargin = stabilityMargin; + } + + /// + /// How far a sheet may sit from its nominal place on the bed, along each axis. The + /// analyzer checks the bed shifted by up to this much and keeps the worst result. + /// + public double RegistrationTolerance { get; } + + /// + /// How far inside its contacts a part's center of gravity must lie for the part to count + /// as supported; it allows for slag, gas pressure and a contact that is worn low. + /// + public double StabilityMargin { get; } + + /// + /// The contacts under (world coordinates) on a sheet in + /// , with the bed moved by . With + /// , a layout may omit contacts that cannot be corners of + /// their convex hull; the analyzer needs no others. + /// + public abstract IReadOnlyList Contacts(SupportMaterial material, int quadrant, Vector shift, + bool extremesOnly = false); + + /// This layout with every length multiplied by (a unit change). + public abstract SupportBed Scaled(double factor); + + public abstract bool Equals(SupportBed other); + + public override bool Equals(object obj) => obj is SupportBed other && Equals(other); + + public abstract override int GetHashCode(); + + /// -1 where a sheet in lies on the negative side of the origin along X. + protected static double SignX(int quadrant) => quadrant is 2 or 3 ? -1 : 1; + + /// -1 where a sheet in lies on the negative side of the origin along Y. + protected static double SignY(int quadrant) => quadrant is 3 or 4 ? -1 : 1; + } +} diff --git a/OpenNest.Core/Support/SupportGrid.cs b/OpenNest.Core/Support/SupportGrid.cs new file mode 100644 index 00000000..05191589 --- /dev/null +++ b/OpenNest.Core/Support/SupportGrid.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Support +{ + /// + /// A laser bed whose parts rest on a regular grid of contact points: one point every + /// along X and every along Y, the first at + /// (, ) measured from the plate origin into the + /// sheet. A sheet in another quadrant sees the same grid mirrored, since it lies on the + /// other side of the origin. Lengths are in the nest's units. + /// + public sealed class SupportGrid : SupportBed + { + public SupportGrid(double pitchX, double pitchY, double offsetX = 0, double offsetY = 0, + double registrationTolerance = 0, double stabilityMargin = 0) + : base(registrationTolerance, stabilityMargin) + { + PitchX = Positive(pitchX, nameof(pitchX)); + PitchY = Positive(pitchY, nameof(pitchY)); + OffsetX = Finite(offsetX, nameof(offsetX)); + OffsetY = Finite(offsetY, nameof(offsetY)); + } + + public double PitchX { get; } + public double PitchY { get; } + public double OffsetX { get; } + public double OffsetY { get; } + + public override IReadOnlyList Contacts(SupportMaterial material, int quadrant, Vector shift, + bool extremesOnly = false) + { + ArgumentNullException.ThrowIfNull(material); + var contacts = new List(); + if (!material.IsComplete) + return contacts; + + // The grid's world lattice: OffsetX into the sheet from the origin, then every PitchX. + var baseX = SignX(quadrant) * OffsetX + shift.X; + var baseY = SignY(quadrant) * OffsetY + shift.Y; + var bounds = material.Bounds; + var firstRow = (long)System.Math.Ceiling((bounds.Bottom - baseY) / PitchY); + var lastRow = (long)System.Math.Floor((bounds.Top - baseY) / PitchY); + for (var row = firstRow; row <= lastRow; row++) + { + var y = baseY + row * PitchY; + foreach (var (start, end) in material.SpansAtY(y)) + { + var first = (long)System.Math.Ceiling((start - baseX) / PitchX); + var last = (long)System.Math.Floor((end - baseX) / PitchX); + if (first > last) + continue; + // Within one span only its end points can be corners of the contacts' hull. + if (extremesOnly) + { + contacts.Add(new Vector(baseX + first * PitchX, y)); + if (last != first) + contacts.Add(new Vector(baseX + last * PitchX, y)); + continue; + } + for (var column = first; column <= last; column++) + contacts.Add(new Vector(baseX + column * PitchX, y)); + } + } + return contacts; + } + + /// + /// The grid points within (world coordinates) on a sheet in + /// , for display; empty when there would be more than + /// . + /// + public IReadOnlyList PointsIn(Box region, int quadrant, int limit = 20000) + { + ArgumentNullException.ThrowIfNull(region); + var baseX = SignX(quadrant) * OffsetX; + var baseY = SignY(quadrant) * OffsetY; + var firstColumn = System.Math.Ceiling((region.Left - baseX) / PitchX); + var lastColumn = System.Math.Floor((region.Right - baseX) / PitchX); + var firstRow = System.Math.Ceiling((region.Bottom - baseY) / PitchY); + var lastRow = System.Math.Floor((region.Top - baseY) / PitchY); + var count = (lastColumn - firstColumn + 1) * (lastRow - firstRow + 1); + if (!double.IsFinite(count) || count <= 0 || count > limit) + return Array.Empty(); + + var points = new List((int)count); + for (var row = firstRow; row <= lastRow; row++) + for (var column = firstColumn; column <= lastColumn; column++) + points.Add(new Vector(baseX + column * PitchX, baseY + row * PitchY)); + return points; + } + + public override SupportBed Scaled(double factor) => + new SupportGrid(PitchX * factor, PitchY * factor, OffsetX * factor, OffsetY * factor, + RegistrationTolerance * factor, StabilityMargin * factor); + + public override bool Equals(SupportBed other) => + other is SupportGrid grid + && grid.PitchX == PitchX && grid.PitchY == PitchY + && grid.OffsetX == OffsetX && grid.OffsetY == OffsetY + && grid.RegistrationTolerance == RegistrationTolerance + && grid.StabilityMargin == StabilityMargin; + + public override int GetHashCode() => + HashCode.Combine(PitchX, PitchY, OffsetX, OffsetY, RegistrationTolerance, StabilityMargin); + + private static double Positive(double value, string name) => + double.IsFinite(value) && value > 0 ? value + : throw new ArgumentOutOfRangeException(name, "Grid pitch must be finite and positive."); + + private static double Finite(double value, string name) => + double.IsFinite(value) ? value + : throw new ArgumentOutOfRangeException(name, "Grid offset must be finite."); + } +} diff --git a/OpenNest.Core/Support/SupportMaterial.cs b/OpenNest.Core/Support/SupportMaterial.cs new file mode 100644 index 00000000..40f4c16d --- /dev/null +++ b/OpenNest.Core/Support/SupportMaterial.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Support +{ + /// + /// The material of one placed part as it drops onto the support bed, in world coordinates: + /// its outline minus its holes, chorded at . A tabbed cutout's + /// slug stays attached while the sheet is cut (see ), + /// so it counts as part. Immutable; an unusable program gives an incomplete material. + /// + public sealed class SupportMaterial + { + /// Chord tolerance for arcs, in model units. + public const double ChordTolerance = 0.001; + + private readonly Vector[] outline; + private readonly Vector[][] holes; + private readonly Box bounds = new(); + + private SupportMaterial(Vector[] outline, Vector[][] holes, string reason) + { + this.outline = outline ?? Array.Empty(); + this.holes = holes ?? Array.Empty(); + Reason = reason; + if (reason != null) + return; + + var minX = double.PositiveInfinity; + var minY = double.PositiveInfinity; + var maxX = double.NegativeInfinity; + var maxY = double.NegativeInfinity; + foreach (var point in this.outline) + { + minX = System.Math.Min(minX, point.X); + minY = System.Math.Min(minY, point.Y); + maxX = System.Math.Max(maxX, point.X); + maxY = System.Math.Max(maxY, point.Y); + } + bounds = new Box(minX, minY, maxX - minX, maxY - minY); + } + + /// True when the material was captured; otherwise says why not. + public bool IsComplete => Reason == null; + + public string Reason { get; } + + /// The outline's vertices, without a repeated closing vertex. + public IReadOnlyList Outline => outline; + + /// Each hole's vertices, without a repeated closing vertex. + public IReadOnlyList> Holes => holes; + + /// A copy of the outline's bounding box. + public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width); + + /// + /// Material from an outline and holes already in world coordinates. Fewer than three + /// vertices, or a nonfinite one, gives an incomplete material. + /// + public static SupportMaterial FromPolygons(IEnumerable outline, IEnumerable> holes = null) + { + var ring = Ring(outline); + if (ring == null) + return Incomplete("The part outline is not a polygon."); + var cutouts = new List(); + foreach (var hole in holes ?? Enumerable.Empty>()) + { + var cutout = Ring(hole); + if (cutout == null) + return Incomplete("A part hole is not a polygon."); + cutouts.Add(cutout); + } + return new SupportMaterial(ring, cutouts.ToArray(), null); + } + + /// The material of a placed part: its program at its location. + public static SupportMaterial FromPart(Part part) + { + ArgumentNullException.ThrowIfNull(part); + return FromProgram(part.Program, part.Location); + } + + /// + /// The material of (part coordinates, rotation applied) placed + /// at . Leads, rapids, scribes and break cuts are not material. + /// A perimeter left open by a tab still bounds its material, closed across the tab. + /// + public static SupportMaterial FromProgram(Program program, Vector location) + { + ArgumentNullException.ThrowIfNull(program); + try + { + var entities = ConvertProgram.ToGeometry(program) + .Where(e => e.Layer != SpecialLayers.Leadin && e.Layer != SpecialLayers.Leadout) + .ForNesting(); + if (entities.Count == 0) + return Incomplete("The part has no material contour."); + + var profile = new ShapeProfile(entities); + var ring = Ring(Chord(profile.Perimeter, location)); + if (ring == null) + return Incomplete("The part outline is not a polygon."); + var cutouts = new List(); + foreach (var cutout in profile.Cutouts) + if (Ring(Chord(cutout, location)) is { } hole) + cutouts.Add(hole); + return new SupportMaterial(ring, cutouts.ToArray(), null); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or NotSupportedException + or ArithmeticException or IndexOutOfRangeException) + { + return Incomplete(ex.Message); + } + } + + private static SupportMaterial Incomplete(string reason) => new(null, null, reason); + + private static IEnumerable Chord(Shape shape, Vector location) => + shape.ToPolygonWithTolerance(ChordTolerance).Vertices.Select(v => v + location); + + // The distinct vertices of a closed ring, or null when it cannot bound any area. + private static Vector[] Ring(IEnumerable vertices) + { + if (vertices == null) + return null; + var ring = new List(); + foreach (var vertex in vertices) + { + if (!double.IsFinite(vertex.X) || !double.IsFinite(vertex.Y)) + return null; + if (ring.Count == 0 || ring[^1] != vertex) + ring.Add(vertex); + } + if (ring.Count > 1 && ring[0] == ring[^1]) + ring.RemoveAt(ring.Count - 1); + return ring.Count < 3 ? null : ring.ToArray(); + } + + /// + /// The material's area and center of gravity (its area centroid: plate is of even + /// thickness), or false when the material is incomplete or encloses no area. + /// + public bool TryGetCenterOfGravity(out Vector centerOfGravity, out double area) + { + centerOfGravity = Vector.Invalid; + area = 0; + if (!IsComplete || !PolygonAreaMoments.TryCompute(outline, out var whole)) + return false; + var cutouts = new List(); + foreach (var hole in holes) + if (PolygonAreaMoments.TryCompute(hole, out var moments)) + cutouts.Add(moments); + if (!PolygonAreaMoments.TrySubtract(whole, cutouts, out var net)) + return false; + centerOfGravity = net.Centroid; + area = net.Area; + return true; + } + + /// + /// The spans of the horizontal line at that lie in material, in + /// ascending order. A line along an edge or through a vertex counts that crossing once, + /// on the half-open rule, so touching is not crossing. + /// + public IReadOnlyList<(double Start, double End)> SpansAtY(double y) + { + var crossings = new List(); + if (!IsComplete || y < bounds.Bottom || y > bounds.Top) + return Array.Empty<(double, double)>(); + AddCrossings(outline, y, crossings); + foreach (var hole in holes) + AddCrossings(hole, y, crossings); + crossings.Sort(); + var spans = new List<(double, double)>(crossings.Count / 2); + for (var i = 0; i + 1 < crossings.Count; i += 2) + spans.Add((crossings[i], crossings[i + 1])); + return spans; + } + + /// Whether lies in material (even-odd over outline and holes). + public bool Contains(Vector point) + { + if (!IsComplete) + return false; + var inside = Inside(outline, point); + foreach (var hole in holes) + if (Inside(hole, point)) + inside = !inside; + return inside; + } + + private static void AddCrossings(Vector[] ring, double y, List crossings) + { + for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++) + { + var a = ring[j]; + var b = ring[i]; + if ((a.Y > y) != (b.Y > y)) + crossings.Add(a.X + (y - a.Y) * (b.X - a.X) / (b.Y - a.Y)); + } + } + + private static bool Inside(Vector[] ring, Vector point) + { + var inside = false; + for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++) + { + var a = ring[j]; + var b = ring[i]; + if ((a.Y > point.Y) != (b.Y > point.Y) + && point.X < a.X + (point.Y - a.Y) * (b.X - a.X) / (b.Y - a.Y)) + inside = !inside; + } + return inside; + } + } +} diff --git a/OpenNest.Data/SupportGridSettings.cs b/OpenNest.Data/SupportGridSettings.cs new file mode 100644 index 00000000..247a5c2d --- /dev/null +++ b/OpenNest.Data/SupportGridSettings.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using OpenNest.Support; + +namespace OpenNest.Data; + +/// +/// The machine's laser support grid, persisted to a single JSON file (by default +/// %APPDATA%\OpenNest\support-grid.json). Off by default: until is set, +/// new nests get no support bed. Loading never throws; an invalid value falls back to its +/// default and a file that cannot be read loads as off. +/// +public sealed class SupportGridSettings +{ + public const int CurrentVersion = 1; + + private const double MillimetersPerInch = 25.4; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// Whether new nests receive this grid. Default off. + public bool Enabled { get; set; } + + /// Units of every length below. + public Units Units { get; set; } = Units.Inches; + + public double PitchX { get; set; } = 2; + public double PitchY { get; set; } = 2; + public double OffsetX { get; set; } + public double OffsetY { get; set; } + public double RegistrationTolerance { get; set; } = 0.125; + public double StabilityMargin { get; set; } = 0.25; + + /// %APPDATA%\OpenNest\support-grid.json. + public static string DefaultPath => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenNest", + "support-grid.json" + ); + + /// Settings holding (or the defaults when null) in . + public static SupportGridSettings FromGrid(SupportGrid grid, Units units, bool enabled) + { + var settings = new SupportGridSettings().ConvertedTo(units); + settings.Enabled = enabled; + if (grid != null) + { + settings.PitchX = grid.PitchX; + settings.PitchY = grid.PitchY; + settings.OffsetX = grid.OffsetX; + settings.OffsetY = grid.OffsetY; + settings.RegistrationTolerance = grid.RegistrationTolerance; + settings.StabilityMargin = grid.StabilityMargin; + } + return settings; + } + + /// The grid these settings describe in , or null when it is invalid. + public SupportGrid GridFor(Units units) + { + var converted = ConvertedTo(units); + try + { + return new SupportGrid(converted.PitchX, converted.PitchY, converted.OffsetX, converted.OffsetY, + converted.RegistrationTolerance, converted.StabilityMargin); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } + + /// A copy with every length expressed in (inches and millimeters convert at 25.4). + public SupportGridSettings ConvertedTo(Units units) + { + var scale = Units == units ? 1 : Units == Units.Inches ? MillimetersPerInch : 1 / MillimetersPerInch; + return new SupportGridSettings + { + Enabled = Enabled, + Units = units, + PitchX = PitchX * scale, + PitchY = PitchY * scale, + OffsetX = OffsetX * scale, + OffsetY = OffsetY * scale, + RegistrationTolerance = RegistrationTolerance * scale, + StabilityMargin = StabilityMargin * scale, + }; + } + + /// Gives a newly created nest this grid when enabled (after its units are set). + public void ApplyToNewNest(Nest nest) + { + ArgumentNullException.ThrowIfNull(nest); + if (Enabled && GridFor(nest.Units) is { } grid) + nest.SetSupportBed(grid); + } + + /// + /// Loads the settings, never throwing. A missing or unreadable file yields the defaults + /// (off); each invalid value in a readable file keeps its default. + /// + public static SupportGridSettings Load(string path) + { + var settings = new SupportGridSettings(); + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return settings; + + try + { + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + return settings; + + if (root.TryGetProperty("enabled", out var enabled) && enabled.ValueKind is JsonValueKind.True or JsonValueKind.False) + settings.Enabled = enabled.GetBoolean(); + // Lengths read below are in the file's units; the defaults are converted to them first. + if (root.TryGetProperty("units", out var units) && units.ValueKind == JsonValueKind.String) + foreach (var unit in Enum.GetValues()) + if (string.Equals(unit.ToString(), units.GetString(), StringComparison.OrdinalIgnoreCase)) + settings = settings.ConvertedTo(unit); + + settings.PitchX = Length(root, "pitchX", settings.PitchX, positive: true); + settings.PitchY = Length(root, "pitchY", settings.PitchY, positive: true); + settings.OffsetX = Length(root, "offsetX", settings.OffsetX, positive: false, signed: true); + settings.OffsetY = Length(root, "offsetY", settings.OffsetY, positive: false, signed: true); + settings.RegistrationTolerance = Length(root, "registrationTolerance", settings.RegistrationTolerance, positive: false); + settings.StabilityMargin = Length(root, "stabilityMargin", settings.StabilityMargin, positive: false); + return settings; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException + or NotSupportedException or System.Security.SecurityException) + { + return new SupportGridSettings(); + } + } + + /// Writes the file, creating its directory. + public void Save(string path) + { + var dto = new Dictionary + { + ["version"] = CurrentVersion, + ["enabled"] = Enabled, + ["units"] = Units.ToString().ToLowerInvariant(), + ["pitchX"] = PitchX, + ["pitchY"] = PitchY, + ["offsetX"] = OffsetX, + ["offsetY"] = OffsetY, + ["registrationTolerance"] = RegistrationTolerance, + ["stabilityMargin"] = StabilityMargin, + }; + var directory = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + File.WriteAllText(path, JsonSerializer.Serialize(dto, JsonOptions)); + } + + // A finite number that stays finite in either unit; otherwise the fallback. + private static double Length(JsonElement root, string name, double fallback, bool positive, bool signed = false) + { + if (!root.TryGetProperty(name, out var element) || element.ValueKind != JsonValueKind.Number + || !element.TryGetDouble(out var value) || !double.IsFinite(value) || !double.IsFinite(value * MillimetersPerInch)) + return fallback; + if (positive ? value <= 0 : !signed && value < 0) + return fallback; + return value; + } +} diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 547223b1..90ea9f02 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -6,6 +6,7 @@ using OpenNest.CNC.CuttingPlanning; using OpenNest.CNC.CuttingStrategy; using OpenNest.Diagnostics; using OpenNest.Geometry; +using OpenNest.Support; namespace OpenNest.Engine.CuttingPlanning; @@ -39,10 +40,20 @@ public sealed class CuttingPlanRequest bool preservePartOrder = false, int maxEntries = 16) => new(plate?.Parts, startPoint, expansionBudget, confirmedParameters, eligibleParts, preservePartOrder, maxEntries) - { Plate = plate }; + { Plate = plate, SupportBed = plate?.SupportBed, SupportQuadrant = plate?.Quadrant ?? 1 }; /// The plate scope, or null for a detached part list that cannot be applied. public Plate Plate { get; private init; } + + /// + /// The bed the parts rest on once cut free (the plate's own for ), or + /// null. With it and , each regenerated part the + /// bed cannot hold level is tabbed where the tab steadies it (docs/support-tabs.md). + /// + public SupportBed SupportBed { get; init; } + + /// The sheet quadrant that places on the parts' coordinates. + public int SupportQuadrant { get; init; } = 1; public CuttingParameters ConfirmedParameters { get; } public IReadOnlyList EligibleParts { get; } public bool PreservePartOrder { get; } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index 6f75c31f..2aaa4986 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -122,8 +122,11 @@ public static class CuttingPlanService warnings.Add(new(index, source, null, null, PostVerificationKind.Incomplete, material.Reason)); } if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source))) + { prepared = DrawingPinCapture.Prepare(source, index, ownedClean, request.ConfirmedParameters, warnings, token); + prepared = SupportTabCapture.Prepare(prepared, ownedClean, location, request, token); + } else if (source.HasActiveDrawingPins) warnings.Add(DrawingPinCapture.NotApplied(source, index)); } diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 77c894c7..456e2eb7 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -803,27 +803,18 @@ internal static class JointCuttingPlanSearch private IReadOnlyList SelectEntries(FixedProgramPlacement source, int contour, Vector? target, Vector arrival, Vector[] sightTargets = null) { - // Last contour has no downstream target, but still needs its exact native - // closest-arrival fallback. Keep target null in the ranker: arrival is not - // a next cut and must not acquire facing-side priority. - var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token); - var ordered = catalogue.RankTowardNextCut(target, arrival); - // With no lead-out the tool leaves the part from its outside entry. From inside a - // slot or pocket the straight way to the next cut runs back over the part just - // cut, so entries with the clearest way out lead. Order only: every entry stays. - if (sightTargets is { Length: > 0 } && contour == source.Prepared.PerimeterOrdinal - && source.Material is { IsComplete: true } material) - { - var perimeter = material.Rings[0]; - ordered = ordered.OrderBy(c => sightTargets.Count(sight => PostVerificationGeometry.Crosses( - c.Choice.Point + source.Location, sight, perimeter, token))).ToList(); - } - // A hole is left where it was entered too: entries whose rapid to the next - // pierce clears the hole itself lead. - if (target is { } next && contour != source.Prepared.PerimeterOrdinal) - ordered = ordered.DepartingClear(source.Prepared, next, token); + var ordered = RankedEntries(source, contour, target, arrival, sightTargets, ignoreSupport: false); var selection = ContourEntrySelection.Select(ordered, candidate => Evaluate(source, candidate), snapshot.MaxEntries, token); + // No steadying point of a support-tabbed perimeter has a lead-in that fits: it is + // still tabbed, wherever its configured lead-ins fit. + if (contour == source.Prepared.PerimeterOrdinal && source.Prepared.HasSupportTab + && selection.Choices.Count == 0 && selection.UncertainChoices.Count == 0) + { + ordered = RankedEntries(source, contour, target, arrival, sightTargets, ignoreSupport: true); + selection = ContourEntrySelection.Select(ordered, + candidate => Evaluate(source, candidate), snapshot.MaxEntries, token); + } // No configured lead-in fits the perimeter: with a pierce diameter, try open scrap. string scrapReason = null; if (contour == source.Prepared.PerimeterOrdinal && selection.Choices.Count == 0 @@ -849,6 +840,33 @@ internal static class JointCuttingPlanSearch return selection.Choices.Concat(selection.UncertainChoices).Take(snapshot.MaxEntries).ToArray(); } + // One contour's automatic catalogue ranked toward the next cut, before lead checks. + private IReadOnlyList RankedEntries(FixedProgramPlacement source, int contour, + Vector? target, Vector arrival, Vector[] sightTargets, bool ignoreSupport) + { + // Last contour has no downstream target, but still needs its exact native + // closest-arrival fallback. Keep target null in the ranker: arrival is not + // a next cut and must not acquire facing-side priority. + var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token, + ignoreSupport); + var ordered = catalogue.RankTowardNextCut(target, arrival); + // With no lead-out the tool leaves the part from its outside entry. From inside a + // slot or pocket the straight way to the next cut runs back over the part just + // cut, so entries with the clearest way out lead. Order only: every entry stays. + if (sightTargets is { Length: > 0 } && contour == source.Prepared.PerimeterOrdinal + && source.Material is { IsComplete: true } material) + { + var perimeter = material.Rings[0]; + ordered = ordered.OrderBy(c => sightTargets.Count(sight => PostVerificationGeometry.Crosses( + c.Choice.Point + source.Location, sight, perimeter, token))).ToList(); + } + // A hole is left where it was entered too: entries whose rapid to the next + // pierce clears the hole itself lead. + if (target is { } next && contour != source.Prepared.PerimeterOrdinal) + ordered = ordered.DepartingClear(source.Prepared, next, token); + return ordered; + } + private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter) { var source = active.Source; diff --git a/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs new file mode 100644 index 00000000..76bbc9af --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs @@ -0,0 +1,34 @@ +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Geometry; +using OpenNest.Support; + +namespace OpenNest.Engine.CuttingPlanning; + +/// +/// Capture-time support tabs for one regenerated part (docs/support-tabs.md): with a support +/// bed and on, a part the bed +/// cannot hold level has its perimeter tabbed where the tab steadies it. A supported part, a +/// pinned perimeter (the drawing wins), a missing tab configuration or material the analyzer +/// cannot read leave the preparation as it was. +/// +internal static class SupportTabCapture +{ + internal static PreparedContours Prepare(PreparedContours prepared, Program ownedClean, Vector location, + CuttingPlanRequest request, CancellationToken token) + { + var parameters = request.ConfirmedParameters; + if (prepared == null || request.SupportBed == null || parameters?.SupportTabs != true + || parameters.TabConfig == null || prepared.IsPinned(prepared.PerimeterOrdinal)) + return prepared; + token.ThrowIfCancellationRequested(); + // The clean program is the drawing's own, rotated as placed: its material at the + // part's location is the material that drops onto the bed. + var support = SupportAnalyzer.Analyze(request.SupportBed, request.SupportQuadrant, + SupportMaterial.FromProgram(ownedClean, location)); + if (!support.IsComplete || support.IsSupported || support.BestTabPoint == null) + return prepared; + return prepared.WithSupportTab(support, location, parameters.TabConfig); + } +} diff --git a/OpenNest.IO.Tests/SupportGridPersistenceTests.cs b/OpenNest.IO.Tests/SupportGridPersistenceTests.cs new file mode 100644 index 00000000..7b62e62c --- /dev/null +++ b/OpenNest.IO.Tests/SupportGridPersistenceTests.cs @@ -0,0 +1,115 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json.Nodes; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; +using OpenNest.Support; + +namespace OpenNest.IO.Tests; + +/// +/// The nest's support grid persists in plateDefaults.supportBed and every plate follows +/// it on load; a nest without one writes no key, and a malformed one loads as none. +/// +public class SupportGridPersistenceTests +{ + private static readonly SupportGrid Grid = new(2, 1.5, 0.5, -0.25, 0.125, 0.25); + + private static Nest NestWith(SupportBed bed) + { + var nest = new Nest("grid"); + nest.PlateDefaults.Size = new Size(48, 96); + nest.SetSupportBed(bed); + var plate = nest.CreatePlate(); + plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + return nest; + } + + private static MemoryStream Write(Nest nest) + { + var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + return stream; + } + + private static JsonNode NestJson(MemoryStream zip) + { + zip.Position = 0; + using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true); + using var reader = new StreamReader(archive.GetEntry("nest.json")!.Open(), Encoding.UTF8); + var json = JsonNode.Parse(reader.ReadToEnd())!; + zip.Position = 0; + return json; + } + + private static MemoryStream Rewrite(MemoryStream zip, Action edit) + { + var json = NestJson(zip); + edit(json); + var copy = new MemoryStream(); + zip.Position = 0; + zip.CopyTo(copy); + using (var archive = new ZipArchive(copy, ZipArchiveMode.Update, leaveOpen: true)) + { + archive.GetEntry("nest.json")!.Delete(); + using var writer = new StreamWriter(archive.CreateEntry("nest.json").Open(), new UTF8Encoding(false)); + writer.Write(json.ToJsonString()); + } + copy.Position = 0; + return copy; + } + + [Fact] + public void GridRoundTripsAndEveryPlateFollowsIt() + { + var reopened = new NestReader(Write(NestWith(Grid))).Read(); + + Assert.Equal(Grid, reopened.SupportBed); + Assert.Same(reopened.SupportBed, Assert.Single(reopened.Plates).SupportBed); + Assert.Same(reopened.SupportBed, reopened.CreatePlate().SupportBed); + } + + [Fact] + public void GridIsWrittenWithItsType() + { + var bed = NestJson(Write(NestWith(Grid)))["plateDefaults"]!["supportBed"]!; + + Assert.Equal("grid", bed["type"]!.GetValue()); + Assert.Equal(1.5, bed["pitchY"]!.GetValue()); + Assert.Equal(-0.25, bed["offsetY"]!.GetValue()); + } + + [Fact] + public void NestWithoutGrid_WritesNoKey() + { + var defaults = NestJson(Write(NestWith(null)))["plateDefaults"]!.AsObject(); + + Assert.False(defaults.ContainsKey("supportBed")); + } + + [Theory] + [InlineData("""{"type":"slats","pitchX":2,"pitchY":2}""")] + [InlineData("""{"type":"grid","pitchX":0,"pitchY":2}""")] + [InlineData("""{"type":"grid","pitchX":"two","pitchY":2}""")] + [InlineData("""[1,2]""")] + public void MalformedOrUnknownGrid_LoadsAsNone(string bed) + { + var file = Rewrite(Write(NestWith(Grid)), json => json["plateDefaults"]!["supportBed"] = JsonNode.Parse(bed)); + + var reopened = new NestReader(file).Read(); + + Assert.Null(reopened.SupportBed); + Assert.Null(Assert.Single(reopened.Plates).SupportBed); + } + + [Fact] + public void SupportTabsRoundTripInCuttingParameters() + { + var parameters = new CuttingParameters { SupportTabs = true, TabConfig = new NormalTab { Size = 0.1 } }; + + var restored = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters)); + + Assert.True(restored.SupportTabs); + } +} diff --git a/OpenNest.IO/CuttingParametersDto.cs b/OpenNest.IO/CuttingParametersDto.cs index 768f7892..47302376 100644 --- a/OpenNest.IO/CuttingParametersDto.cs +++ b/OpenNest.IO/CuttingParametersDto.cs @@ -33,6 +33,7 @@ public class CuttingParametersDto public double LeadInAngleIncrement { get; set; } public double AutoTabMinSize { get; set; } public double AutoTabMaxSize { get; set; } + public bool SupportTabs { get; set; } public SequenceDto Sequencing { get; set; } = new(); public AssignmentDto Assignment { get; set; } = new(); diff --git a/OpenNest.IO/CuttingParametersSerializer.cs b/OpenNest.IO/CuttingParametersSerializer.cs index 11b6859b..369b4a8a 100644 --- a/OpenNest.IO/CuttingParametersSerializer.cs +++ b/OpenNest.IO/CuttingParametersSerializer.cs @@ -74,6 +74,7 @@ public static class CuttingParametersSerializer LeadInAngleIncrement = parameters.LeadInAngleIncrement, AutoTabMinSize = parameters.AutoTabMinSize, AutoTabMaxSize = parameters.AutoTabMaxSize, + SupportTabs = parameters.SupportTabs, Sequencing = ToSequenceDto(parameters.Sequencing), Assignment = ToAssignmentDto(parameters.Assignment), }; @@ -110,6 +111,7 @@ public static class CuttingParametersSerializer LeadInAngleIncrement = dto.LeadInAngleIncrement, AutoTabMinSize = dto.AutoTabMinSize, AutoTabMaxSize = dto.AutoTabMaxSize, + SupportTabs = dto.SupportTabs, Sequencing = FromSequenceDto(dto.Sequencing), Assignment = FromAssignmentDto(dto.Assignment), }; diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index d65ef307..97101877 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -53,6 +53,85 @@ namespace OpenNest.IO [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonConverter(typeof(LenientClampRailConverter))] public ClampRailDto ClampRail { get; init; } + + /// + /// The machine support bed every sheet of the nest rests on (docs/support-tabs.md). + /// Omitted when the nest has none; older files and older readers simply have none. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(LenientSupportBedConverter))] + public SupportBedDto SupportBed { get; init; } + } + + /// + /// A support bed layout. names the layout: only grid, a regular + /// grid of contact points, so far. Lengths are in the nest's units. + /// + public record SupportBedDto + { + public string Type { get; init; } = "grid"; + public double PitchX { get; init; } + public double PitchY { get; init; } + public double OffsetX { get; init; } + public double OffsetY { get; init; } + public double RegistrationTolerance { get; init; } + public double StabilityMargin { get; init; } + } + + /// + /// Reads a malformed, wrongly typed or unknown support bed as none instead of failing the + /// whole nest; the reader still validates the values it receives. Optional lengths + /// default to zero. + /// + public sealed class LenientSupportBedConverter : JsonConverter + { + private static readonly string[] Optional = { "offsetX", "offsetY", "registrationTolerance", "stabilityMargin" }; + + public override SupportBedDto Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var bed = document.RootElement; + if (bed.ValueKind != JsonValueKind.Object + || !bed.TryGetProperty("type", out var type) || type.ValueKind != JsonValueKind.String + || type.GetString() != "grid" + || !TryNumber(bed, "pitchX", out var pitchX) || !TryNumber(bed, "pitchY", out var pitchY)) + return null; + var values = new double[Optional.Length]; + for (var i = 0; i < Optional.Length; i++) + if (bed.TryGetProperty(Optional[i], out _) && !TryNumber(bed, Optional[i], out values[i])) + return null; + return new SupportBedDto + { + PitchX = pitchX, + PitchY = pitchY, + OffsetX = values[0], + OffsetY = values[1], + RegistrationTolerance = values[2], + StabilityMargin = values[3], + }; + } + + public override void Write(Utf8JsonWriter writer, SupportBedDto value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + writer.WriteNumber("pitchX", value.PitchX); + writer.WriteNumber("pitchY", value.PitchY); + writer.WriteNumber("offsetX", value.OffsetX); + writer.WriteNumber("offsetY", value.OffsetY); + writer.WriteNumber("registrationTolerance", value.RegistrationTolerance); + writer.WriteNumber("stabilityMargin", value.StabilityMargin); + writer.WriteEndObject(); + } + + private static bool TryNumber(JsonElement bed, string name, out double value) + { + value = 0; + return bed.TryGetProperty(name, out var element) + && element.ValueKind == JsonValueKind.Number + && element.TryGetDouble(out value); + } } /// diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index e4efec37..b109220f 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -12,6 +12,7 @@ using OpenNest.CNC; using OpenNest.CNC.CuttingStrategy; using OpenNest.Converters; using OpenNest.Geometry; +using OpenNest.Support; using OpenNest.TabbedCutouts; using static OpenNest.IO.NestFormat; @@ -472,6 +473,22 @@ namespace OpenNest.IO /// is ignored rather than failing the nest; each plate's saved zones still load). A /// missing or invalid removal-minutes value falls back to . /// + /// The saved support bed, or null when absent or invalid (a bad bed never fails the nest). + private static SupportBed ReadSupportBed(SupportBedDto dto) + { + if (dto == null) + return null; + try + { + return new SupportGrid(dto.PitchX, dto.PitchY, dto.OffsetX, dto.OffsetY, + dto.RegistrationTolerance, dto.StabilityMargin); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } + private static ClampRail ReadClampRail(ClampRailDto dto) { if (dto == null) @@ -540,6 +557,8 @@ namespace OpenNest.IO ); var clampRail = ReadClampRail(pd.ClampRail); nest.PlateDefaults.ClampRail = clampRail; + // Plates read below follow the nest's bed as they are added. + nest.SetSupportBed(ReadSupportBed(pd.SupportBed)); // Stock options: sheet sizes and salvage rate nest.SalvageRate = dto.SalvageRate; diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index e1f59292..52358bb2 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -9,6 +9,7 @@ using System.Text; using System.Text.Json; using OpenNest.BestFit; using OpenNest.CNC; +using OpenNest.Support; using static OpenNest.IO.NestFormat; namespace OpenNest.IO @@ -142,9 +143,23 @@ namespace OpenNest.IO Bottom = pd.EdgeSpacing.Bottom, }, ClampRail = ToDto(pd.ClampRail), + SupportBed = ToDto(nest.SupportBed), }; } + private static SupportBedDto ToDto(SupportBed bed) => + bed is SupportGrid grid + ? new SupportBedDto + { + PitchX = grid.PitchX, + PitchY = grid.PitchY, + OffsetX = grid.OffsetX, + OffsetY = grid.OffsetY, + RegistrationTolerance = grid.RegistrationTolerance, + StabilityMargin = grid.StabilityMargin, + } + : null; + private List BuildDrawingDtos() { var list = new List(); diff --git a/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs b/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs new file mode 100644 index 00000000..3b3cc9d5 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs @@ -0,0 +1,92 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; +using OpenNest.Support; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// Plan Cutting with "Tab parts the support grid cannot hold level": a part the grid cannot +/// hold is tabbed where the tab steadies it; a supported part is left untabbed. +/// +public class SupportTabPlanningTests +{ + private const double TabSize = 0.2; + + private static CuttingParameters Parameters() + { + var parameters = ExplicitContourTests.Parameters(); + parameters.SupportTabs = true; + parameters.TabConfig = new NormalTab { Size = TabSize }; + return parameters; + } + + // A 10 x 1 strip at the origin; the start is beside its lower-left corner. + private static (Plate Plate, Part Part) Strip(SupportBed bed) + { + var plate = new Plate(20, 20) { SupportBed = bed }; + var part = new Part(new Drawing("strip", LeadPathValidationTests.Rectangle(0, 0, 10, 1)), new Vector(2, 2)); + plate.Parts.Add(part); + return (plate, part); + } + + private static (Vector Entry, Vector CutEnd) EntryAndCutEnd(FixedProgramPlacement proposal) + { + var motions = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, null, default).Motions; + var entry = motions.Last(m => m.Layer == LayerType.Leadin).End; + var cutEnd = motions.Last(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).End; + return (entry, cutEnd); + } + + [Fact] + public void StripOnOneRowOfContacts_IsTabbedOnTheEdgeAcrossItsCenterFromTheRow() + { + // One row of grid points at y = 2.25 runs under the strip (y 2..3), below its center. + var bed = new SupportGrid(2, 4, 0, 2.25, stabilityMargin: 0.1); + var (plate, part) = Strip(bed); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(0, 0), + confirmedParameters: Parameters())); + + Assert.True(result.Status == CuttingPlanStatus.Ready, string.Join("\n", result.Findings.Select(f => f.Message))); + var (entry, cutEnd) = EntryAndCutEnd(Assert.Single(result.ProposedOrder)); + Assert.Equal(3, entry.Y, 6); + Assert.Equal(TabSize, entry.DistanceTo(cutEnd), 6); + var support = SupportAnalyzer.Analyze(bed, plate.Quadrant, part); + Assert.False(support.IsSupported); + Assert.True(support.MarginWith(entry) >= bed.StabilityMargin); + } + + [Fact] + public void SupportedPart_IsNotTabbed() + { + var (plate, _) = Strip(new SupportGrid(0.5, 0.5, 0.25, 0.25, stabilityMargin: 0.1)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(0, 0), + confirmedParameters: Parameters())); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + var (entry, cutEnd) = EntryAndCutEnd(Assert.Single(result.ProposedOrder)); + Assert.Equal(0, entry.DistanceTo(cutEnd), 6); + } + + [Fact] + public void WithoutSupportTabs_TheGridChangesNothing() + { + var bed = new SupportGrid(2, 4, 0, 2.25, stabilityMargin: 0.1); + var parameters = Parameters(); + parameters.SupportTabs = false; + var (withBed, _) = Strip(bed); + var (withoutBed, _) = Strip(null); + + var planned = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(withBed, new Vector(0, 0), + confirmedParameters: parameters)); + var plain = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(withoutBed, new Vector(0, 0), + confirmedParameters: parameters)); + + Assert.Equal(ExplicitContourTests.Fingerprint(Assert.Single(plain.ProposedOrder).CopyProgram()), + ExplicitContourTests.Fingerprint(Assert.Single(planned.ProposedOrder).CopyProgram())); + } +} diff --git a/OpenNest.Tests/Data/SupportGridSettingsTests.cs b/OpenNest.Tests/Data/SupportGridSettingsTests.cs new file mode 100644 index 00000000..fcc45031 --- /dev/null +++ b/OpenNest.Tests/Data/SupportGridSettingsTests.cs @@ -0,0 +1,78 @@ +using OpenNest.Data; +using OpenNest.Support; + +namespace OpenNest.Tests.Data; + +/// +/// The machine support grid store (%APPDATA%\OpenNest\support-grid.json): off by default, a +/// never-throwing read that keeps each invalid value's default, and unit conversion for new nests. +/// +public class SupportGridSettingsTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString()); + private readonly string _path; + + public SupportGridSettingsTests() + { + Directory.CreateDirectory(_directory); + _path = Path.Combine(_directory, "support-grid.json"); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + [Fact] + public void MissingFile_IsOffAndGivesNewNestsNoGrid() + { + var settings = SupportGridSettings.Load(_path); + var nest = new Nest(); + + settings.ApplyToNewNest(nest); + + Assert.False(settings.Enabled); + Assert.Null(nest.SupportBed); + } + + [Fact] + public void SavedSettingsRoundTripAndConvertToMillimeterNests() + { + new SupportGridSettings + { + Enabled = true, + PitchX = 2, + PitchY = 1, + OffsetX = 0.5, + OffsetY = -0.25, + RegistrationTolerance = 0.125, + StabilityMargin = 0.25, + }.Save(_path); + var nest = new Nest { Units = Units.Millimeters }; + + SupportGridSettings.Load(_path).ApplyToNewNest(nest); + + var grid = Assert.IsType(nest.SupportBed); + Assert.Equal(50.8, grid.PitchX, 9); + Assert.Equal(25.4, grid.PitchY, 9); + Assert.Equal(-6.35, grid.OffsetY, 9); + Assert.Equal(6.35, grid.StabilityMargin, 9); + } + + [Fact] + public void InvalidValues_KeepTheirDefaults_AndBadJsonLoadsAsOff() + { + File.WriteAllText(_path, """{"enabled":true,"pitchX":-1,"pitchY":"x","offsetX":0.5,"stabilityMargin":-2}"""); + var settings = SupportGridSettings.Load(_path); + + Assert.True(settings.Enabled); + Assert.Equal(new SupportGridSettings().PitchX, settings.PitchX); + Assert.Equal(new SupportGridSettings().PitchY, settings.PitchY); + Assert.Equal(0.5, settings.OffsetX); + Assert.Equal(new SupportGridSettings().StabilityMargin, settings.StabilityMargin); + + File.WriteAllText(_path, "{ not json"); + Assert.False(SupportGridSettings.Load(_path).Enabled); + } +} diff --git a/OpenNest.Tests/Support/SupportAnalyzerTests.cs b/OpenNest.Tests/Support/SupportAnalyzerTests.cs new file mode 100644 index 00000000..77c8d92e --- /dev/null +++ b/OpenNest.Tests/Support/SupportAnalyzerTests.cs @@ -0,0 +1,133 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Support; + +namespace OpenNest.Tests.Support; + +/// +/// Whether a cut part stays level on the laser support grid: its center of gravity against the +/// convex hull of the grid points under its material, and where one tab steadies it best. +/// +public class SupportAnalyzerTests +{ + private static SupportMaterial Rectangle(double x1, double y1, double x2, double y2, params Vector[][] holes) => + SupportMaterial.FromPolygons( + new[] { new Vector(x1, y1), new Vector(x1, y2), new Vector(x2, y2), new Vector(x2, y1) }, holes); + + private static Vector[] Square(double x1, double y1, double x2, double y2) => + new[] { new Vector(x1, y1), new Vector(x2, y1), new Vector(x2, y2), new Vector(x1, y2) }; + + [Fact] + public void Square_OverManyPoints_IsSupportedByItsDistanceToTheOutermostContacts() + { + var grid = new SupportGrid(1, 1, 0.5, 0.5); + + var support = SupportAnalyzer.Analyze(grid, 1, Rectangle(0, 0, 4, 4)); + + Assert.True(support.IsComplete); + Assert.Equal(new Vector(2, 2), support.CenterOfGravity); + Assert.Equal(16, support.Contacts.Count); + Assert.Equal(1.5, support.Margin, 12); + Assert.True(support.IsSupported); + } + + [Fact] + public void Holes_HaveNoContactsAndMoveTheCenterOfGravity() + { + var grid = new SupportGrid(1, 1, 0.5, 0.5); + + var centered = SupportAnalyzer.Analyze(grid, 1, Rectangle(0, 0, 4, 4, Square(1, 1, 3, 3))); + var offCenter = SupportAnalyzer.Analyze(grid, 1, Rectangle(0, 0, 4, 4, Square(0.2, 0.2, 1.8, 1.8))); + + Assert.Equal(12, centered.Contacts.Count); + Assert.DoesNotContain(centered.Contacts, c => c.X is > 1 and < 3 && c.Y is > 1 and < 3); + Assert.Equal(new Vector(2, 2), centered.CenterOfGravity); + Assert.True(offCenter.CenterOfGravity.X > 2 && offCenter.CenterOfGravity.Y > 2); + } + + [Fact] + public void RegistrationTolerance_KeepsTheWorstShiftedMargin() + { + var grid = new SupportGrid(1, 1, 0.5, 0.5, registrationTolerance: 0.25); + + var support = SupportAnalyzer.Analyze(grid, 1, Rectangle(0, 0, 4, 4)); + + Assert.Equal(1.25, support.Margin, 12); + } + + [Fact] + public void Strip_OnOneRowOfContacts_TipsAndATabOnTheFarEdgeSteadiesIt() + { + // One row of contacts at y = 0.25 runs under a 10 x 1 strip whose center is at y = 0.5. + var grid = new SupportGrid(2, 4, 0, 0.25, stabilityMargin: 0.1); + + var support = SupportAnalyzer.Analyze(grid, 1, Rectangle(0, 0, 10, 1)); + + Assert.False(support.IsSupported); + Assert.Equal(-0.25, support.Margin, 12); + Assert.True(support.TabCanSupport); + Assert.Equal(0.25, support.BestTabMargin, 9); + Assert.Equal(1, support.BestTabPoint!.Value.Y, 9); + Assert.True(support.AcceptsTabAt(new Vector(5, 1))); + Assert.False(support.AcceptsTabAt(new Vector(5, 0))); + Assert.True(support.MarginWith(new Vector(5, 0)) < 0); + } + + [Fact] + public void Strip_BetweenRows_FallsThroughAndOnlyTheNearestTabIsAccepted() + { + var grid = new SupportGrid(1, 1, 0, 0.9, stabilityMargin: 0.1); + + var support = SupportAnalyzer.Analyze(grid, 1, Rectangle(0, 0, 10, 0.8)); + + Assert.Empty(support.Contacts); + Assert.Equal(double.NegativeInfinity, support.Margin); + Assert.False(support.TabCanSupport); + // The outline is sampled, so the nearest sample may sit just beside (5, 0) or (5, 0.8). + Assert.InRange(support.BestTabMargin, -0.41, -0.4); + Assert.True(support.AcceptsTabAt(support.BestTabPoint!.Value)); + Assert.False(support.AcceptsTabAt(new Vector(0, 0))); + } + + [Theory] + [InlineData(1, 1, 1)] + [InlineData(2, -1, 1)] + [InlineData(3, -1, -1)] + [InlineData(4, 1, -1)] + public void Quadrant_MeasuresTheGridFromTheOriginIntoTheSheet(int quadrant, double signX, double signY) + { + var grid = new SupportGrid(1, 1, 0.25, 0.75); + var sheet = new Box(signX < 0 ? -4 : 0, signY < 0 ? -4 : 0, 4, 4); + + var points = grid.PointsIn(sheet, quadrant); + + Assert.Equal(16, points.Count); + Assert.Contains(new Vector(signX * 0.25, signY * 0.75), points); + Assert.Contains(new Vector(signX * 3.25, signY * 3.75), points); + } + + [Fact] + public void ProgramMaterial_IsPlacedAtItsLocationWithoutLeads() + { + var program = new Program(); + program.MoveTo(-1, 2); + program.Codes.Add(new LinearMove(0, 2) { Layer = LayerType.Leadin }); + program.LineTo(0, 4); program.LineTo(4, 4); program.LineTo(4, 0); program.LineTo(0, 0); program.LineTo(0, 2); + + var material = SupportMaterial.FromProgram(program, new Vector(10, 20)); + + Assert.True(material.IsComplete, material.Reason); + Assert.True(material.TryGetCenterOfGravity(out var center, out var area)); + Assert.Equal(16, area, 9); + Assert.Equal(12, center.X, 9); + Assert.Equal(22, center.Y, 9); + } + + [Fact] + public void Grid_RejectsNonPositivePitch() + { + Assert.Throws(() => new SupportGrid(0, 1)); + Assert.Throws(() => new SupportGrid(1, 1, registrationTolerance: -1)); + Assert.Equal(new SupportGrid(2, 2, 1, 1, 0.5, 0.25), new SupportGrid(1, 1, 0.5, 0.5, 0.25, 0.125).Scaled(2)); + } +} diff --git a/OpenNest/Controls/CuttingPanel.cs b/OpenNest/Controls/CuttingPanel.cs index aafeecf5..06521199 100644 --- a/OpenNest/Controls/CuttingPanel.cs +++ b/OpenNest/Controls/CuttingPanel.cs @@ -38,6 +38,7 @@ namespace OpenNest.Controls private readonly NumericUpDown nudTabWidth; private readonly RadioButton rbTabAll; private readonly RadioButton rbAutoTab; + private readonly RadioButton rbSupportTab; private readonly NumericUpDown nudAutoTabMin; private readonly NumericUpDown nudAutoTabMax; private readonly NumericUpDown nudPierceClearance; @@ -205,12 +206,23 @@ namespace OpenNest.Controls nudAutoTabMax.Enabled = false; tabsPanel.ContentPanel.Controls.Add(nudAutoTabMax); + // Plan Cutting tabs only the parts the plate's support grid cannot hold level. + rbSupportTab = new RadioButton + { + Text = "Tab parts the support grid cannot hold level", + Location = new Point(28, 102), + AutoSize = true, + Enabled = false, + }; + tabsPanel.ContentPanel.Controls.Add(rbSupportTab); + chkTabsEnabled.CheckedChanged += (s, e) => { var enabled = chkTabsEnabled.Checked; nudTabWidth.Enabled = enabled; rbTabAll.Enabled = enabled; rbAutoTab.Enabled = enabled; + rbSupportTab.Enabled = enabled; nudAutoTabMin.Enabled = enabled && rbAutoTab.Checked; nudAutoTabMax.Enabled = enabled && rbAutoTab.Checked; OnParametersChanged(); @@ -223,6 +235,14 @@ namespace OpenNest.Controls OnParametersChanged(); }; + // Switching between auto and support tabs does not pass through Tab all parts. + rbSupportTab.CheckedChanged += (s, e) => + { + nudAutoTabMin.Enabled = chkTabsEnabled.Checked && rbAutoTab.Checked; + nudAutoTabMax.Enabled = chkTabsEnabled.Checked && rbAutoTab.Checked; + OnParametersChanged(); + }; + // Pierce section var piercePanel = new CollapsiblePanel { @@ -337,6 +357,7 @@ namespace OpenNest.Controls chkTabsEnabled.Checked && rbAutoTab.Checked ? (double)nudAutoTabMin.Value : 0, AutoTabMaxSize = chkTabsEnabled.Checked && rbAutoTab.Checked ? (double)nudAutoTabMax.Value : 0, + SupportTabs = chkTabsEnabled.Checked && rbSupportTab.Checked, }; } @@ -352,9 +373,10 @@ namespace OpenNest.Controls LoadLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut, p.ArcCircleLeadOut); var hasAutoTab = p.AutoTabMinSize > 0 || p.AutoTabMaxSize > 0; - chkTabsEnabled.Checked = p.TabsEnabled || hasAutoTab; - rbAutoTab.Checked = hasAutoTab; - rbTabAll.Checked = !hasAutoTab; + chkTabsEnabled.Checked = p.TabsEnabled || hasAutoTab || p.SupportTabs; + rbAutoTab.Checked = hasAutoTab && !p.SupportTabs; + rbSupportTab.Checked = p.SupportTabs; + rbTabAll.Checked = !hasAutoTab && !p.SupportTabs; if (p.TabConfig != null) nudTabWidth.Value = (decimal)p.TabConfig.Size; nudPierceClearance.Value = (decimal)p.PierceClearance; diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index c4590e4e..675a6683 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -27,6 +27,7 @@ namespace OpenNest.Controls private readonly OverlapOverlayController overlapOverlay; private readonly ValidationOverlay validationOverlay; private readonly CutSimulationOverlay cutSimulation; + private readonly SupportOverlay supportOverlay = new(); private string status; private Plate plate; @@ -185,6 +186,9 @@ namespace OpenNest.Controls public bool DrawPiercePoints { get; set; } + /// Draws the plate's support grid, each part's contacts and center of gravity. + public bool DrawSupport { get; set; } + public bool DrawBounds { get; set; } public bool DrawOffset { get; set; } @@ -749,6 +753,8 @@ namespace OpenNest.Controls { renderer.DrawActiveWorkArea(e.Graphics); renderer.DrawDebugRemnants(e.Graphics); + if (DrawSupport) + supportOverlay.Draw(e.Graphics, this); DrawOverlapOverlay(e.Graphics); } // Plan Cutting marks findings on its proposal as well as on the live plate. diff --git a/OpenNest/Controls/SupportOverlay.cs b/OpenNest/Controls/SupportOverlay.cs new file mode 100644 index 00000000..79a3dab8 --- /dev/null +++ b/OpenNest/Controls/SupportOverlay.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Runtime.CompilerServices; +using OpenNest.Geometry; +using OpenNest.Support; + +namespace OpenNest.Controls +{ + /// + /// View > Draw Support: the plate's support grid and, for each part, the contacts it rests + /// on and its center of gravity, green where the grid holds it level and red where it can + /// tip; a red part's best tab point is marked with an orange diamond. Display only: it + /// takes part in no hit test, selection or sequencing. Analyses are cached per part until + /// its program, pose, the bed or the quadrant changes. + /// + internal sealed class SupportOverlay + { + // Grid points are drawn only while they are at least this far apart on screen. + private const float MinimumGridSpacing = 6f; + + private static readonly Color GridColor = Color.FromArgb(110, 120, 120, 120); + private static readonly Color ContactColor = Color.FromArgb(220, 30, 150, 60); + private static readonly Color SupportedColor = Color.FromArgb(230, 20, 140, 50); + private static readonly Color TippingColor = Color.FromArgb(240, 210, 30, 30); + private static readonly Color TabColor = Color.FromArgb(240, 240, 140, 0); + + private readonly ConditionalWeakTable cache = new(); + + private sealed record Entry(CNC.Program Program, Vector Location, double Rotation, SupportBed Bed, int Quadrant, + PartSupport Support); + + public void Draw(Graphics g, PlateView view) + { + var plate = view.DisplayPlate; + if (plate?.SupportBed is not { } bed) + return; + + if (bed is SupportGrid grid) + DrawGrid(g, view, plate, grid); + + using var contactBrush = new SolidBrush(ContactColor); + using var supportedPen = new Pen(SupportedColor, 2f); + using var tippingPen = new Pen(TippingColor, 2f); + using var tabBrush = new SolidBrush(TabColor); + foreach (var part in plate.Parts) + { + if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff) + continue; + var support = Analyze(part, bed, plate.Quadrant); + if (!support.IsComplete) + continue; + + foreach (var contact in support.Contacts) + Dot(g, view, contact, 2.5f, contactBrush); + Cross(g, view, support.CenterOfGravity, support.IsSupported ? supportedPen : tippingPen); + if (!support.IsSupported && support.BestTabPoint is { } tab) + Diamond(g, view, tab, tabBrush); + } + } + + private PartSupport Analyze(Part part, SupportBed bed, int quadrant) + { + if (cache.TryGetValue(part, out var entry) && ReferenceEquals(entry.Program, part.Program) + && entry.Location == part.Location && entry.Rotation == part.Rotation && Equals(entry.Bed, bed) + && entry.Quadrant == quadrant) + return entry.Support; + var support = SupportAnalyzer.Analyze(bed, quadrant, part); + cache.AddOrUpdate(part, new Entry(part.Program, part.Location, part.Rotation, bed, quadrant, support)); + return support; + } + + private static void DrawGrid(Graphics g, PlateView view, Plate plate, SupportGrid grid) + { + if (view.LengthWorldToGui(System.Math.Min(grid.PitchX, grid.PitchY)) < MinimumGridSpacing) + return; + var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0; + var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0; + var sheet = new Box(left, bottom, plate.Size.Length, plate.Size.Width); + using var brush = new SolidBrush(GridColor); + foreach (var point in grid.PointsIn(sheet, plate.Quadrant)) + Dot(g, view, point, 1.5f, brush); + } + + private static void Dot(Graphics g, PlateView view, Vector point, float radius, Brush brush) + { + var p = view.PointWorldToGraph(point); + g.FillEllipse(brush, p.X - radius, p.Y - radius, radius * 2, radius * 2); + } + + private static void Cross(Graphics g, PlateView view, Vector point, Pen pen) + { + const float size = 6f; + var p = view.PointWorldToGraph(point); + g.DrawEllipse(pen, p.X - size, p.Y - size, size * 2, size * 2); + g.DrawLine(pen, p.X - size, p.Y, p.X + size, p.Y); + g.DrawLine(pen, p.X, p.Y - size, p.X, p.Y + size); + } + + private static void Diamond(Graphics g, PlateView view, Vector point, Brush brush) + { + const float size = 5f; + var p = view.PointWorldToGraph(point); + g.FillPolygon(brush, new[] + { + new PointF(p.X, p.Y - size), new PointF(p.X + size, p.Y), + new PointF(p.X, p.Y + size), new PointF(p.X - size, p.Y), + }); + } + } +} diff --git a/OpenNest/Forms/BomImportForm.cs b/OpenNest/Forms/BomImportForm.cs index c6166378..4c798611 100644 --- a/OpenNest/Forms/BomImportForm.cs +++ b/OpenNest/Forms/BomImportForm.cs @@ -415,6 +415,7 @@ namespace OpenNest.Forms var importErrors = new List(); // Read when the nests are created, warning (once per session) like New does. var clamps = MachineSettings.LoadClampSettings(this); + var supportGrid = MachineSettings.LoadSupportGridSettings(); foreach (var group in groups) { @@ -441,6 +442,7 @@ namespace OpenNest.Forms // As New does: saved defaults, then the machine clamps in those units. _defaults.ApplyTo(nest); clamps.ApplyToNewNest(nest); + supportGrid.ApplyToNewNest(nest); } ); importErrors.AddRange(result.Errors); diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 5cfcffdb..223e13f9 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -873,6 +873,12 @@ namespace OpenNest.Forms PlateView.Invalidate(); } + public void ToggleSupport() + { + PlateView.DrawSupport = !PlateView.DrawSupport; + PlateView.Invalidate(); + } + public void ToggleFillParts() { PlateView.FillParts = !PlateView.FillParts; diff --git a/OpenNest/Forms/MachineSettings.cs b/OpenNest/Forms/MachineSettings.cs index 3d8369ba..5fc378c6 100644 --- a/OpenNest/Forms/MachineSettings.cs +++ b/OpenNest/Forms/MachineSettings.cs @@ -74,6 +74,10 @@ namespace OpenNest.Forms return settings; } + /// The persisted machine support grid; never throws, and loads as off when unreadable. + public static SupportGridSettings LoadSupportGridSettings() => + SupportGridSettings.Load(SupportGridSettings.DefaultPath); + /// The single desktop entry point for the persisted machine clamp settings. public static ClampSettings LoadSavedClampSettings(out ClampSettingsStatus status) => ClampSettings.Load(ClampSettings.DefaultPath, out status); diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index f4c69920..7792024b 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -67,6 +67,7 @@ namespace OpenNest.Forms mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem(); mnuViewDrawOffset = new System.Windows.Forms.ToolStripMenuItem(); mnuViewDrawCutDirection = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewDrawSupport = new System.Windows.Forms.ToolStripMenuItem(); toolStripMenuItem5 = new System.Windows.Forms.ToolStripSeparator(); mnuViewZoomTo = new System.Windows.Forms.ToolStripMenuItem(); mnuViewZoomToArea = new System.Windows.Forms.ToolStripMenuItem(); @@ -89,6 +90,7 @@ namespace OpenNest.Forms mnuNest = new System.Windows.Forms.ToolStripMenuItem(); mnuNestEdit = new System.Windows.Forms.ToolStripMenuItem(); mnuNestClamps = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestSupportGrid = new System.Windows.Forms.ToolStripMenuItem(); mnuNestImportDrawing = new System.Windows.Forms.ToolStripMenuItem(); mnuNestShapeLibrary = new System.Windows.Forms.ToolStripMenuItem(); mnuNestDrawingLibrary = new System.Windows.Forms.ToolStripMenuItem(); @@ -343,7 +345,7 @@ namespace OpenNest.Forms // // mnuView // - mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawPiercePoints, mnuViewDrawBounds, mnuViewDrawOffset, mnuViewDrawCutDirection, mnuViewOverlapCheck, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut }); + mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawPiercePoints, mnuViewDrawBounds, mnuViewDrawOffset, mnuViewDrawCutDirection, mnuViewDrawSupport, mnuViewOverlapCheck, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut }); mnuView.Name = "mnuView"; mnuView.Size = new System.Drawing.Size(44, 20); mnuView.Text = "&View"; @@ -438,6 +440,14 @@ namespace OpenNest.Forms mnuViewDrawCutDirection.Text = "Draw Cut Direction"; mnuViewDrawCutDirection.Click += ToggleDrawCutDirection_Click; // + // mnuViewDrawSupport + // + mnuViewDrawSupport.CheckOnClick = true; + mnuViewDrawSupport.Name = "mnuViewDrawSupport"; + mnuViewDrawSupport.Size = new System.Drawing.Size(222, 22); + mnuViewDrawSupport.Text = "Draw Support"; + mnuViewDrawSupport.Click += ToggleDrawSupport_Click; + // // toolStripMenuItem5 // toolStripMenuItem5.Name = "toolStripMenuItem5"; @@ -574,7 +584,7 @@ namespace OpenNest.Forms // // mnuNest // - mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestClamps, mnuNestImportDrawing, mnuNestShapeLibrary, mnuNestDrawingLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestClear, mnuNestValidate, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAutomaticCutOff, mnuNestPlanCutting, mnuNestAssignLeadIns, mnuNestRemoveLeadIns }); + mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestClamps, mnuNestSupportGrid, mnuNestImportDrawing, mnuNestShapeLibrary, mnuNestDrawingLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestClear, mnuNestValidate, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAutomaticCutOff, mnuNestPlanCutting, mnuNestAssignLeadIns, mnuNestRemoveLeadIns }); mnuNest.Name = "mnuNest"; mnuNest.Size = new System.Drawing.Size(43, 20); mnuNest.Text = "&Nest"; @@ -593,6 +603,13 @@ namespace OpenNest.Forms mnuNestClamps.Size = new System.Drawing.Size(205, 22); mnuNestClamps.Text = "Clamps..."; mnuNestClamps.Click += Clamps_Click; + // + // mnuNestSupportGrid + // + mnuNestSupportGrid.Name = "mnuNestSupportGrid"; + mnuNestSupportGrid.Size = new System.Drawing.Size(205, 22); + mnuNestSupportGrid.Text = "Support Grid..."; + mnuNestSupportGrid.Click += SupportGrid_Click; // // mnuNestImportDrawing // @@ -1339,6 +1356,7 @@ namespace OpenNest.Forms private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds; private System.Windows.Forms.ToolStripMenuItem mnuViewDrawOffset; private System.Windows.Forms.ToolStripMenuItem mnuViewDrawCutDirection; + private System.Windows.Forms.ToolStripMenuItem mnuViewDrawSupport; private System.Windows.Forms.ToolStripSeparator toolStripMenuItem5; private System.Windows.Forms.ToolStripMenuItem mnuTools; private System.Windows.Forms.ToolStripMenuItem mnuToolsMachineConfig; @@ -1348,6 +1366,7 @@ namespace OpenNest.Forms private System.Windows.Forms.ToolStripMenuItem mnuNest; private System.Windows.Forms.ToolStripMenuItem mnuNestEdit; private System.Windows.Forms.ToolStripMenuItem mnuNestClamps; + private System.Windows.Forms.ToolStripMenuItem mnuNestSupportGrid; private System.Windows.Forms.ToolStripMenuItem mnuPlateEditClamps; private System.Windows.Forms.ToolStripMenuItem mnuNestImportDrawing; private System.Windows.Forms.ToolStripMenuItem mnuNestShapeLibrary; diff --git a/OpenNest/Forms/MainForm.Nest.cs b/OpenNest/Forms/MainForm.Nest.cs index efb9bb82..7574700c 100644 --- a/OpenNest/Forms/MainForm.Nest.cs +++ b/OpenNest/Forms/MainForm.Nest.cs @@ -85,6 +85,30 @@ namespace OpenNest.Forms view.Invalidate(); } + private void SupportGrid_Click(object sender, EventArgs e) + { + var editForm = activeForm; + if (editForm == null) + return; + + var nest = editForm.Nest; + var views = ViewsOf(nest); + if (IsPlateWorkBusy(views)) + { + MessageBox.Show(this, "Finish or cancel the current nesting or plate action first.", + "Support Grid", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + using var form = new SupportGridForm(nest.SupportBed, nest.Units, MachineSettings.LoadSupportGridSettings()); + if (form.ShowDialog(this) != DialogResult.OK) + return; + + nest.SetSupportBed(form.Grid); + foreach (var view in views) + view.Invalidate(); + } + private void RemoveEmptyPlates_Click(object sender, EventArgs e) => WithActive(f => f.Nest.Plates.RemoveEmptyPlates()); private void ClearNest_Click(object sender, EventArgs e) diff --git a/OpenNest/Forms/MainForm.View.cs b/OpenNest/Forms/MainForm.View.cs index 68a51dba..ac61d8e9 100644 --- a/OpenNest/Forms/MainForm.View.cs +++ b/OpenNest/Forms/MainForm.View.cs @@ -140,6 +140,14 @@ namespace OpenNest.Forms mnuViewDrawCutDirection.Checked = activeForm.PlateView.DrawCutDirection; } + private void ToggleDrawSupport_Click(object sender, EventArgs e) + { + if (activeForm == null) + return; + activeForm.ToggleSupport(); + mnuViewDrawSupport.Checked = activeForm.PlateView.DrawSupport; + } + private void ZoomToArea_Click(object sender, EventArgs e) => WithActive(f => f.PlateView.SetAction(typeof(ActionZoomWindow))); private void ZoomToFit_Click(object sender, EventArgs e) => WithActive(f => f.PlateView.ZoomToFit()); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 3b1a40b1..ecbe16e5 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -241,6 +241,7 @@ namespace OpenNest.Forms mnuViewDrawPiercePoints.Checked = activeForm.PlateView.DrawPiercePoints; mnuViewDrawBounds.Checked = activeForm.PlateView.DrawBounds; mnuViewDrawCutDirection.Checked = activeForm.PlateView.DrawCutDirection; + mnuViewDrawSupport.Checked = activeForm.PlateView.DrawSupport; } protected override void OnLoad(EventArgs e) diff --git a/OpenNest/Forms/NewNestFactory.cs b/OpenNest/Forms/NewNestFactory.cs index bb7e435f..303e09f9 100644 --- a/OpenNest/Forms/NewNestFactory.cs +++ b/OpenNest/Forms/NewNestFactory.cs @@ -41,6 +41,7 @@ namespace OpenNest.Forms (units is { } converted ? defaults.ConvertedTo(converted) : defaults).ApplyTo(nest); // After the defaults so the rail is converted to the new nest's units. MachineSettings.LoadClampSettings(owner).ApplyToNewNest(nest); + MachineSettings.LoadSupportGridSettings().ApplyToNewNest(nest); return nest; } diff --git a/OpenNest/Forms/SupportGridForm.cs b/OpenNest/Forms/SupportGridForm.cs new file mode 100644 index 00000000..33d0f2ab --- /dev/null +++ b/OpenNest/Forms/SupportGridForm.cs @@ -0,0 +1,226 @@ +using System; +using System.Drawing; +using System.IO; +using System.Windows.Forms; +using OpenNest.Data; +using OpenNest.Support; + +namespace OpenNest.Forms; + +/// +/// Nest > Support Grid...: edits the laser grid of contact points every sheet of the current +/// nest rests on, used to judge whether cut parts stay level and where support tabs go (see +/// docs/support-tabs.md). OK applies it to the nest; Cancel discards the edits. Save as Machine +/// Defaults writes support-grid.json immediately and is not undone by Cancel. +/// +public sealed class SupportGridForm : Form +{ + private readonly Units units; + private readonly SupportGridSettings machineDefaults; + private readonly string settingsPath; + private readonly CheckBox useGridBox; + private readonly NumericUpDown pitchXBox; + private readonly NumericUpDown pitchYBox; + private readonly NumericUpDown offsetXBox; + private readonly NumericUpDown offsetYBox; + private readonly NumericUpDown toleranceBox; + private readonly NumericUpDown marginBox; + private readonly CheckBox applyToNewNestsBox; + private readonly Label savedLabel; + + public SupportGridForm(SupportBed bed, Units units, SupportGridSettings machineDefaults, string settingsPath = null) + { + this.units = units; + this.machineDefaults = machineDefaults ?? new SupportGridSettings(); + this.settingsPath = settingsPath ?? SupportGridSettings.DefaultPath; + var grid = bed as SupportGrid; + // A nest without a grid starts from the machine defaults, so enabling it is one click. + var initial = grid != null + ? SupportGridSettings.FromGrid(grid, units, this.machineDefaults.Enabled) + : this.machineDefaults.ConvertedTo(units); + var unit = UnitsHelper.GetShortString(units); + + Text = "Support Grid"; + StartPosition = FormStartPosition.CenterParent; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + MinimizeBox = false; + MaximizeBox = false; + ShowInTaskbar = false; + FormBorderStyle = FormBorderStyle.FixedDialog; + + var layout = new FlowLayoutPanel + { + AutoSize = true, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + Padding = new Padding(10), + }; + layout.Controls.Add(new Label + { + AutoSize = true, + MaximumSize = new Size(430, 0), + Padding = new Padding(0, 0, 0, 8), + Text = "The grid of contact points cut parts rest on, one every pitch along X and Y, the first " + + "at the offset from the plate origin into the sheet. A part whose center of gravity " + + "is not inside its contacts by the stability margin can tip; with \"Tab parts the " + + "grid cannot support\" in Cutting Settings, Plan Cutting tabs it where the tab " + + "steadies it. The registration tolerance allows for a sheet loaded off its place.", + }); + + useGridBox = new CheckBox { Text = "This nest's sheets rest on a support grid", AutoSize = true, Checked = grid != null }; + layout.Controls.Add(useGridBox); + + var values = new TableLayoutPanel { AutoSize = true, ColumnCount = 3 }; + pitchXBox = NewLengthBox(initial.PitchX, 0.0001m); + pitchYBox = NewLengthBox(initial.PitchY, 0.0001m); + offsetXBox = NewLengthBox(initial.OffsetX, -100000m); + offsetYBox = NewLengthBox(initial.OffsetY, -100000m); + toleranceBox = NewLengthBox(initial.RegistrationTolerance, 0m); + marginBox = NewLengthBox(initial.StabilityMargin, 0m); + AddRow(values, "Pitch along X:", pitchXBox, unit); + AddRow(values, "Pitch along Y:", pitchYBox, unit); + AddRow(values, "First point, X from the origin:", offsetXBox, unit); + AddRow(values, "First point, Y from the origin:", offsetYBox, unit); + AddRow(values, "Registration tolerance:", toleranceBox, unit); + AddRow(values, "Stability margin:", marginBox, unit); + layout.Controls.Add(values); + + var defaultsGroup = new GroupBox { Text = "Machine defaults (support-grid.json)", AutoSize = true, Padding = new Padding(8) }; + var defaultsLayout = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + AutoSize = true, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + }; + applyToNewNestsBox = new CheckBox + { + Text = "Give new nests this grid", + AutoSize = true, + Checked = this.machineDefaults.Enabled, + }; + var defaultsButtons = new FlowLayoutPanel { AutoSize = true, WrapContents = false }; + var loadDefaultsButton = new Button { Text = "Load Machine Defaults", AutoSize = true }; + var saveDefaultsButton = new Button { Text = "Save as Machine Defaults", AutoSize = true }; + defaultsButtons.Controls.Add(loadDefaultsButton); + defaultsButtons.Controls.Add(saveDefaultsButton); + savedLabel = new Label + { + AutoSize = true, + MaximumSize = new Size(410, 0), + Text = "Save as Machine Defaults writes the file immediately; Cancel below does not undo it.", + }; + defaultsLayout.Controls.Add(applyToNewNestsBox); + defaultsLayout.Controls.Add(defaultsButtons); + defaultsLayout.Controls.Add(savedLabel); + defaultsGroup.Controls.Add(defaultsLayout); + layout.Controls.Add(defaultsGroup); + + var buttons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Anchor = AnchorStyles.Right }; + var cancel = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + var ok = new Button { Text = "OK", AutoSize = true }; + buttons.Controls.Add(cancel); + buttons.Controls.Add(ok); + layout.Controls.Add(buttons); + + Controls.Add(layout); + AcceptButton = ok; + CancelButton = cancel; + + useGridBox.CheckedChanged += (_, _) => UpdateEnabled(); + loadDefaultsButton.Click += (_, _) => ShowValues(this.machineDefaults.ConvertedTo(units)); + saveDefaultsButton.Click += (_, _) => SaveMachineDefaults(); + ok.Click += (_, _) => Accept(); + UpdateEnabled(); + } + + /// The grid the nest's sheets rest on after OK, or null for none. + public SupportGrid Grid { get; private set; } + + private SupportGridSettings Read() => new() + { + Enabled = applyToNewNestsBox.Checked, + Units = units, + PitchX = (double)pitchXBox.Value, + PitchY = (double)pitchYBox.Value, + OffsetX = (double)offsetXBox.Value, + OffsetY = (double)offsetYBox.Value, + RegistrationTolerance = (double)toleranceBox.Value, + StabilityMargin = (double)marginBox.Value, + }; + + private void ShowValues(SupportGridSettings settings) + { + pitchXBox.Value = ToBoxValue(pitchXBox, settings.PitchX); + pitchYBox.Value = ToBoxValue(pitchYBox, settings.PitchY); + offsetXBox.Value = ToBoxValue(offsetXBox, settings.OffsetX); + offsetYBox.Value = ToBoxValue(offsetYBox, settings.OffsetY); + toleranceBox.Value = ToBoxValue(toleranceBox, settings.RegistrationTolerance); + marginBox.Value = ToBoxValue(marginBox, settings.StabilityMargin); + applyToNewNestsBox.Checked = settings.Enabled; + } + + private void UpdateEnabled() + { + foreach (var box in new[] { pitchXBox, pitchYBox, offsetXBox, offsetYBox, toleranceBox, marginBox }) + box.Enabled = useGridBox.Checked; + } + + private void SaveMachineDefaults() + { + var settings = Read(); + try + { + settings.Save(settingsPath); + savedLabel.Text = settings.Enabled + ? $"Saved {DateTime.Now:t}: new nests get this grid. Cancel does not undo this." + : $"Saved {DateTime.Now:t}: new nests get no grid. Cancel does not undo this."; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + MessageBox.Show(this, $"The machine defaults could not be saved:\n{settingsPath}\n\n{ex.Message}", + Text, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void Accept() + { + Grid = useGridBox.Checked ? Read().GridFor(units) : null; + if (useGridBox.Checked && Grid == null) + { + MessageBox.Show(this, "Both pitches must be greater than zero.", Text, + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + DialogResult = DialogResult.OK; + } + + private static NumericUpDown NewLengthBox(double value, decimal minimum) + { + var box = new NumericUpDown + { + DecimalPlaces = 4, + Minimum = minimum, + Maximum = 100000, + Increment = 0.125m, + Width = 110, + }; + box.Value = ToBoxValue(box, value); + return box; + } + + private static decimal ToBoxValue(NumericUpDown box, double value) + { + if (!double.IsFinite(value) || value < (double)box.Minimum) + return box.Minimum; + return value > (double)box.Maximum ? box.Maximum : (decimal)value; + } + + private static void AddRow(TableLayoutPanel table, string label, Control box, string unit) + { + table.Controls.Add(new Label { Text = label, AutoSize = true, Padding = new Padding(0, 6, 6, 0) }); + table.Controls.Add(box); + table.Controls.Add(new Label { Text = unit, AutoSize = true, Padding = new Padding(0, 6, 0, 0) }); + } +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index fbb42c01..1498aa97 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -35,6 +35,10 @@ var result = CuttingPlanService.Plan(snapshot, cancellationToken); Locked placements never regenerate. Foreign or duplicate eligible identities are invalid; eligibility without confirmed parameters is invalid. - `preservePartOrder` fixes whole-part order, not eligible internal contour choices. +- With `CuttingParameters.SupportTabs` and a support bed (`CuttingPlanRequest.SupportBed`, + the plate's own under `ForPlate`), each regenerated part the bed cannot hold level gets a + tabbed perimeter whose automatic entries keep to points where the tab steadies it; see + [support tabs](support-tabs.md). - Parameters are caller-confirmed inputs. The service does not recover missing operator settings or silently change lead styles to find a solution. diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 77ee1011..589fd978 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -123,6 +123,17 @@ and sheets with parts, cut-offs, removed clamps or explicit zones are not change An empty plate with other zones, or a rail-following one whose size, quadrant or spacing differs from the saved defaults, is still written. +## Support grid + +`plateDefaults.supportBed` optionally stores the machine support bed every sheet of +the nest rests on (see [support tabs](support-tabs.md)): `type` (`grid`, the only +layout so far), `pitchX`, `pitchY`, `offsetX`, `offsetY` (the first grid point, +measured from the plate origin into the sheet), `registrationTolerance` and +`stabilityMargin`, all in the nest's units. The key is omitted when the nest has +none, so such files are unchanged. A missing pitch, a non-numeric value, an unknown +`type` or an invalid value (a non-positive pitch, a negative tolerance or margin) +loads as no bed; the rest of the nest loads. Every plate follows the nest's bed. + ## Drawing edits `DrawingProgramSnapshot` captures serialized main and hole-program text before diff --git a/docs/support-tabs.md b/docs/support-tabs.md new file mode 100644 index 00000000..926105af --- /dev/null +++ b/docs/support-tabs.md @@ -0,0 +1,73 @@ +# Support grid and support tabs + +A cut part drops onto the machine's support bed. If its center of gravity is not over the +points it rests on, it can tip into the head's path or fall through. OpenNest models the bed, +checks every part against it, and can tab only the parts the bed cannot hold level, at the +perimeter point where one tab steadies the part most. + +## The support bed + +`OpenNest.Support.SupportBed` is the machine's bed, fixed to the machine and measured from the +plate origin into the sheet; a sheet in another quadrant sees it mirrored. The built-in layout +is the laser grid, `SupportGrid`: one contact point every `PitchX` along X and `PitchY` along +Y, the first at (`OffsetX`, `OffsetY`). Lengths are in the nest's units. Straight slats, and +curved slats imported from a CAD layout (as on the plasma table), are planned as further +`SupportBed` subclasses: a layout only reports its contacts under a part's material. + +Every bed also carries: + +- `RegistrationTolerance`: how far a sheet may sit from its nominal place. The bed is checked + shifted by up to this much along X, Y and both diagonals, and the worst result is kept. +- `StabilityMargin`: how far inside its contacts the center of gravity must lie to count as + supported, allowing for slag, gas pressure and worn contacts. + +The bed belongs to the nest (`Nest.SetSupportBed`); every plate follows it, including plates +added later. It is display, analysis and tab placement only: never material, spacing or a +keep-out. Nest > Support Grid... edits it; Save as Machine Defaults writes +`%APPDATA%\OpenNest\support-grid.json`, which new nests receive while it is enabled. The +nest file stores it in `plateDefaults.supportBed` (see [nest file format](nest-file-format.md)). + +## The support check + +`SupportAnalyzer.Analyze` takes a placed part's material (`SupportMaterial`: outline minus +holes, chorded at 0.001; leads, rapids, scribes and break cuts are not material, and a tabbed +cutout's slug counts as part since it stays attached). Plate is of even thickness, so the +center of gravity is the area centroid. The part rests on the bed contacts inside its material; +the margin is the signed distance of the center of gravity inside the convex hull of those +contacts (negative outside). Fewer than three contacts that are not in line can only balance a +part, so their margin is at most zero; no contacts at all is negative infinity. A part is +supported when its worst margin is positive and at least the stability margin. + +A perimeter tab holds the part at the tab, so a tab point counts as one more contact. +`PartSupport.MarginWith(point)` is the worst margin with a tab there, and `BestTabPoint` the +outline point (vertices plus 256 even samples) that maximizes it. `AcceptsTabAt(point)` accepts +any point that reaches the stability margin, or, when no single tab can, only points as good as +the best one found. A tab's bending strength is ignored: it is a support point, not a cantilever. + +View > Draw Support shows the grid (while its points are at least 6 pixels apart), each part's +contacts, and its center of gravity: green when supported, red when it can tip, with an orange +diamond on a red part's best tab point. + +## Support tabs in Plan Cutting + +Cutting Settings > Tabs > "Tab parts the support grid cannot hold level" sets +`CuttingParameters.SupportTabs`. With it and a plate support bed, `CuttingPlanService` capture +analyzes each regenerated part (`SupportTabCapture`). A part the bed cannot hold level gets a +tabbed perimeter (`PreparedContours.WithSupportTab`), using `TabConfig` for the tab size; all +other parts keep the plate settings, so supported parts are not tabbed. + +The emitter tabs a perimeter at its entry: the cut stops short of the entry by the tab size. +A support-tabbed perimeter's automatic entries are therefore limited to catalogue points the +analysis accepts, plus the closest native point to its best tab point (`AutomaticEntryKind.SupportTab`, +never on a reflex or cusp vertex). Ranking, lead checks and replay are unchanged. If none of +those entries has a lead-in that fits, the search falls back to the full catalogue and the part +is still tabbed, wherever a lead-in fits; this fallback is not reported yet. + +Not covered yet: + +- A drawing pin on the perimeter wins: its entry and tab are kept as pinned. +- Locked or ineligible parts keep their programs; the older Assign Lead-ins command and + Auto Tab sizes ignore the bed. +- Only one perimeter tab per part. A part that needs more (one tab cannot reach the stability + margin) gets the best single tab. +- Pre-post verification does not yet warn about unsupported, untabbed parts.