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);
|
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()
|
public Box BoundingBox()
|
||||||
{
|
{
|
||||||
var origin = new Vector(0, 0);
|
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
|
// Capture the frame origin at entry. Sub-program Offsets and
|
||||||
// absolute-mode endpoints are relative to this fixed origin.
|
// absolute-mode endpoints are relative to this fixed origin.
|
||||||
var frameOrigin = pos;
|
var frameOrigin = pos;
|
||||||
|
|
||||||
double minX = 0.0;
|
var minX = double.PositiveInfinity;
|
||||||
double minY = 0.0;
|
var minY = double.PositiveInfinity;
|
||||||
double maxX = 0.0;
|
var maxX = double.NegativeInfinity;
|
||||||
double maxY = 0.0;
|
var maxY = double.NegativeInfinity;
|
||||||
|
|
||||||
for (int i = 0; i < Codes.Count; ++i)
|
for (int i = 0; i < Codes.Count; ++i)
|
||||||
{
|
{
|
||||||
@@ -338,12 +343,12 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
maxX = pt.X;
|
maxX = pt.X;
|
||||||
else if (pt.X < minX)
|
if (pt.X < minX)
|
||||||
minX = pt.X;
|
minX = pt.X;
|
||||||
|
|
||||||
if (pt.Y > maxY)
|
if (pt.Y > maxY)
|
||||||
maxY = pt.Y;
|
maxY = pt.Y;
|
||||||
else if (pt.Y < minY)
|
if (pt.Y < minY)
|
||||||
minY = pt.Y;
|
minY = pt.Y;
|
||||||
|
|
||||||
pos = pt;
|
pos = pt;
|
||||||
@@ -361,12 +366,12 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
maxX = pt.X;
|
maxX = pt.X;
|
||||||
else if (pt.X < minX)
|
if (pt.X < minX)
|
||||||
minX = pt.X;
|
minX = pt.X;
|
||||||
|
|
||||||
if (pt.Y > maxY)
|
if (pt.Y > maxY)
|
||||||
maxY = pt.Y;
|
maxY = pt.Y;
|
||||||
else if (pt.Y < minY)
|
if (pt.Y < minY)
|
||||||
minY = pt.Y;
|
minY = pt.Y;
|
||||||
|
|
||||||
pos = pt;
|
pos = pt;
|
||||||
@@ -470,7 +475,8 @@ namespace OpenNest.CNC
|
|||||||
// Sub-program frame origin in this program's frame
|
// Sub-program frame origin in this program's frame
|
||||||
// is frameOrigin + Offset, regardless of current pos.
|
// is frameOrigin + Offset, regardless of current pos.
|
||||||
pos = frameOrigin + subpgm.Offset;
|
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)
|
if (box.Left < minX)
|
||||||
minX = box.Left;
|
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()
|
public object Clone()
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ namespace OpenNest
|
|||||||
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
||||||
public const double SnapToZero = 0.001;
|
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>
|
/// <summary>
|
||||||
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
|
/// 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
|
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
|
||||||
@@ -73,7 +79,89 @@ namespace OpenNest
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
|
|
||||||
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
|
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)
|
public List<Part> BuildSourceParts(Drawing drawing)
|
||||||
{
|
{
|
||||||
var parts = BuildCanonicalParts();
|
var parts = BuildCanonicalParts();
|
||||||
var sourceAngle = drawing?.Source?.Angle ?? 0.0;
|
|
||||||
|
|
||||||
for (var i = 0; i < parts.Count; i++)
|
return NormalizeToCutOrigin(CanonicalFrame.RebindToOriginal(parts, drawing));
|
||||||
{
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Box GetCutBounds(List<Part> parts)
|
public Box GetCutBounds(List<Part> parts)
|
||||||
|
|||||||
@@ -47,6 +47,38 @@ namespace OpenNest.Engine
|
|||||||
return copy;
|
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>
|
/// <summary>
|
||||||
/// Composes the source drawing's canonical angle onto each placed part so the
|
/// Composes the source drawing's canonical angle onto each placed part so the
|
||||||
/// returned list is in the drawing's original (visible) frame.
|
/// 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.
|
// 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.
|
// 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 originalDrawing = item.Drawing;
|
||||||
var canonicalItem = new NestItem
|
var canonicalItem = new NestItem
|
||||||
{
|
{
|
||||||
@@ -81,7 +80,7 @@ namespace OpenNest
|
|||||||
$"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}"
|
$"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}"
|
||||||
);
|
);
|
||||||
WinnerPhase = NestPhase.Pairs;
|
WinnerPhase = NestPhase.Pairs;
|
||||||
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
|
fast = RebindAndUnCanonicalize(fast, originalDrawing);
|
||||||
ReportProgress(
|
ReportProgress(
|
||||||
progress,
|
progress,
|
||||||
new ProgressReport
|
new ProgressReport
|
||||||
@@ -129,7 +128,7 @@ namespace OpenNest
|
|||||||
if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
|
if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
|
||||||
best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
|
best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
|
||||||
|
|
||||||
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
|
best = RebindAndUnCanonicalize(best, originalDrawing);
|
||||||
|
|
||||||
ReportProgress(
|
ReportProgress(
|
||||||
progress,
|
progress,
|
||||||
@@ -150,31 +149,10 @@ namespace OpenNest
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Single exit point for canonical -> source frame conversion. Rebinds every Part to the
|
/// 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)
|
/// 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>
|
/// </summary>
|
||||||
private static List<Part> RebindAndUnCanonicalize(
|
private static List<Part> RebindAndUnCanonicalize(List<Part> parts, Drawing original) =>
|
||||||
List<Part> parts,
|
CanonicalFrame.RebindToOriginal(parts, original);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fast path for qty 1-2: place a single part or a best-fit pair
|
/// 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
|
// rectangular obstacle boundary. Without this, gaps between
|
||||||
// individual bounding boxes cause the next drawing to fill
|
// individual bounding boxes cause the next drawing to fill
|
||||||
// into inter-row spaces, producing an interleaved layout.
|
// 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);
|
RemoveTopmostPart(placed);
|
||||||
|
|
||||||
allParts.AddRange(placed);
|
allParts.AddRange(placed);
|
||||||
@@ -132,6 +135,23 @@ namespace OpenNest.Engine.Fill
|
|||||||
return false;
|
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)
|
private static void RemoveTopmostPart(List<Part> parts)
|
||||||
{
|
{
|
||||||
var topIdx = 0;
|
var topIdx = 0;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
||||||
namespace OpenNest;
|
namespace OpenNest;
|
||||||
@@ -20,6 +21,7 @@ namespace OpenNest;
|
|||||||
public sealed class DefaultPlateNester : IPlateNester
|
public sealed class DefaultPlateNester : IPlateNester
|
||||||
{
|
{
|
||||||
private readonly Func<Plate, DefaultNestEngine> engineFactory;
|
private readonly Func<Plate, DefaultNestEngine> engineFactory;
|
||||||
|
private readonly OrderedPlateNester restrictedRotationNester = new();
|
||||||
private readonly Dictionary<string, Drawing> drawingsById = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, Drawing> drawingsById = new(StringComparer.Ordinal);
|
||||||
private readonly Dictionary<Drawing, string> idByDrawing = new(
|
private readonly Dictionary<Drawing, string> idByDrawing = new(
|
||||||
ReferenceEqualityComparer.Instance
|
ReferenceEqualityComparer.Instance
|
||||||
@@ -44,6 +46,13 @@ public sealed class DefaultPlateNester : IPlateNester
|
|||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
token.ThrowIfCancellationRequested();
|
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 plate = DrawingJobMapper.CreatePlate(request.Stock);
|
||||||
var items = new List<NestItem>(request.Parts.Count);
|
var items = new List<NestItem>(request.Parts.Count);
|
||||||
foreach (var requirement in request.Parts)
|
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
|
// 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.
|
// + un-rotate winning pair to the original drawing's frame before returning.
|
||||||
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
|
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
|
||||||
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
|
|
||||||
|
|
||||||
List<Part> bestPlacement = null;
|
List<Part> bestPlacement = null;
|
||||||
Box bestTarget = null;
|
Box bestTarget = null;
|
||||||
@@ -438,9 +437,9 @@ namespace OpenNest
|
|||||||
if (bestPlacement == null)
|
if (bestPlacement == null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Rebind to the original drawing and compose sourceAngle onto rotation so the
|
// Rebind to the original drawing and compose the canonical angle onto rotation so
|
||||||
// final placed parts sit in the user's visible frame.
|
// the final placed parts sit in the user's visible frame.
|
||||||
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle);
|
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing);
|
||||||
|
|
||||||
result.AddRange(bestPlacement);
|
result.AddRange(bestPlacement);
|
||||||
item.Quantity = 0;
|
item.Quantity = 0;
|
||||||
@@ -460,31 +459,12 @@ namespace OpenNest
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rebinds each canonical-frame Part in the pair to the original Drawing at its current
|
/// 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
|
/// world pose, then composes the canonical angle onto each via
|
||||||
/// the returned list is in the original drawing's visible frame. Mirrors
|
/// CanonicalFrame.RebindToOriginal so the returned list is in the original drawing's
|
||||||
/// DefaultNestEngine.RebindAndUnCanonicalize.
|
/// visible frame. Mirrors DefaultNestEngine.RebindAndUnCanonicalize.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Part> RebindPairToOriginal(
|
private static List<Part> RebindPairToOriginal(List<Part> parts, Drawing original) =>
|
||||||
List<Part> parts,
|
CanonicalFrame.RebindToOriginal(parts, original);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
|
/// 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());
|
var d = new Drawing("empty", new OpenNest.CNC.Program());
|
||||||
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
|
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
|
public class DrawingCanonicalAngleWiringTests
|
||||||
|
|||||||
@@ -81,4 +81,35 @@ public class CanonicalFrameTests
|
|||||||
Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2);
|
Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2);
|
||||||
Assert.Equal(originalBbox.Length, placed[0].BoundingBox.Length, 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
|
// Should not throw, returns whatever was placed
|
||||||
Assert.NotNull(result);
|
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