From c3881c5534928eb6aa726a930371c644f1664820 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 19:33:12 -0400 Subject: [PATCH] fix(support): center tied support tabs across the part's long side A tab cannot lift a part's margin past its contacts' own hull edges, so a whole stretch of outline often ties for the best tab point, and the first sample found won, leaving the tab off-center. Keep the full outline search for the margin, then among samples within Tolerance.Epsilon of the best, pick the one nearest the line through the center of gravity across the long side of the outline's minimum-area bounding rectangle. A part held only near one end is still tabbed at the far end. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Support/SupportAnalyzer.cs | 23 +++++++++++-- .../Support/SupportAnalyzerTests.cs | 33 +++++++++++++++++++ docs/support-tabs.md | 12 ++++--- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/OpenNest.Core/Support/SupportAnalyzer.cs b/OpenNest.Core/Support/SupportAnalyzer.cs index 4bcca2f2..7ec44af0 100644 --- a/OpenNest.Core/Support/SupportAnalyzer.cs +++ b/OpenNest.Core/Support/SupportAnalyzer.cs @@ -67,16 +67,24 @@ namespace OpenNest.Support samples = allowed; var scored = samples.Select(s => (s.Point, s.Clear, Margin: MarginWith(center, hulls, s.Point))).ToList(); - // Equally good points run along a straight edge; of those, the one farthest from an - // inside corner, or the first. + // A tab cannot lift the margin past the contacts' own hull edges, so a whole stretch + // of outline often ties; of those, the one nearest the line through the center across + // the outline's long side, then the one farthest from an inside corner, or the first. var bestMargin = scored.Count == 0 ? double.NegativeInfinity : scored.Max(s => s.Margin); + var along = scored.Count == 0 ? Vector.Zero : LongSide(material.Outline); Vector? best = null; + var bestOffset = double.PositiveInfinity; var bestClear = double.NegativeInfinity; foreach (var sample in scored) { - if (sample.Margin >= bestMargin - Math.Tolerance.Epsilon && sample.Clear > bestClear) + if (sample.Margin < bestMargin - Math.Tolerance.Epsilon) + continue; + var offset = System.Math.Abs((sample.Point - center).DotProduct(along)); + if (offset < bestOffset - Math.Tolerance.Epsilon + || (offset <= bestOffset + Math.Tolerance.Epsilon && sample.Clear > bestClear)) { best = sample.Point; + bestOffset = offset; bestClear = sample.Clear; } } @@ -85,6 +93,15 @@ namespace OpenNest.Support corners, insideCornerClearance); } + /// The unit direction of the longer side of the outline's minimum-area bounding rectangle. + private static Vector LongSide(IReadOnlyList outline) + { + // The full angle range checks every hull edge. + var box = RotatingCalipers.MinimumBoundingRectangle(ConvexHull.Compute(outline.ToList()), 0, Math.Angle.TwoPI); + var angle = box.Width >= box.Height ? box.Angle : box.Angle + Math.Angle.HalfPI; + return new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); + } + // A point `clear` from the nearest inside corner keeps `clearance` from it; with no // clearance it must still not be the corner. internal static bool IsClear(double clear, double clearance) => diff --git a/OpenNest.Tests/Support/SupportAnalyzerTests.cs b/OpenNest.Tests/Support/SupportAnalyzerTests.cs index f876e099..f523cb05 100644 --- a/OpenNest.Tests/Support/SupportAnalyzerTests.cs +++ b/OpenNest.Tests/Support/SupportAnalyzerTests.cs @@ -73,6 +73,39 @@ public class SupportAnalyzerTests Assert.True(support.MarginWith(new Vector(5, 0)) < 0); } + [Fact] + public void Strip_WhoseTabsTieAlongTheFarEdge_IsTabbedAcrossItsCenterOfGravity() + { + // The row's own edge caps the margin at 0.25, so tabs from about x = 2.5 to 7.5 on the + // far edge tie; the one across the center of gravity is chosen. + var grid = new SupportGrid(2, 4, 0, 0.25, stabilityMargin: 0.1); + + var support = SupportAnalyzer.Analyze(grid, 1, Rectangle(0, 0, 10, 1)); + + Assert.Equal(0.25, support.MarginWith(new Vector(3, 1)), 9); + Assert.Equal(0.25, support.MarginWith(new Vector(7, 1)), 9); + Assert.Equal(0.25, support.BestTabMargin, 9); + Assert.Equal(1, support.BestTabPoint!.Value.Y, 9); + Assert.InRange(support.BestTabPoint!.Value.X, 4.95, 5.05); + } + + [Fact] + public void Strip_RestingOnlyNearOneEnd_IsTabbedAtTheFarEndNotAcrossItsCenter() + { + // Two contacts at x = 1 hold a 10 x 2 strip whose center of gravity is at (5, 1); only a + // tab at the far end reaches past it, so the tie-break does not pull it to mid-side. + var grid = new SupportGrid(20, 1, 1, 0.5, stabilityMargin: 0.1); + + var support = SupportAnalyzer.Analyze(grid, 1, Rectangle(0, 0, 10, 2)); + + Assert.Equal(-4, support.Margin, 9); + Assert.True(support.TabCanSupport); + Assert.Equal(10, support.BestTabPoint!.Value.X, 9); + Assert.InRange(support.BestTabPoint!.Value.Y, 0.9, 1.1); + Assert.True(support.MarginWith(new Vector(5, 2)) < 0); + Assert.False(support.AcceptsTabAt(new Vector(5, 2))); + } + [Fact] public void Strip_BetweenRows_FallsThroughAndOnlyTheNearestTabIsAccepted() { diff --git a/docs/support-tabs.md b/docs/support-tabs.md index 7834cc90..b778597e 100644 --- a/docs/support-tabs.md +++ b/docs/support-tabs.md @@ -40,7 +40,11 @@ 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 +outline point (vertices plus 256 even samples) that maximizes it. A tab cannot lift the margin +past the contacts' own hull edges, so a whole stretch of outline often ties; among samples +within `Tolerance.Epsilon` of the best, the one nearest the line through the center of gravity +across the long side of the outline's minimum-area bounding rectangle wins. The search itself +is not narrowed, so a part held only near one end is still tabbed at the far end. `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. @@ -48,9 +52,9 @@ A tab never goes in an inside corner, where its nub cannot be ground flush. An i a run of consecutive inward-turning outline vertices that turn at least 30 degrees together, so a chorded fillet counts while a shallow bend does not. Tab points keep the analysis's inside-corner clearance from those vertices (Plan Cutting and the overlay use twice the tab size; 0 keeps a tab -off the corner only), and `AcceptsTabAt` rejects closer points. Equally good points usually run -along a straight edge; of those, `BestTabPoint` is the one farthest from an inside corner. Only -when every outline point is too close does the clearance give way. +off the corner only), and `AcceptsTabAt` rejects closer points. The tie-break above runs over +the clear points only; points equally near the center line go to the one farthest from an +inside corner. Only when every outline point is too close does the clearance give way. 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