Merge branch 'fix/failing-tests-after-master-pull'

This commit is contained in:
aj
2026-09-21 11:07:49 -04:00
12 changed files with 352 additions and 81 deletions
+25 -12
View File
@@ -305,22 +305,27 @@ namespace OpenNest.CNC
return new Vector(0, 0);
}
/// <summary>
/// Bounding box of the geometry the program visits. The tool's starting position is not
/// part of the geometry, so the origin only contributes when the program reaches it.
/// An empty program returns a zero-size box at the origin.
/// </summary>
public Box BoundingBox()
{
var origin = new Vector(0, 0);
return BoundingBox(ref origin);
return BoundingBox(ref origin, out var box) ? box : new Box(0, 0, 0, 0);
}
private Box BoundingBox(ref Vector pos)
private bool BoundingBox(ref Vector pos, out Box result)
{
// Capture the frame origin at entry. Sub-program Offsets and
// absolute-mode endpoints are relative to this fixed origin.
var frameOrigin = pos;
double minX = 0.0;
double minY = 0.0;
double maxX = 0.0;
double maxY = 0.0;
var minX = double.PositiveInfinity;
var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity;
var maxY = double.NegativeInfinity;
for (int i = 0; i < Codes.Count; ++i)
{
@@ -338,12 +343,12 @@ namespace OpenNest.CNC
if (pt.X > maxX)
maxX = pt.X;
else if (pt.X < minX)
if (pt.X < minX)
minX = pt.X;
if (pt.Y > maxY)
maxY = pt.Y;
else if (pt.Y < minY)
if (pt.Y < minY)
minY = pt.Y;
pos = pt;
@@ -361,12 +366,12 @@ namespace OpenNest.CNC
if (pt.X > maxX)
maxX = pt.X;
else if (pt.X < minX)
if (pt.X < minX)
minX = pt.X;
if (pt.Y > maxY)
maxY = pt.Y;
else if (pt.Y < minY)
if (pt.Y < minY)
minY = pt.Y;
pos = pt;
@@ -470,7 +475,8 @@ namespace OpenNest.CNC
// Sub-program frame origin in this program's frame
// is frameOrigin + Offset, regardless of current pos.
pos = frameOrigin + subpgm.Offset;
var box = subpgm.Program.BoundingBox(ref pos);
if (!subpgm.Program.BoundingBox(ref pos, out var box))
break;
if (box.Left < minX)
minX = box.Left;
@@ -489,7 +495,14 @@ namespace OpenNest.CNC
}
}
return new Box(minX, minY, maxX - minX, maxY - minY);
if (minX > maxX || minY > maxY)
{
result = new Box(0, 0, 0, 0);
return false;
}
result = new Box(minX, minY, maxX - minX, maxY - minY);
return true;
}
public object Clone()
+89 -1
View File
@@ -14,6 +14,12 @@ namespace OpenNest
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
public const double SnapToZero = 0.001;
/// <summary>Centroid offsets below this fraction of the MBR extent count as symmetric.</summary>
private const double SymmetryTolerance = 1e-6;
/// <summary>Angular margin (radians) keeping axis-aligned centroid offsets off the edge of the preferred quadrant.</summary>
private const double PreferenceMargin = 0.001;
/// <summary>
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
@@ -73,7 +79,89 @@ namespace OpenNest
return 0.0;
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
return FromMbr(mbr);
var angle = FromMbr(mbr);
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
return angle;
var quarterTurns = PreferredQuarterTurns(polygon, hull, angle);
if (quarterTurns == 0)
return angle;
return NormalizeSigned(angle + quarterTurns * System.Math.PI / 2.0);
}
/// <summary>
/// The MBR only fixes the frame modulo 90°, leaving four equivalent orientations. Nest
/// results are not 90°-symmetric, so pick one deterministically: the quarter-turn count
/// that puts the perimeter's centroid toward the lower-left of its MBR. Shapes with no
/// centroid offset (rectangles, circles) are symmetric and keep the MBR orientation.
/// </summary>
private static int PreferredQuarterTurns(Polygon polygon, Polygon hull, double angle)
{
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var vertex in hull.Vertices)
{
var rotated = vertex.Rotate(angle);
minX = System.Math.Min(minX, rotated.X);
minY = System.Math.Min(minY, rotated.Y);
maxX = System.Math.Max(maxX, rotated.X);
maxY = System.Math.Max(maxY, rotated.Y);
}
var centroid = Centroid(polygon).Rotate(angle);
var dx = centroid.X - (minX + maxX) / 2.0;
var dy = centroid.Y - (minY + maxY) / 2.0;
var extent = System.Math.Max(maxX - minX, maxY - minY);
if (System.Math.Sqrt(dx * dx + dy * dy) <= SymmetryTolerance * extent)
return 0;
// Choose k so the offset direction lands in [PI - margin, 3PI/2 - margin). The margin
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
// interval edge so floating-point noise cannot flip the choice.
var halfPi = System.Math.PI / 2.0;
var direction = System.Math.Atan2(dy, dx);
for (var turns = 0; turns < 4; turns++)
{
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
relative -= 2.0 * System.Math.PI * System.Math.Floor(relative / (2.0 * System.Math.PI));
if (relative < halfPi)
return turns;
}
return 0;
}
private static Vector Centroid(Polygon polygon)
{
var vertices = polygon.Vertices;
var doubleArea = 0.0;
var cx = 0.0;
var cy = 0.0;
for (var i = 0; i < vertices.Count; i++)
{
var p = vertices[i];
var q = vertices[(i + 1) % vertices.Count];
var cross = p.X * q.Y - q.X * p.Y;
doubleArea += cross;
cx += (p.X + q.X) * cross;
cy += (p.Y + q.Y) * cross;
}
if (System.Math.Abs(doubleArea) <= OpenNest.Math.Tolerance.Epsilon)
return new Vector(vertices.Average(v => v.X), vertices.Average(v => v.Y));
return new Vector(cx / (3.0 * doubleArea), cy / (3.0 * doubleArea));
}
private static double NormalizeSigned(double angle)
{
var twoPi = 2.0 * System.Math.PI;
angle -= twoPi * System.Math.Floor((angle + System.Math.PI) / twoPi);
return angle;
}
}
}
+1 -12
View File
@@ -70,19 +70,8 @@ namespace OpenNest.Engine.BestFit
public List<Part> BuildSourceParts(Drawing drawing)
{
var parts = BuildCanonicalParts();
var sourceAngle = drawing?.Source?.Angle ?? 0.0;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(drawing, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return NormalizeToCutOrigin(CanonicalFrame.FromCanonical(parts, sourceAngle));
return NormalizeToCutOrigin(CanonicalFrame.RebindToOriginal(parts, drawing));
}
public Box GetCutBounds(List<Part> parts)
+32
View File
@@ -47,6 +47,38 @@ namespace OpenNest.Engine
return copy;
}
/// <summary>
/// Rebinds canonical-frame placed parts to the original drawing while preserving each
/// part's world footprint.
///
/// <see cref="Part.Rotation"/> is cumulative: it includes the rotation already baked into
/// the drawing's program. The canonical copy carries original + sourceAngle, so a canonical
/// part's rotation minus the original program's rotation is exactly the rotation (engine
/// rotation plus sourceAngle) that turns the original into the same shape. Each part is
/// rebuilt from the original at that rotation and translated to sit where the canonical
/// part did. Rotating a finished part about its Location instead would shift it out of place.
/// </summary>
public static List<Part> RebindToOriginal(List<Part> canonicalParts, Drawing original)
{
if (canonicalParts == null || canonicalParts.Count == 0)
return canonicalParts;
var baseRotation = original.Program.Rotation;
for (var i = 0; i < canonicalParts.Count; i++)
{
var canonical = canonicalParts[i];
var rebound = Part.CreateAtOrigin(
original,
Angle.NormalizeRad(canonical.Rotation - baseRotation)
);
rebound.Offset(canonical.BoundingBox.Location - rebound.BoundingBox.Location);
rebound.UpdateBounds();
canonicalParts[i] = rebound;
}
return canonicalParts;
}
/// <summary>
/// Composes the source drawing's canonical angle onto each placed part so the
/// returned list is in the drawing's original (visible) frame.
+5 -27
View File
@@ -59,7 +59,6 @@ namespace OpenNest
// Replace the item's Drawing with a canonical copy for the duration of this fill.
// All internal methods see canonical geometry; this wrapper un-canonicalizes the final result.
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
var originalDrawing = item.Drawing;
var canonicalItem = new NestItem
{
@@ -81,7 +80,7 @@ namespace OpenNest
$"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}"
);
WinnerPhase = NestPhase.Pairs;
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
fast = RebindAndUnCanonicalize(fast, originalDrawing);
ReportProgress(
progress,
new ProgressReport
@@ -129,7 +128,7 @@ namespace OpenNest
if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
best = RebindAndUnCanonicalize(best, originalDrawing);
ReportProgress(
progress,
@@ -150,31 +149,10 @@ namespace OpenNest
/// <summary>
/// Single exit point for canonical -> source frame conversion. Rebinds every Part to the
/// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy)
/// and composes sourceAngle onto each Part's rotation via CanonicalFrame.FromCanonical.
/// and composes the canonical angle onto each Part's rotation via CanonicalFrame.RebindToOriginal.
/// </summary>
private static List<Part> RebindAndUnCanonicalize(
List<Part> parts,
Drawing original,
double sourceAngle
)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
// Rebind to `original` while preserving world pose. CreateAtOrigin rotates
// at the origin (keeping bbox at world (0,0)) then we offset to match p's bbox.
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
private static List<Part> RebindAndUnCanonicalize(List<Part> parts, Drawing original) =>
CanonicalFrame.RebindToOriginal(parts, original);
/// <summary>
/// Fast path for qty 1-2: place a single part or a best-fit pair
+21 -1
View File
@@ -114,7 +114,10 @@ namespace OpenNest.Engine.Fill
// rectangular obstacle boundary. Without this, gaps between
// individual bounding boxes cause the next drawing to fill
// into inter-row spaces, producing an interleaved layout.
if (placed.Count > 2)
// Only worthwhile while another drawing is still waiting for
// space; otherwise the removed part's slot is walled off by the
// envelope below and the part is lost for nothing.
if (placed.Count > 2 && HasOtherDemand(items, item, localQty))
RemoveTopmostPart(placed);
allParts.AddRange(placed);
@@ -132,6 +135,23 @@ namespace OpenNest.Engine.Fill
return false;
}
private static bool HasOtherDemand(
List<NestItem> items,
NestItem current,
Dictionary<Drawing, int> localQty
)
{
foreach (var other in items)
{
if (ReferenceEquals(other.Drawing, current.Drawing))
continue;
if (localQty[other.Drawing] > 0)
return true;
}
return false;
}
private static void RemoveTopmostPart(List<Part> parts)
{
var topIdx = 0;
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest;
@@ -20,6 +21,7 @@ namespace OpenNest;
public sealed class DefaultPlateNester : IPlateNester
{
private readonly Func<Plate, DefaultNestEngine> engineFactory;
private readonly OrderedPlateNester restrictedRotationNester = new();
private readonly Dictionary<string, Drawing> drawingsById = new(StringComparer.Ordinal);
private readonly Dictionary<Drawing, string> idByDrawing = new(
ReferenceEqualityComparer.Instance
@@ -44,6 +46,13 @@ public sealed class DefaultPlateNester : IPlateNester
ArgumentNullException.ThrowIfNull(request);
token.ThrowIfCancellationRequested();
// The legacy engine cannot express a locked or bounded rotation (start == end == 0 reads
// as "unconstrained") and its Pairs/RectBestFit strategies rotate freely, so it can return
// poses the requirement's RotationPolicy forbids. Restricted requirements go to the
// policy-aware ordered nester, which only proposes allowed angles and validates each pose.
if (request.Parts.Any(part => part.Rotation.Kind != RotationPolicyKind.Automatic))
return restrictedRotationNester.Place(request, progress, token);
var plate = DrawingJobMapper.CreatePlate(request.Stock);
var items = new List<NestItem>(request.Parts.Count);
foreach (var requirement in request.Parts)
+8 -28
View File
@@ -393,7 +393,6 @@ namespace OpenNest
// from a canonical drawing copy so geometry and coords share a frame; rebind
// + un-rotate winning pair to the original drawing's frame before returning.
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
List<Part> bestPlacement = null;
Box bestTarget = null;
@@ -438,9 +437,9 @@ namespace OpenNest
if (bestPlacement == null)
continue;
// Rebind to the original drawing and compose sourceAngle onto rotation so the
// final placed parts sit in the user's visible frame.
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle);
// Rebind to the original drawing and compose the canonical angle onto rotation so
// the final placed parts sit in the user's visible frame.
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing);
result.AddRange(bestPlacement);
item.Quantity = 0;
@@ -460,31 +459,12 @@ namespace OpenNest
/// <summary>
/// Rebinds each canonical-frame Part in the pair to the original Drawing at its current
/// world pose, then composes sourceAngle onto each via CanonicalFrame.FromCanonical so
/// the returned list is in the original drawing's visible frame. Mirrors
/// DefaultNestEngine.RebindAndUnCanonicalize.
/// world pose, then composes the canonical angle onto each via
/// CanonicalFrame.RebindToOriginal so the returned list is in the original drawing's
/// visible frame. Mirrors DefaultNestEngine.RebindAndUnCanonicalize.
/// </summary>
private static List<Part> RebindPairToOriginal(
List<Part> parts,
Drawing original,
double sourceAngle
)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
private static List<Part> RebindPairToOriginal(List<Part> parts, Drawing original) =>
CanonicalFrame.RebindToOriginal(parts, original);
/// <summary>
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
@@ -0,0 +1,50 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests.CNC;
public class ProgramBoundingBoxTests
{
[Fact]
public void GeometryAwayFromOrigin_DoesNotIncludeOrigin()
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(20, 10)));
pgm.Codes.Add(new LinearMove(new Vector(20, 30)));
pgm.Codes.Add(new LinearMove(new Vector(10, 30)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
var box = pgm.BoundingBox();
Assert.Equal(10, box.Left, precision: 6);
Assert.Equal(10, box.Bottom, precision: 6);
Assert.Equal(20, box.Right, precision: 6);
Assert.Equal(30, box.Top, precision: 6);
}
[Fact]
public void GeometryBelowAndLeftOfOrigin_IsTrackedExactly()
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(-30, -20)));
pgm.Codes.Add(new LinearMove(new Vector(-10, -20)));
pgm.Codes.Add(new LinearMove(new Vector(-10, -5)));
var box = pgm.BoundingBox();
Assert.Equal(-30, box.Left, precision: 6);
Assert.Equal(-20, box.Bottom, precision: 6);
Assert.Equal(-10, box.Right, precision: 6);
Assert.Equal(-5, box.Top, precision: 6);
}
[Fact]
public void EmptyProgram_ReturnsZeroBox()
{
var box = new OpenNest.CNC.Program().BoundingBox();
Assert.Equal(0, box.Width, precision: 6);
Assert.Equal(0, box.Length, precision: 6);
}
}
@@ -96,6 +96,61 @@ public class CanonicalAngleTests
var d = new Drawing("empty", new OpenNest.CNC.Program());
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
private static Drawing MakeL(double rotation)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
if (!OpenNest.Math.Tolerance.IsEqualTo(rotation, 0))
pgm.Rotate(rotation, pgm.BoundingBox().Center);
return new Drawing("L", pgm);
}
// Canonical outline, translated to its own corner, as sorted rounded vertices.
private static string Signature(Drawing drawing)
{
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var entities = ConvertProgram
.ToGeometry(canonical.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var vertices = ShapeBuilder
.GetShapes(entities)
.OrderByDescending(s => s.Area())
.First()
.ToPolygonWithTolerance(0.1)
.Vertices.ToList();
var minX = vertices.Min(v => v.X);
var minY = vertices.Min(v => v.Y);
return string.Join(
";",
vertices
.Select(v =>
$"{System.Math.Round(v.X - minX, 2):F2},{System.Math.Round(v.Y - minY, 2):F2}"
)
.Distinct()
.OrderBy(x => x)
);
}
[Theory]
[InlineData(0.3)]
[InlineData(0.8)]
[InlineData(1.2)]
public void AsymmetricShape_CanonicalOrientationIsIndependentOfQuarterTurns(double offset)
{
// The MBR fixes the frame only modulo 90 degrees; an L-shape must still land in one
// deterministic orientation however it was imported.
var baseline = Signature(MakeL(offset));
for (var turns = 1; turns < 4; turns++)
Assert.Equal(baseline, Signature(MakeL(offset + turns * System.Math.PI / 2)));
}
}
public class DrawingCanonicalAngleWiringTests
@@ -81,4 +81,35 @@ public class CanonicalFrameTests
Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2);
Assert.Equal(originalBbox.Length, placed[0].BoundingBox.Length, precision: 2);
}
[Fact]
public void RebindToOriginal_PreservesWorldFootprint_ForRotatedImport()
{
// An L-shape imported at an angle: the canonical copy is rotated, so a canonical part and
// the rebound part must cover exactly the same footprint even though their programs differ.
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
pgm.Rotate(0.8, pgm.BoundingBox().Center);
var original = new Drawing("L", pgm);
var canonical = CanonicalFrame.AsCanonicalCopy(original);
var placed = Part.CreateAtOrigin(canonical, 0.35);
placed.Offset(new Vector(40, 25));
placed.UpdateBounds();
var expected = placed.BoundingBox;
var rebound = CanonicalFrame.RebindToOriginal(new List<Part> { placed }, original);
Assert.Same(original, rebound[0].BaseDrawing);
Assert.Equal(expected.Left, rebound[0].BoundingBox.Left, precision: 6);
Assert.Equal(expected.Bottom, rebound[0].BoundingBox.Bottom, precision: 6);
Assert.Equal(expected.Right, rebound[0].BoundingBox.Right, precision: 6);
Assert.Equal(expected.Top, rebound[0].BoundingBox.Top, precision: 6);
}
}
@@ -103,4 +103,30 @@ public class RemnantFillerTests2
// Should not throw, returns whatever was placed
Assert.NotNull(result);
}
[Fact]
public void FillItems_SingleDrawing_KeepsEveryPlacedPart()
{
// With no other drawing waiting for space there is nothing to keep clear, so a full
// grid fill must not lose its topmost part.
var workArea = new Box(0, 0, 100, 100);
var filler = new RemnantFiller(workArea, 0);
var items = new List<NestItem>
{
new NestItem { Drawing = MakeSquareDrawing(10), Quantity = 4 },
};
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
new List<Part>
{
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(10, 0, 10),
TestHelpers.MakePartAt(0, 10, 10),
TestHelpers.MakePartAt(10, 10, 10),
};
var placed = filler.FillItems(items, fillFunc);
Assert.Equal(4, placed.Count);
}
}