fix(cutoffs): use outward union for fitted arc bounds

This commit is contained in:
aj committed 2026-10-11 20:56:34 -04:00
1 parent ddc4710dfd
commit 322dc3eda6
4 files changed
+100 -3

No files matched your search

+25 -2
View File
@@ -134,8 +134,22 @@ namespace OpenNest
double clearance
)
{
var bb = part.BoundingBox;
var cached = part.BoundingBox;
var roundoff = GetBoundsRoundoff(part);
// Keep the farther of the cached and converted perimeter extents. The
// perimeter's fine intersection/offset can miss a near-tangent sliver;
// only a line in that narrow outer band needs the conservative box.
var bb = cached;
if (perimeter != null)
{
var converted = perimeter.BoundingBox;
var left = System.Math.Min(cached.Left, converted.Left);
var right = System.Math.Max(cached.Right, converted.Right);
var bottom = System.Math.Min(cached.Bottom, converted.Bottom);
var top = System.Math.Max(cached.Top, converted.Top);
bb = new Box(left, bottom, right - left, top - bottom);
}
var (cachedMin, cachedMax) = AxisBounds(cached, clearance);
var (partMin, partMax) = AxisBounds(bb, clearance);
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
// Match the planner's representational allowance in BOTH pruning and
@@ -150,6 +164,8 @@ namespace OpenNest
if (perimeter != null)
{
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
return new List<(double Start, double End)> { (partStart, partEnd) };
var perimeterExclusions = IntersectPerimeter(
perimeter,
cutPosition,
@@ -180,7 +196,14 @@ namespace OpenNest
magnitude = System.Math.Max(magnitude,
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
var step = System.Math.BitIncrement(magnitude) - magnitude;
return double.IsFinite(step) ? System.Math.Min(8 * step, Math.Tolerance.Epsilon / 4) : 0;
// Imported, nearly tangent arcs can amplify endpoint rounding when
// ConvertProgram fits their centers to a chord. Keep that allowance
// tiny and apply it to both pruning and fallback exclusions above;
// larger refits still require an explicit geometry repair.
var arcFitRoundoff = System.Math.Min(4096 * step, Math.Tolerance.Epsilon / 100000);
return double.IsFinite(step)
? System.Math.Min(System.Math.Max(8 * step, arcFitRoundoff), Math.Tolerance.Epsilon / 4)
: 0;
}
private List<(double Start, double End)> IntersectPerimeter(
@@ -678,6 +678,23 @@ public class AutomaticCutOffPlannerTests
Assert.Equal(0, changes);
}
[Fact]
public void LargeArcRefit_DoesNotReplaceConvertedPerimeterWithUndersizedBox()
{
var plate = MakeArcPlate(0.0001);
var part = Assert.Single(plate.Parts);
var perimeter = Plate.BuildPerimeterCache(plate)[part];
Assert.NotNull(perimeter);
Assert.True(perimeter.BoundingBox.Right > part.BoundingBox.Right + CutOff.GetBoundsRoundoff(part));
var arc = Assert.Single(ConvertProgram.ToGeometry(part.Program).OfType<Arc>());
arc.Offset(part.Location);
var x = (part.BoundingBox.Right + arc.BoundingBox.Right) / 2;
var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical);
manual.Regenerate(plate, new CutOffSettings { PartClearance = 0 }, Plate.BuildPerimeterCache(plate));
foreach (var segment in Segments(manual.Drawing.Program))
Assert.False(arc.Intersects(new Line(segment.From, segment.To), out _));
}
[Fact]
public void ExactArcWithinCachedBounds_PlansAndMaterializesSafeCuts()
{
@@ -66,6 +66,63 @@ public class CutOffRoundoffTests
Assert.Empty(AutomaticCutOffPlanner.Create(loadedPlate, options, settings).Definitions);
}
[SkippableFact]
public void RoundedImportedArcs_PlanAllPlatesWithoutEditingSource()
{
var path = TestConfig.GetExistingPath("RoundedCutOffNestPath");
Skip.If(path == null, "Configure RoundedCutOffNestPath in ignored test-config.json.");
var hash = System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(path!));
var nest = new NestReader(path).Read();
var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToArray();
Assert.Equal(2, plates.Length);
var parts = plates.SelectMany(p => p.Parts).ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var result = AutomaticCutOffBatch.Create(plates,
new AutomaticCutOffOptions { Spacing = 4, MinimumTailLength = 12 }, new CutOffSettings());
Assert.Equal(plates.Length, result.Count);
Assert.All(result, p => Assert.False(p.HasBlockingDiagnostics));
Assert.All(result, p => Assert.NotEmpty(p.Definitions));
var defaultSpacing = AutomaticCutOffBatch.Create(plates,
new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 }, new CutOffSettings());
Assert.All(defaultSpacing, p => Assert.False(p.HasBlockingDiagnostics));
Assert.All(defaultSpacing, p => Assert.NotEmpty(p.Definitions));
// Probe a separator in the tiny converted-arc protrusion: both cached and
// uncached regeneration must leave the original curve untouched.
var witness = plates[0].Parts.SelectMany(part => ConvertProgram.ToGeometry(part.Program)
.OfType<Arc>().Where(a => SpecialLayers.IsMaterial(a.Layer))
.Select(a => (part, arc: a, top: a.BoundingBox.Translate(part.Location).Top)))
.First(w => w.top > w.part.BoundingBox.Top + 8 *
(System.Math.BitIncrement(w.part.BoundingBox.Top) - w.part.BoundingBox.Top));
witness.arc.Offset(witness.part.Location);
var y = (witness.part.BoundingBox.Top + witness.top) / 2;
foreach (var useCache in new[] { false, true })
{
var cut = new CutOff(new Vector(0, y), CutOffAxis.Horizontal);
cut.Regenerate(plates[0], new CutOffSettings { PartClearance = 0 },
useCache ? Plate.BuildPerimeterCache(plates[0]) : null);
foreach (var line in ConvertProgram.ToGeometry(cut.Drawing.Program).OfType<Line>()
.Where(e => SpecialLayers.IsMaterial(e.Layer)))
Assert.False(witness.arc.Intersects(line, out _), $"useCache={useCache} y={y:R} segment={line}");
}
Assert.Equal(programs, parts.Select(p => p.Program));
Assert.Equal(bounds, parts.Select(p => p.BoundingBox));
Assert.All(plates, p => Assert.Empty(p.CutOffs));
var applied = AutomaticCutOffBatch.Apply(plates,
new AutomaticCutOffOptions { Spacing = 4, MinimumTailLength = 12 }, new CutOffSettings());
Assert.Equal(result.Select(p => p.Definitions.Count), applied.Select(p => p.Definitions.Count));
Assert.All(plates, p => Assert.NotEmpty(p.CutOffs));
Assert.Equal(programs, parts.Select(p => p.Program));
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var reloaded = new NestReader(stream).Read();
var repeated = AutomaticCutOffBatch.Create(reloaded.Plates.Where(p => p.Parts.Count > 0).ToArray(),
new AutomaticCutOffOptions { Spacing = 4, MinimumTailLength = 12 }, new CutOffSettings());
Assert.All(repeated, p => Assert.Empty(p.Definitions));
Assert.Equal(hash, System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(path!)));
}
[Theory]
[InlineData(CutOffAxis.Vertical, false, 0)]
[InlineData(CutOffAxis.Vertical, true, 0)]
+1 -1
View File
@@ -11,7 +11,7 @@ Finish nesting, then choose **Nest > Automatic Scrap Cutoffs (All Plates)** to p
For the Nest command, the on-sheet overlay and upper measurements show the active plate; the scrollable diagnostics list reports every plate's proposed cuts, occupied/used spans, retained tail and warnings. Each plate uses its own dimensions, quadrant and part spacing with the dialog's common spacing/minimum-tail settings and current cutoff clearance/direction settings. No plate navigation or per-plate confirmation is required. Empty or unchanged plates are left untouched. A blocking conflict or invalid plate prevents changes to **all** plates, with the plate number reported; resolve it before retrying. If application fails after changes begin, all touched plates' original cutoff programs and sequence are restored. An explicit incomplete-recovery warning requires manual review before saving or cutting.
An empty sheet is left untouched. Invalid input or out-of-sheet part geometry prevents automatic planning. Excessively small spacing is rejected rather than allocating an unbounded number of cuts. Ordinary floating-point roundoff between translated material and cached bounds is accepted without changing the part or its program; cutoff obstacle checks and fallback exclusions receive the same conservative numerical padding. Larger discrepancies (for example, an inconsistent arc center corrected during conversion) still prevent planning rather than risking a cut through material; repair the geometry before retrying.
An empty sheet is left untouched. Invalid input or out-of-sheet part geometry prevents automatic planning. Excessively small spacing is rejected rather than allocating an unbounded number of cuts. Small bounded discrepancies between converted material and cached bounds—including amplified roundoff in fitted near-tangent arcs—are accepted without changing the part or its program. The same conservative numerical padding applies to cutoff pruning and exclusions. Their broad-phase envelope uses the farther edge of the cached box and converted perimeter; a cutoff within the narrow uncertainty band at a cached edge gets a full-envelope exclusion instead of relying on a near-tangent contour intersection. Elsewhere the contour is retained for precise trimming. Larger arc-center discrepancies still prevent automatic planning rather than risking a cut through material; repair the geometry before retrying.
## Preserving the unused tail