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.