From a7c0747e11ec0c9bd204ab7b2331c0f6182e2bb4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 16:55:24 -0400 Subject: [PATCH] feat(planning): tab parts the laser support grid cannot hold level Model the machine's support bed as a grid of contact points measured from the plate origin, and check each part's center of gravity against the convex hull of the grid points under its material. The bed is extensible so straight and CAD-imported curved slats can follow. - Core: SupportBed/SupportGrid, SupportMaterial and SupportAnalyzer (margin with registration tolerance, best single tab point) - Nest-wide support bed that every plate follows, saved in plateDefaults.supportBed; machine defaults in support-grid.json - CuttingParameters.SupportTabs: Plan Cutting tabs only unsupported parts and limits their perimeter entries to points where the tab steadies the part, falling back to any fitting entry - Desktop: Nest > Support Grid..., View > Draw Support overlay and a "Tab parts the support grid cannot hold level" tab option - docs/support-tabs.md, nest file format and planner notes Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + .../CuttingPlanning/ContourEntryCandidate.cs | 6 + .../CuttingPlanning/OwnedCuttingParameters.cs | 1 + .../CNC/CuttingPlanning/PreparedContours.cs | 74 ++++- .../CNC/CuttingPlanning/StateFingerprint.cs | 1 + .../CNC/CuttingStrategy/CuttingParameters.cs | 7 + OpenNest.Core/Nest.cs | 23 ++ OpenNest.Core/Plate.cs | 8 + OpenNest.Core/Support/SupportAnalyzer.cs | 254 ++++++++++++++++++ OpenNest.Core/Support/SupportBed.cs | 65 +++++ OpenNest.Core/Support/SupportGrid.cs | 116 ++++++++ OpenNest.Core/Support/SupportMaterial.cs | 222 +++++++++++++++ OpenNest.Data/SupportGridSettings.cs | 173 ++++++++++++ .../CuttingPlanning/CuttingPlanModels.cs | 13 +- .../CuttingPlanning/CuttingPlanService.cs | 3 + .../CuttingPlanning/JointCuttingPlanSearch.cs | 56 ++-- .../CuttingPlanning/SupportTabCapture.cs | 34 +++ .../SupportGridPersistenceTests.cs | 115 ++++++++ OpenNest.IO/CuttingParametersDto.cs | 1 + OpenNest.IO/CuttingParametersSerializer.cs | 2 + OpenNest.IO/NestFormat.cs | 79 ++++++ OpenNest.IO/NestReader.cs | 19 ++ OpenNest.IO/NestWriter.cs | 15 ++ .../SupportTabPlanningTests.cs | 92 +++++++ .../Data/SupportGridSettingsTests.cs | 78 ++++++ .../Support/SupportAnalyzerTests.cs | 133 +++++++++ OpenNest/Controls/CuttingPanel.cs | 28 +- OpenNest/Controls/PlateView.cs | 6 + OpenNest/Controls/SupportOverlay.cs | 110 ++++++++ OpenNest/Forms/BomImportForm.cs | 2 + OpenNest/Forms/EditNestForm.cs | 6 + OpenNest/Forms/MachineSettings.cs | 4 + OpenNest/Forms/MainForm.Designer.cs | 23 +- OpenNest/Forms/MainForm.Nest.cs | 24 ++ OpenNest/Forms/MainForm.View.cs | 8 + OpenNest/Forms/MainForm.cs | 1 + OpenNest/Forms/NewNestFactory.cs | 1 + OpenNest/Forms/SupportGridForm.cs | 226 ++++++++++++++++ docs/cutting-planner.md | 4 + docs/nest-file-format.md | 11 + docs/support-tabs.md | 73 +++++ 41 files changed, 2090 insertions(+), 28 deletions(-) create mode 100644 OpenNest.Core/Support/SupportAnalyzer.cs create mode 100644 OpenNest.Core/Support/SupportBed.cs create mode 100644 OpenNest.Core/Support/SupportGrid.cs create mode 100644 OpenNest.Core/Support/SupportMaterial.cs create mode 100644 OpenNest.Data/SupportGridSettings.cs create mode 100644 OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs create mode 100644 OpenNest.IO.Tests/SupportGridPersistenceTests.cs create mode 100644 OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs create mode 100644 OpenNest.Tests/Data/SupportGridSettingsTests.cs create mode 100644 OpenNest.Tests/Support/SupportAnalyzerTests.cs create mode 100644 OpenNest/Controls/SupportOverlay.cs create mode 100644 OpenNest/Forms/SupportGridForm.cs create mode 100644 docs/support-tabs.md 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.