fix(engine): make canonical-frame fills orientation-invariant
Part.Rotation is cumulative, so rebinding canonical parts with CreateAtOrigin(original, p.Rotation) double-counted the drawing's own rotation, and FromCanonical rotated each part about its Location, which moved it off its slot and out of the work area. Add CanonicalFrame.RebindToOriginal (rotation = part - original program rotation, footprint aligned to the canonical part) and use it in the three places that duplicated the old logic. The MBR only fixes the frame modulo 90 degrees and nest results are not 90-degree symmetric (an L gave 56/43/42/42 parts by orientation). CanonicalAngle.Compute now picks one of the four orientations from the centroid offset; symmetric shapes keep the MBR orientation. Fixes the three NestInvarianceTests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user