Merge branch 'fix/failing-tests-after-master-pull'
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user