Compare commits
25 Commits
6229e5e49d
...
442501828a
| Author | SHA1 | Date | |
|---|---|---|---|
| 442501828a | |||
| 202f49f368 | |||
| 7bbfe06494 | |||
| 267254dcae | |||
| 5668748f37 | |||
| b7de61e4d1 | |||
| c4d5cfd17b | |||
| 1f965897f2 | |||
| 46fe48870c | |||
| c287e3ec32 | |||
| 4348e5c427 | |||
| e6a7d9b047 | |||
| ddf1686ea5 | |||
| 501fbda762 | |||
| a83efd0b01 | |||
| a1139efecb | |||
| d8373ab135 | |||
| f0b9b51229 | |||
| 76a338f3d0 | |||
| 0ac7b9babd | |||
| f336af5d65 | |||
| 3d6be3900e | |||
| 285e7082fb | |||
| 207cef5423 | |||
| c3b3f24704 |
@@ -71,6 +71,40 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generalized ray-edge distance along an arbitrary unit direction vector.
|
||||||
|
/// Returns double.MaxValue if the ray does not hit the segment.
|
||||||
|
/// </summary>
|
||||||
|
[System.Runtime.CompilerServices.MethodImpl(
|
||||||
|
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static double RayEdgeDistance(
|
||||||
|
double vx, double vy,
|
||||||
|
double p1x, double p1y, double p2x, double p2y,
|
||||||
|
double dirX, double dirY)
|
||||||
|
{
|
||||||
|
var ex = p2x - p1x;
|
||||||
|
var ey = p2y - p1y;
|
||||||
|
|
||||||
|
var det = ex * dirY - ey * dirX;
|
||||||
|
if (System.Math.Abs(det) < Tolerance.Epsilon)
|
||||||
|
return double.MaxValue;
|
||||||
|
|
||||||
|
var dvx = p1x - vx;
|
||||||
|
var dvy = p1y - vy;
|
||||||
|
|
||||||
|
var t = (ex * dvy - ey * dvx) / det;
|
||||||
|
if (t < -Tolerance.Epsilon)
|
||||||
|
return double.MaxValue;
|
||||||
|
|
||||||
|
var s = (dirX * dvy - dirY * dvx) / det;
|
||||||
|
if (s < -Tolerance.Epsilon || s > 1.0 + Tolerance.Epsilon)
|
||||||
|
return double.MaxValue;
|
||||||
|
|
||||||
|
if (t > Tolerance.Epsilon) return t;
|
||||||
|
if (t >= -Tolerance.Epsilon) return 0;
|
||||||
|
return double.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes the minimum translation distance along a push direction before
|
/// Computes the minimum translation distance along a push direction before
|
||||||
/// any edge of movingLines contacts any edge of stationaryLines.
|
/// any edge of movingLines contacts any edge of stationaryLines.
|
||||||
@@ -361,6 +395,135 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region Generalized direction (Vector) overloads
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes how far a box can travel along the given unit direction
|
||||||
|
/// before exiting the boundary box.
|
||||||
|
/// </summary>
|
||||||
|
public static double EdgeDistance(Box box, Box boundary, Vector direction)
|
||||||
|
{
|
||||||
|
var dist = double.MaxValue;
|
||||||
|
|
||||||
|
if (direction.X < -Tolerance.Epsilon)
|
||||||
|
{
|
||||||
|
var d = (box.Left - boundary.Left) / -direction.X;
|
||||||
|
if (d < dist) dist = d;
|
||||||
|
}
|
||||||
|
else if (direction.X > Tolerance.Epsilon)
|
||||||
|
{
|
||||||
|
var d = (boundary.Right - box.Right) / direction.X;
|
||||||
|
if (d < dist) dist = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction.Y < -Tolerance.Epsilon)
|
||||||
|
{
|
||||||
|
var d = (box.Bottom - boundary.Bottom) / -direction.Y;
|
||||||
|
if (d < dist) dist = d;
|
||||||
|
}
|
||||||
|
else if (direction.Y > Tolerance.Epsilon)
|
||||||
|
{
|
||||||
|
var d = (boundary.Top - box.Top) / direction.Y;
|
||||||
|
if (d < dist) dist = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dist < 0 ? 0 : dist;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the directional gap between two boxes along an arbitrary unit direction.
|
||||||
|
/// Positive means 'to' is ahead of 'from' in the push direction.
|
||||||
|
/// </summary>
|
||||||
|
public static double DirectionalGap(Box from, Box to, Vector direction)
|
||||||
|
{
|
||||||
|
var fromMax = BoxProjectionMax(from, direction.X, direction.Y);
|
||||||
|
var toMin = BoxProjectionMin(to, direction.X, direction.Y);
|
||||||
|
return toMin - fromMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if two boxes overlap when projected onto the axis
|
||||||
|
/// perpendicular to the given unit direction.
|
||||||
|
/// </summary>
|
||||||
|
public static bool PerpendicularOverlap(Box a, Box b, Vector direction)
|
||||||
|
{
|
||||||
|
var px = -direction.Y;
|
||||||
|
var py = direction.X;
|
||||||
|
|
||||||
|
var aMin = BoxProjectionMin(a, px, py);
|
||||||
|
var aMax = BoxProjectionMax(a, px, py);
|
||||||
|
var bMin = BoxProjectionMin(b, px, py);
|
||||||
|
var bMax = BoxProjectionMax(b, px, py);
|
||||||
|
|
||||||
|
return aMin <= bMax + Tolerance.Epsilon && bMin <= aMax + Tolerance.Epsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||||
|
/// before any edge of movingLines contacts any edge of stationaryLines.
|
||||||
|
/// </summary>
|
||||||
|
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, Vector direction)
|
||||||
|
{
|
||||||
|
var minDist = double.MaxValue;
|
||||||
|
var dirX = direction.X;
|
||||||
|
var dirY = direction.Y;
|
||||||
|
|
||||||
|
var movingVertices = new HashSet<Vector>();
|
||||||
|
for (var i = 0; i < movingLines.Count; i++)
|
||||||
|
{
|
||||||
|
movingVertices.Add(movingLines[i].pt1);
|
||||||
|
movingVertices.Add(movingLines[i].pt2);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var mv in movingVertices)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < stationaryLines.Count; i++)
|
||||||
|
{
|
||||||
|
var e = stationaryLines[i];
|
||||||
|
var d = RayEdgeDistance(mv.X, mv.Y, e.pt1.X, e.pt1.Y, e.pt2.X, e.pt2.Y, dirX, dirY);
|
||||||
|
if (d < minDist) minDist = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var oppX = -dirX;
|
||||||
|
var oppY = -dirY;
|
||||||
|
|
||||||
|
var stationaryVertices = new HashSet<Vector>();
|
||||||
|
for (var i = 0; i < stationaryLines.Count; i++)
|
||||||
|
{
|
||||||
|
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||||
|
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var sv in stationaryVertices)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < movingLines.Count; i++)
|
||||||
|
{
|
||||||
|
var e = movingLines[i];
|
||||||
|
var d = RayEdgeDistance(sv.X, sv.Y, e.pt1.X, e.pt1.Y, e.pt2.X, e.pt2.Y, oppX, oppY);
|
||||||
|
if (d < minDist) minDist = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDist;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double BoxProjectionMin(Box box, double dx, double dy)
|
||||||
|
{
|
||||||
|
var x = dx >= 0 ? box.Left : box.Right;
|
||||||
|
var y = dy >= 0 ? box.Bottom : box.Top;
|
||||||
|
return x * dx + y * dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double BoxProjectionMax(Box box, double dx, double dy)
|
||||||
|
{
|
||||||
|
var x = dx >= 0 ? box.Right : box.Left;
|
||||||
|
var y = dy >= 0 ? box.Top : box.Bottom;
|
||||||
|
return x * dx + y * dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
public static double ClosestDistanceLeft(Box box, List<Box> boxes)
|
public static double ClosestDistanceLeft(Box box, List<Box> boxes)
|
||||||
{
|
{
|
||||||
var closestDistance = double.MaxValue;
|
var closestDistance = double.MaxValue;
|
||||||
|
|||||||
@@ -85,6 +85,73 @@ namespace OpenNest
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<Line> GetPartLines(Part part, Vector facingDirection, double chordTolerance = 0.001)
|
||||||
|
{
|
||||||
|
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||||
|
var lines = new List<Line>();
|
||||||
|
|
||||||
|
foreach (var shape in shapes)
|
||||||
|
{
|
||||||
|
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
|
||||||
|
polygon.Offset(part.Location);
|
||||||
|
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Line> GetOffsetPartLines(Part part, double spacing, Vector facingDirection, double chordTolerance = 0.001)
|
||||||
|
{
|
||||||
|
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||||
|
var lines = new List<Line>();
|
||||||
|
|
||||||
|
foreach (var shape in shapes)
|
||||||
|
{
|
||||||
|
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
|
||||||
|
|
||||||
|
if (offsetEntity == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||||
|
polygon.RemoveSelfIntersections();
|
||||||
|
polygon.Offset(part.Location);
|
||||||
|
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns only polygon edges whose outward normal faces the specified direction vector.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Line> GetDirectionalLines(Polygon polygon, Vector direction)
|
||||||
|
{
|
||||||
|
if (polygon.Vertices.Count < 3)
|
||||||
|
return polygon.ToLines();
|
||||||
|
|
||||||
|
var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0;
|
||||||
|
var lines = new List<Line>();
|
||||||
|
var last = polygon.Vertices[0];
|
||||||
|
|
||||||
|
for (var i = 1; i < polygon.Vertices.Count; i++)
|
||||||
|
{
|
||||||
|
var current = polygon.Vertices[i];
|
||||||
|
var edx = current.X - last.X;
|
||||||
|
var edy = current.Y - last.Y;
|
||||||
|
|
||||||
|
var keep = sign * (edy * direction.X - edx * direction.Y) > 0;
|
||||||
|
|
||||||
|
if (keep)
|
||||||
|
lines.Add(new Line(last, current));
|
||||||
|
|
||||||
|
last = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns only polygon edges whose outward normal faces the specified direction.
|
/// Returns only polygon edges whose outward normal faces the specified direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
@@ -84,6 +85,85 @@ namespace OpenNest
|
|||||||
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up).
|
||||||
|
/// </summary>
|
||||||
|
public static double Push(List<Part> movingParts, Plate plate, double angle)
|
||||||
|
{
|
||||||
|
var obstacleParts = plate.Parts
|
||||||
|
.Where(p => !movingParts.Contains(p))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes movingParts along an arbitrary angle (radians, 0 = right, π/2 = up).
|
||||||
|
/// </summary>
|
||||||
|
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
||||||
|
Box workArea, double partSpacing, double angle)
|
||||||
|
{
|
||||||
|
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
||||||
|
var opposite = -direction;
|
||||||
|
|
||||||
|
var obstacleBoxes = new Box[obstacleParts.Count];
|
||||||
|
var obstacleLines = new List<Line>[obstacleParts.Count];
|
||||||
|
|
||||||
|
for (var i = 0; i < obstacleParts.Count; i++)
|
||||||
|
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
|
||||||
|
|
||||||
|
var halfSpacing = partSpacing / 2;
|
||||||
|
var distance = double.MaxValue;
|
||||||
|
|
||||||
|
foreach (var moving in movingParts)
|
||||||
|
{
|
||||||
|
var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction);
|
||||||
|
if (edgeDist <= 0)
|
||||||
|
distance = 0;
|
||||||
|
else if (edgeDist < distance)
|
||||||
|
distance = edgeDist;
|
||||||
|
|
||||||
|
var movingBox = moving.BoundingBox;
|
||||||
|
List<Line> movingLines = null;
|
||||||
|
|
||||||
|
for (var i = 0; i < obstacleBoxes.Length; i++)
|
||||||
|
{
|
||||||
|
var reverseGap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], opposite);
|
||||||
|
if (reverseGap > 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
|
||||||
|
if (gap >= distance)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
movingLines ??= halfSpacing > 0
|
||||||
|
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
|
||||||
|
: PartGeometry.GetPartLines(moving, direction, ChordTolerance);
|
||||||
|
|
||||||
|
obstacleLines[i] ??= halfSpacing > 0
|
||||||
|
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
|
||||||
|
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
|
||||||
|
|
||||||
|
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
|
||||||
|
if (d < distance)
|
||||||
|
distance = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distance < double.MaxValue && distance > 0)
|
||||||
|
{
|
||||||
|
var offset = direction * distance;
|
||||||
|
foreach (var moving in movingParts)
|
||||||
|
moving.Offset(offset);
|
||||||
|
return distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
||||||
Box workArea, double partSpacing, PushDirection direction)
|
Box workArea, double partSpacing, PushDirection direction)
|
||||||
{
|
{
|
||||||
@@ -158,6 +238,73 @@ namespace OpenNest
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes movingParts using bounding-box distances only (no geometry lines).
|
||||||
|
/// Much faster but less precise — use as a coarse positioning pass before
|
||||||
|
/// a full geometry Push.
|
||||||
|
/// </summary>
|
||||||
|
public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||||
|
{
|
||||||
|
var obstacleParts = plate.Parts
|
||||||
|
.Where(p => !movingParts.Contains(p))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double PushBoundingBox(List<Part> movingParts, List<Part> obstacleParts,
|
||||||
|
Box workArea, double partSpacing, PushDirection direction)
|
||||||
|
{
|
||||||
|
var obstacleBoxes = new Box[obstacleParts.Count];
|
||||||
|
for (var i = 0; i < obstacleParts.Count; i++)
|
||||||
|
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
|
||||||
|
|
||||||
|
var opposite = SpatialQuery.OppositeDirection(direction);
|
||||||
|
var isHorizontal = SpatialQuery.IsHorizontalDirection(direction);
|
||||||
|
var distance = double.MaxValue;
|
||||||
|
|
||||||
|
foreach (var moving in movingParts)
|
||||||
|
{
|
||||||
|
var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction);
|
||||||
|
if (edgeDist <= 0)
|
||||||
|
distance = 0;
|
||||||
|
else if (edgeDist < distance)
|
||||||
|
distance = edgeDist;
|
||||||
|
|
||||||
|
var movingBox = moving.BoundingBox;
|
||||||
|
|
||||||
|
for (var i = 0; i < obstacleBoxes.Length; i++)
|
||||||
|
{
|
||||||
|
var reverseGap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], opposite);
|
||||||
|
if (reverseGap > 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var perpOverlap = isHorizontal
|
||||||
|
? movingBox.IsHorizontalTo(obstacleBoxes[i], out _)
|
||||||
|
: movingBox.IsVerticalTo(obstacleBoxes[i], out _);
|
||||||
|
|
||||||
|
if (!perpOverlap)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
|
||||||
|
var d = gap - partSpacing;
|
||||||
|
if (d < 0) d = 0;
|
||||||
|
if (d < distance)
|
||||||
|
distance = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distance < double.MaxValue && distance > 0)
|
||||||
|
{
|
||||||
|
var offset = SpatialQuery.DirectionToOffset(direction, distance);
|
||||||
|
foreach (var moving in movingParts)
|
||||||
|
moving.Offset(offset);
|
||||||
|
return distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compacts parts individually toward the bottom-left of the work area.
|
/// Compacts parts individually toward the bottom-left of the work area.
|
||||||
/// Each part is pushed against all others as obstacles, closing geometry-based gaps.
|
/// Each part is pushed against all others as obstacles, closing geometry-based gaps.
|
||||||
|
|||||||
@@ -34,71 +34,33 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
PhaseResults.Clear();
|
PhaseResults.Clear();
|
||||||
AngleResults.Clear();
|
AngleResults.Clear();
|
||||||
var best = FindBestFill(item, workArea, progress, token);
|
|
||||||
|
|
||||||
if (best == null || best.Count == 0)
|
var context = new FillContext
|
||||||
return new List<Part>();
|
{
|
||||||
|
Item = item,
|
||||||
|
WorkArea = workArea,
|
||||||
|
Plate = Plate,
|
||||||
|
PlateNumber = PlateNumber,
|
||||||
|
Token = token,
|
||||||
|
Progress = progress,
|
||||||
|
};
|
||||||
|
|
||||||
|
RunPipeline(context);
|
||||||
|
|
||||||
|
// PhaseResults already synced during RunPipeline.
|
||||||
|
AngleResults.AddRange(context.AngleResults);
|
||||||
|
WinnerPhase = context.WinnerPhase;
|
||||||
|
|
||||||
|
var best = context.CurrentBest ?? new List<Part>();
|
||||||
|
|
||||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||||
best = best.Take(item.Quantity).ToList();
|
best = best.Take(item.Quantity).ToList();
|
||||||
|
|
||||||
|
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary());
|
||||||
|
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fast fill count using linear fill with two angles plus the top cached
|
|
||||||
/// pair candidates. Used by binary search to estimate capacity at a given
|
|
||||||
/// box size without running the full Fill pipeline.
|
|
||||||
/// </summary>
|
|
||||||
private int QuickFillCount(Drawing drawing, Box testBox, double bestRotation)
|
|
||||||
{
|
|
||||||
var engine = new FillLinear(testBox, Plate.PartSpacing);
|
|
||||||
var bestCount = 0;
|
|
||||||
|
|
||||||
// Single-part linear fills.
|
|
||||||
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
|
||||||
|
|
||||||
foreach (var angle in angles)
|
|
||||||
{
|
|
||||||
var h = engine.Fill(drawing, angle, NestDirection.Horizontal);
|
|
||||||
if (h != null && h.Count > bestCount)
|
|
||||||
bestCount = h.Count;
|
|
||||||
|
|
||||||
var v = engine.Fill(drawing, angle, NestDirection.Vertical);
|
|
||||||
if (v != null && v.Count > bestCount)
|
|
||||||
bestCount = v.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Top pair candidates — check if pairs tile better in this box.
|
|
||||||
var bestFits = BestFitCache.GetOrCompute(
|
|
||||||
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
|
||||||
var topPairs = bestFits.Where(r => r.Keep).Take(3);
|
|
||||||
|
|
||||||
foreach (var pair in topPairs)
|
|
||||||
{
|
|
||||||
var pairParts = pair.BuildParts(drawing);
|
|
||||||
var pairAngles = pair.HullAngles ?? new List<double> { 0 };
|
|
||||||
var pairEngine = new FillLinear(testBox, Plate.PartSpacing);
|
|
||||||
|
|
||||||
foreach (var angle in pairAngles)
|
|
||||||
{
|
|
||||||
var pattern = BuildRotatedPattern(pairParts, angle);
|
|
||||||
if (pattern.Parts.Count == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var h = pairEngine.Fill(pattern, NestDirection.Horizontal);
|
|
||||||
if (h != null && h.Count > bestCount)
|
|
||||||
bestCount = h.Count;
|
|
||||||
|
|
||||||
var v = pairEngine.Fill(pattern, NestDirection.Vertical);
|
|
||||||
if (v != null && v.Count > bestCount)
|
|
||||||
bestCount = v.Count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||||
IProgress<NestProgress> progress, CancellationToken token)
|
IProgress<NestProgress> progress, CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -122,7 +84,11 @@ namespace OpenNest
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing };
|
var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing };
|
||||||
var rectResult = FillRectangleBestFit(nestItem, workArea);
|
var binItem = BinConverter.ToItem(nestItem, Plate.PartSpacing);
|
||||||
|
var bin = BinConverter.CreateBin(workArea, Plate.PartSpacing);
|
||||||
|
var rectEngine = new FillBestFit(bin);
|
||||||
|
rectEngine.Fill(binItem);
|
||||||
|
var rectResult = BinConverter.ToParts(bin, new List<NestItem> { nestItem });
|
||||||
PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, 0));
|
PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, 0));
|
||||||
|
|
||||||
Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts");
|
Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts");
|
||||||
@@ -150,13 +116,15 @@ namespace OpenNest
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var extentsFiller = new FillExtents(workArea, Plate.PartSpacing);
|
var extentsFiller = new FillExtents(workArea, Plate.PartSpacing);
|
||||||
|
var bestFits2 = BestFitCache.GetOrCompute(
|
||||||
|
groupParts[0].BaseDrawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
||||||
var extentsAngles2 = new[] { groupParts[0].Rotation, groupParts[0].Rotation + Angle.HalfPI };
|
var extentsAngles2 = new[] { groupParts[0].Rotation, groupParts[0].Rotation + Angle.HalfPI };
|
||||||
List<Part> bestExtents2 = null;
|
List<Part> bestExtents2 = null;
|
||||||
|
|
||||||
foreach (var angle in extentsAngles2)
|
foreach (var angle in extentsAngles2)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var result = extentsFiller.Fill(groupParts[0].BaseDrawing, angle, PlateNumber, token, progress);
|
var result = extentsFiller.Fill(groupParts[0].BaseDrawing, angle, PlateNumber, token, progress, bestFits2);
|
||||||
if (result != null && result.Count > (bestExtents2?.Count ?? 0))
|
if (result != null && result.Count > (bestExtents2?.Count ?? 0))
|
||||||
bestExtents2 = result;
|
bestExtents2 = result;
|
||||||
}
|
}
|
||||||
@@ -200,210 +168,59 @@ namespace OpenNest
|
|||||||
return BinConverter.ToParts(bin, items);
|
return BinConverter.ToParts(bin, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- FindBestFill: core orchestration ---
|
// --- RunPipeline: strategy-based orchestration ---
|
||||||
|
|
||||||
private List<Part> FindBestFill(NestItem item, Box workArea,
|
private void RunPipeline(FillContext context)
|
||||||
IProgress<NestProgress> progress = null, CancellationToken token = default)
|
|
||||||
{
|
{
|
||||||
List<Part> best = null;
|
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||||
|
context.SharedState["BestRotation"] = bestRotation;
|
||||||
|
|
||||||
|
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
|
||||||
|
context.SharedState["AngleCandidates"] = angles;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var bestRotation = RotationAnalysis.FindBestRotation(item);
|
foreach (var strategy in FillStrategyRegistry.Strategies)
|
||||||
var angles = angleBuilder.Build(item, bestRotation, workArea);
|
|
||||||
|
|
||||||
// Pairs phase
|
|
||||||
var pairSw = Stopwatch.StartNew();
|
|
||||||
var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing);
|
|
||||||
var pairResult = pairFiller.Fill(item, workArea, PlateNumber, token, progress);
|
|
||||||
pairSw.Stop();
|
|
||||||
best = pairResult;
|
|
||||||
var bestScore = FillScore.Compute(best, workArea);
|
|
||||||
WinnerPhase = NestPhase.Pairs;
|
|
||||||
PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, pairSw.ElapsedMilliseconds));
|
|
||||||
|
|
||||||
Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts");
|
|
||||||
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary());
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
// Linear phase
|
|
||||||
var linearSw = Stopwatch.StartNew();
|
|
||||||
var bestLinearCount = 0;
|
|
||||||
|
|
||||||
for (var ai = 0; ai < angles.Count; ai++)
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
context.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var angle = angles[ai];
|
var sw = Stopwatch.StartNew();
|
||||||
var localEngine = new FillLinear(workArea, Plate.PartSpacing);
|
var result = strategy.Fill(context);
|
||||||
var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal);
|
sw.Stop();
|
||||||
var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical);
|
|
||||||
|
|
||||||
var angleDeg = Angle.ToDegrees(angle);
|
var phaseResult = new PhaseResult(
|
||||||
if (h != null && h.Count > 0)
|
strategy.Phase, result?.Count ?? 0, sw.ElapsedMilliseconds);
|
||||||
|
context.PhaseResults.Add(phaseResult);
|
||||||
|
|
||||||
|
// Keep engine's PhaseResults in sync so BuildProgressSummary() works
|
||||||
|
// during progress reporting.
|
||||||
|
PhaseResults.Add(phaseResult);
|
||||||
|
|
||||||
|
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
|
||||||
{
|
{
|
||||||
var scoreH = FillScore.Compute(h, workArea);
|
context.CurrentBest = result;
|
||||||
AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count });
|
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||||
if (h.Count > bestLinearCount) bestLinearCount = h.Count;
|
context.WinnerPhase = strategy.Phase;
|
||||||
if (scoreH > bestScore)
|
ReportProgress(context.Progress, strategy.Phase, PlateNumber,
|
||||||
{
|
result, context.WorkArea, BuildProgressSummary());
|
||||||
best = h;
|
|
||||||
bestScore = scoreH;
|
|
||||||
WinnerPhase = NestPhase.Linear;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (v != null && v.Count > 0)
|
|
||||||
{
|
|
||||||
var scoreV = FillScore.Compute(v, workArea);
|
|
||||||
AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count });
|
|
||||||
if (v.Count > bestLinearCount) bestLinearCount = v.Count;
|
|
||||||
if (scoreV > bestScore)
|
|
||||||
{
|
|
||||||
best = v;
|
|
||||||
bestScore = scoreV;
|
|
||||||
WinnerPhase = NestPhase.Linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea,
|
|
||||||
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
|
|
||||||
}
|
|
||||||
|
|
||||||
linearSw.Stop();
|
|
||||||
PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds));
|
|
||||||
|
|
||||||
angleBuilder.RecordProductive(AngleResults);
|
|
||||||
|
|
||||||
Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}");
|
|
||||||
|
|
||||||
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary());
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
// RectBestFit phase
|
|
||||||
var rectSw = Stopwatch.StartNew();
|
|
||||||
var rectResult = FillRectangleBestFit(item, workArea);
|
|
||||||
rectSw.Stop();
|
|
||||||
var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default;
|
|
||||||
Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts");
|
|
||||||
PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, rectSw.ElapsedMilliseconds));
|
|
||||||
|
|
||||||
if (rectScore > bestScore)
|
|
||||||
{
|
|
||||||
best = rectResult;
|
|
||||||
WinnerPhase = NestPhase.RectBestFit;
|
|
||||||
ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea, BuildProgressSummary());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extents phase
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
var extentsSw = Stopwatch.StartNew();
|
|
||||||
var extentsFiller = new FillExtents(workArea, Plate.PartSpacing);
|
|
||||||
List<Part> bestExtents = null;
|
|
||||||
var extentsAngles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
|
||||||
|
|
||||||
foreach (var angle in extentsAngles)
|
|
||||||
{
|
|
||||||
token.ThrowIfCancellationRequested();
|
|
||||||
var extentsResult = extentsFiller.Fill(item.Drawing, angle, PlateNumber, token, progress);
|
|
||||||
if (bestExtents == null || (extentsResult != null && extentsResult.Count > (bestExtents?.Count ?? 0)))
|
|
||||||
bestExtents = extentsResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
extentsSw.Stop();
|
|
||||||
var extentsScore = bestExtents != null ? FillScore.Compute(bestExtents, workArea) : default;
|
|
||||||
Debug.WriteLine($"[FindBestFill] Extents: {extentsScore.Count} parts");
|
|
||||||
PhaseResults.Add(new PhaseResult(NestPhase.Extents, bestExtents?.Count ?? 0, extentsSw.ElapsedMilliseconds));
|
|
||||||
|
|
||||||
var bestScore2 = FillScore.Compute(best, workArea);
|
|
||||||
if (extentsScore > bestScore2)
|
|
||||||
{
|
|
||||||
best = bestExtents;
|
|
||||||
WinnerPhase = NestPhase.Extents;
|
|
||||||
ReportProgress(progress, NestPhase.Extents, PlateNumber, best, workArea, BuildProgressSummary());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
Debug.WriteLine("[FindBestFill] Cancelled, returning current best");
|
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always report the final winner so the UI's temporary parts
|
angleBuilder.RecordProductive(context.AngleResults);
|
||||||
// match the returned result (sub-phases may have reported their
|
|
||||||
// own intermediate results via progress).
|
|
||||||
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary());
|
|
||||||
|
|
||||||
return best ?? new List<Part>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Fill strategies ---
|
|
||||||
|
|
||||||
private List<Part> FillRectangleBestFit(NestItem item, Box workArea)
|
|
||||||
{
|
|
||||||
var binItem = BinConverter.ToItem(item, Plate.PartSpacing);
|
|
||||||
var bin = BinConverter.CreateBin(workArea, Plate.PartSpacing);
|
|
||||||
|
|
||||||
var engine = new FillBestFit(bin);
|
|
||||||
engine.Fill(binItem);
|
|
||||||
|
|
||||||
return BinConverter.ToParts(bin, new List<NestItem> { item });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pattern helpers ---
|
// --- Pattern helpers ---
|
||||||
|
|
||||||
internal static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
internal static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
||||||
{
|
=> FillHelpers.BuildRotatedPattern(groupParts, angle);
|
||||||
var pattern = new Pattern();
|
|
||||||
var center = ((IEnumerable<IBoundable>)groupParts).GetBoundingBox().Center;
|
|
||||||
|
|
||||||
foreach (var part in groupParts)
|
|
||||||
{
|
|
||||||
var clone = (Part)part.Clone();
|
|
||||||
clone.UpdateBounds();
|
|
||||||
|
|
||||||
if (!angle.IsEqualTo(0))
|
|
||||||
clone.Rotate(angle, center);
|
|
||||||
|
|
||||||
pattern.Parts.Add(clone);
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern.UpdateBounds();
|
|
||||||
return pattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
internal static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||||
{
|
=> FillHelpers.FillPattern(engine, groupParts, angles, workArea);
|
||||||
var results = new System.Collections.Concurrent.ConcurrentBag<(List<Part> Parts, FillScore Score)>();
|
|
||||||
|
|
||||||
Parallel.ForEach(angles, angle =>
|
|
||||||
{
|
|
||||||
var pattern = BuildRotatedPattern(groupParts, angle);
|
|
||||||
|
|
||||||
if (pattern.Parts.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
|
||||||
if (h != null && h.Count > 0)
|
|
||||||
results.Add((h, FillScore.Compute(h, workArea)));
|
|
||||||
|
|
||||||
var v = engine.Fill(pattern, NestDirection.Vertical);
|
|
||||||
if (v != null && v.Count > 0)
|
|
||||||
results.Add((v, FillScore.Compute(v, workArea)));
|
|
||||||
});
|
|
||||||
|
|
||||||
List<Part> best = null;
|
|
||||||
var bestScore = default(FillScore);
|
|
||||||
|
|
||||||
foreach (var res in results)
|
|
||||||
{
|
|
||||||
if (best == null || res.Score > bestScore)
|
|
||||||
{
|
|
||||||
best = res.Parts;
|
|
||||||
bestScore = res.Score;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ namespace OpenNest
|
|||||||
case NestPhase.Linear: return "Linear";
|
case NestPhase.Linear: return "Linear";
|
||||||
case NestPhase.RectBestFit: return "BestFit";
|
case NestPhase.RectBestFit: return "BestFit";
|
||||||
case NestPhase.Extents: return "Extents";
|
case NestPhase.Extents: return "Extents";
|
||||||
|
case NestPhase.Custom: return "Custom";
|
||||||
default: return phase.ToString();
|
default: return phase.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ namespace OpenNest
|
|||||||
RectBestFit,
|
RectBestFit,
|
||||||
Pairs,
|
Pairs,
|
||||||
Nfp,
|
Nfp,
|
||||||
Extents
|
Extents,
|
||||||
|
Custom
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PhaseResult
|
public class PhaseResult
|
||||||
|
|||||||
52
OpenNest.Engine/PatternTiler.cs
Normal file
52
OpenNest.Engine/PatternTiler.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine
|
||||||
|
{
|
||||||
|
public static class PatternTiler
|
||||||
|
{
|
||||||
|
public static List<Part> Tile(List<Part> cell, Size plateSize, double partSpacing)
|
||||||
|
{
|
||||||
|
if (cell == null || cell.Count == 0)
|
||||||
|
return new List<Part>();
|
||||||
|
|
||||||
|
var cellBox = cell.GetBoundingBox();
|
||||||
|
var halfSpacing = partSpacing / 2;
|
||||||
|
|
||||||
|
var cellWidth = cellBox.Width + partSpacing;
|
||||||
|
var cellHeight = cellBox.Length + partSpacing;
|
||||||
|
|
||||||
|
if (cellWidth <= 0 || cellHeight <= 0)
|
||||||
|
return new List<Part>();
|
||||||
|
|
||||||
|
// Size.Width = X-axis, Size.Length = Y-axis
|
||||||
|
var cols = (int)System.Math.Floor(plateSize.Width / cellWidth);
|
||||||
|
var rows = (int)System.Math.Floor(plateSize.Length / cellHeight);
|
||||||
|
|
||||||
|
if (cols <= 0 || rows <= 0)
|
||||||
|
return new List<Part>();
|
||||||
|
|
||||||
|
// Shift cell so parts start at halfSpacing inset, ensuring symmetric
|
||||||
|
// spacing between adjacent tiled cells on all sides.
|
||||||
|
var cellOrigin = cellBox.Location;
|
||||||
|
var baseOffset = new Vector(halfSpacing - cellOrigin.X, halfSpacing - cellOrigin.Y);
|
||||||
|
|
||||||
|
var result = new List<Part>(cols * rows * cell.Count);
|
||||||
|
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
for (var col = 0; col < cols; col++)
|
||||||
|
{
|
||||||
|
var tileOffset = baseOffset + new Vector(col * cellWidth, row * cellHeight);
|
||||||
|
|
||||||
|
foreach (var part in cell)
|
||||||
|
{
|
||||||
|
result.Add(part.CloneAtOffset(tileOffset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
OpenNest.Engine/Strategies/ExtentsFillStrategy.cs
Normal file
49
OpenNest.Engine/Strategies/ExtentsFillStrategy.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class ExtentsFillStrategy : IFillStrategy
|
||||||
|
{
|
||||||
|
public string Name => "Extents";
|
||||||
|
public NestPhase Phase => NestPhase.Extents;
|
||||||
|
public int Order => 300;
|
||||||
|
|
||||||
|
public List<Part> Fill(FillContext context)
|
||||||
|
{
|
||||||
|
var filler = new FillExtents(context.WorkArea, context.Plate.PartSpacing);
|
||||||
|
|
||||||
|
var bestRotation = context.SharedState.TryGetValue("BestRotation", out var rot)
|
||||||
|
? (double)rot
|
||||||
|
: RotationAnalysis.FindBestRotation(context.Item);
|
||||||
|
|
||||||
|
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
||||||
|
|
||||||
|
var bestFits = context.SharedState.TryGetValue("BestFits", out var cached)
|
||||||
|
? (List<BestFitResult>)cached
|
||||||
|
: null;
|
||||||
|
|
||||||
|
List<Part> best = null;
|
||||||
|
var bestScore = default(FillScore);
|
||||||
|
|
||||||
|
foreach (var angle in angles)
|
||||||
|
{
|
||||||
|
context.Token.ThrowIfCancellationRequested();
|
||||||
|
var result = filler.Fill(context.Item.Drawing, angle,
|
||||||
|
context.PlateNumber, context.Token, context.Progress, bestFits);
|
||||||
|
if (result != null && result.Count > 0)
|
||||||
|
{
|
||||||
|
var score = FillScore.Compute(result, context.WorkArea);
|
||||||
|
if (best == null || score > bestScore)
|
||||||
|
{
|
||||||
|
best = result;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best ?? new List<Part>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
OpenNest.Engine/Strategies/FillContext.cs
Normal file
25
OpenNest.Engine/Strategies/FillContext.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class FillContext
|
||||||
|
{
|
||||||
|
public NestItem Item { get; init; }
|
||||||
|
public Box WorkArea { get; init; }
|
||||||
|
public Plate Plate { get; init; }
|
||||||
|
public int PlateNumber { get; init; }
|
||||||
|
public CancellationToken Token { get; init; }
|
||||||
|
public IProgress<NestProgress> Progress { get; init; }
|
||||||
|
|
||||||
|
public List<Part> CurrentBest { get; set; }
|
||||||
|
public FillScore CurrentBestScore { get; set; }
|
||||||
|
public NestPhase WinnerPhase { get; set; }
|
||||||
|
public List<PhaseResult> PhaseResults { get; } = new();
|
||||||
|
public List<AngleResult> AngleResults { get; } = new();
|
||||||
|
|
||||||
|
public Dictionary<string, object> SharedState { get; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
66
OpenNest.Engine/Strategies/FillHelpers.cs
Normal file
66
OpenNest.Engine/Strategies/FillHelpers.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public static class FillHelpers
|
||||||
|
{
|
||||||
|
public static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
||||||
|
{
|
||||||
|
var pattern = new Pattern();
|
||||||
|
var center = ((IEnumerable<IBoundable>)groupParts).GetBoundingBox().Center;
|
||||||
|
|
||||||
|
foreach (var part in groupParts)
|
||||||
|
{
|
||||||
|
var clone = (Part)part.Clone();
|
||||||
|
clone.UpdateBounds();
|
||||||
|
|
||||||
|
if (!angle.IsEqualTo(0))
|
||||||
|
clone.Rotate(angle, center);
|
||||||
|
|
||||||
|
pattern.Parts.Add(clone);
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern.UpdateBounds();
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||||
|
{
|
||||||
|
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
|
||||||
|
|
||||||
|
Parallel.ForEach(angles, angle =>
|
||||||
|
{
|
||||||
|
var pattern = BuildRotatedPattern(groupParts, angle);
|
||||||
|
|
||||||
|
if (pattern.Parts.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||||
|
if (h != null && h.Count > 0)
|
||||||
|
results.Add((h, FillScore.Compute(h, workArea)));
|
||||||
|
|
||||||
|
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||||
|
if (v != null && v.Count > 0)
|
||||||
|
results.Add((v, FillScore.Compute(v, workArea)));
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Part> best = null;
|
||||||
|
var bestScore = default(FillScore);
|
||||||
|
|
||||||
|
foreach (var res in results)
|
||||||
|
{
|
||||||
|
if (best == null || res.Score > bestScore)
|
||||||
|
{
|
||||||
|
best = res.Parts;
|
||||||
|
bestScore = res.Score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
OpenNest.Engine/Strategies/FillStrategyRegistry.cs
Normal file
79
OpenNest.Engine/Strategies/FillStrategyRegistry.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public static class FillStrategyRegistry
|
||||||
|
{
|
||||||
|
private static readonly List<IFillStrategy> strategies = new();
|
||||||
|
private static List<IFillStrategy> sorted;
|
||||||
|
|
||||||
|
static FillStrategyRegistry()
|
||||||
|
{
|
||||||
|
LoadFrom(typeof(FillStrategyRegistry).Assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||||
|
sorted ??= strategies.OrderBy(s => s.Order).ToList();
|
||||||
|
|
||||||
|
public static void LoadFrom(Assembly assembly)
|
||||||
|
{
|
||||||
|
foreach (var type in assembly.GetTypes())
|
||||||
|
{
|
||||||
|
if (type.IsAbstract || type.IsInterface || !typeof(IFillStrategy).IsAssignableFrom(type))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var ctor = type.GetConstructor(Type.EmptyTypes);
|
||||||
|
if (ctor == null)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Skipping {type.Name}: no parameterless constructor");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var instance = (IFillStrategy)ctor.Invoke(null);
|
||||||
|
|
||||||
|
if (strategies.Any(s => s.Name.Equals(instance.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Duplicate strategy '{instance.Name}' skipped");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
strategies.Add(instance);
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Registered: {instance.Name} (Order={instance.Order})");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Failed to instantiate {type.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void LoadPlugins(string directory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var assembly = Assembly.LoadFrom(dll);
|
||||||
|
LoadFrom(assembly);
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Loaded plugin assembly: {Path.GetFileName(dll)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Failed to load {Path.GetFileName(dll)}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
OpenNest.Engine/Strategies/IFillStrategy.cs
Normal file
12
OpenNest.Engine/Strategies/IFillStrategy.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public interface IFillStrategy
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
NestPhase Phase { get; }
|
||||||
|
int Order { get; }
|
||||||
|
List<Part> Fill(FillContext context);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
OpenNest.Engine/Strategies/LinearFillStrategy.cs
Normal file
75
OpenNest.Engine/Strategies/LinearFillStrategy.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class LinearFillStrategy : IFillStrategy
|
||||||
|
{
|
||||||
|
public string Name => "Linear";
|
||||||
|
public NestPhase Phase => NestPhase.Linear;
|
||||||
|
public int Order => 400;
|
||||||
|
|
||||||
|
public List<Part> Fill(FillContext context)
|
||||||
|
{
|
||||||
|
var angles = context.SharedState.TryGetValue("AngleCandidates", out var cached)
|
||||||
|
? (List<double>)cached
|
||||||
|
: new List<double> { 0, Angle.HalfPI };
|
||||||
|
|
||||||
|
var workArea = context.WorkArea;
|
||||||
|
List<Part> best = null;
|
||||||
|
var bestScore = default(FillScore);
|
||||||
|
|
||||||
|
for (var ai = 0; ai < angles.Count; ai++)
|
||||||
|
{
|
||||||
|
context.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var angle = angles[ai];
|
||||||
|
var engine = new FillLinear(workArea, context.Plate.PartSpacing);
|
||||||
|
var h = engine.Fill(context.Item.Drawing, angle, NestDirection.Horizontal);
|
||||||
|
var v = engine.Fill(context.Item.Drawing, angle, NestDirection.Vertical);
|
||||||
|
|
||||||
|
var angleDeg = Angle.ToDegrees(angle);
|
||||||
|
|
||||||
|
if (h != null && h.Count > 0)
|
||||||
|
{
|
||||||
|
var scoreH = FillScore.Compute(h, workArea);
|
||||||
|
context.AngleResults.Add(new AngleResult
|
||||||
|
{
|
||||||
|
AngleDeg = angleDeg,
|
||||||
|
Direction = NestDirection.Horizontal,
|
||||||
|
PartCount = h.Count
|
||||||
|
});
|
||||||
|
|
||||||
|
if (best == null || scoreH > bestScore)
|
||||||
|
{
|
||||||
|
best = h;
|
||||||
|
bestScore = scoreH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v != null && v.Count > 0)
|
||||||
|
{
|
||||||
|
var scoreV = FillScore.Compute(v, workArea);
|
||||||
|
context.AngleResults.Add(new AngleResult
|
||||||
|
{
|
||||||
|
AngleDeg = angleDeg,
|
||||||
|
Direction = NestDirection.Vertical,
|
||||||
|
PartCount = v.Count
|
||||||
|
});
|
||||||
|
|
||||||
|
if (best == null || scoreV > bestScore)
|
||||||
|
{
|
||||||
|
best = v;
|
||||||
|
bestScore = scoreV;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
|
||||||
|
context.PlateNumber, best, workArea,
|
||||||
|
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
|
||||||
|
}
|
||||||
|
|
||||||
|
return best ?? new List<Part>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
OpenNest.Engine/Strategies/PairsFillStrategy.cs
Normal file
27
OpenNest.Engine/Strategies/PairsFillStrategy.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class PairsFillStrategy : IFillStrategy
|
||||||
|
{
|
||||||
|
public string Name => "Pairs";
|
||||||
|
public NestPhase Phase => NestPhase.Pairs;
|
||||||
|
public int Order => 100;
|
||||||
|
|
||||||
|
public List<Part> Fill(FillContext context)
|
||||||
|
{
|
||||||
|
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing);
|
||||||
|
var result = filler.Fill(context.Item, context.WorkArea,
|
||||||
|
context.PlateNumber, context.Token, context.Progress);
|
||||||
|
|
||||||
|
// Cache hit — PairFiller already called GetOrCompute internally.
|
||||||
|
var bestFits = BestFitCache.GetOrCompute(
|
||||||
|
context.Item.Drawing, context.Plate.Size.Length,
|
||||||
|
context.Plate.Size.Width, context.Plate.PartSpacing);
|
||||||
|
context.SharedState["BestFits"] = bestFits;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
OpenNest.Engine/Strategies/RectBestFitStrategy.cs
Normal file
23
OpenNest.Engine/Strategies/RectBestFitStrategy.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.RectanglePacking;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class RectBestFitStrategy : IFillStrategy
|
||||||
|
{
|
||||||
|
public string Name => "RectBestFit";
|
||||||
|
public NestPhase Phase => NestPhase.RectBestFit;
|
||||||
|
public int Order => 200;
|
||||||
|
|
||||||
|
public List<Part> Fill(FillContext context)
|
||||||
|
{
|
||||||
|
var binItem = BinConverter.ToItem(context.Item, context.Plate.PartSpacing);
|
||||||
|
var bin = BinConverter.CreateBin(context.WorkArea, context.Plate.PartSpacing);
|
||||||
|
|
||||||
|
var engine = new FillBestFit(bin);
|
||||||
|
engine.Fill(binItem);
|
||||||
|
|
||||||
|
return BinConverter.ToParts(bin, new List<NestItem> { context.Item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
OpenNest.Tests/DxfRoundtripTests.cs
Normal file
148
OpenNest.Tests/DxfRoundtripTests.cs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
public class DxfRoundtripTests
|
||||||
|
{
|
||||||
|
private const double Tolerance = 0.01;
|
||||||
|
|
||||||
|
private static List<Entity> ExportAndReimport(List<Entity> geometry)
|
||||||
|
{
|
||||||
|
var program = ConvertGeometry.ToProgram(geometry);
|
||||||
|
var exporter = new DxfExporter();
|
||||||
|
var importer = new DxfImporter();
|
||||||
|
|
||||||
|
using var exportStream = new MemoryStream();
|
||||||
|
exporter.ExportProgram(program, exportStream);
|
||||||
|
var bytes = exportStream.ToArray();
|
||||||
|
|
||||||
|
var importStream = new MemoryStream(bytes);
|
||||||
|
var success = importer.GetGeometry(importStream, out var reimported);
|
||||||
|
|
||||||
|
Assert.True(success, "Failed to re-import exported DXF");
|
||||||
|
return reimported;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<T> FilterByLayer<T>(List<Entity> entities, string layerName) where T : Entity
|
||||||
|
{
|
||||||
|
return entities
|
||||||
|
.Where(e => e is T && e.Layer?.Name == layerName)
|
||||||
|
.Cast<T>()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_Lines_PreservesGeometry()
|
||||||
|
{
|
||||||
|
// A simple triangle (non-collinear lines to avoid GeometryOptimizer merging)
|
||||||
|
var original = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(0, 0, 10, 0),
|
||||||
|
new Line(10, 0, 5, 8),
|
||||||
|
new Line(5, 8, 0, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
var reimported = ExportAndReimport(original);
|
||||||
|
var cutLines = FilterByLayer<Line>(reimported, "Cut");
|
||||||
|
|
||||||
|
Assert.Equal(original.Count, cutLines.Count);
|
||||||
|
|
||||||
|
for (var i = 0; i < original.Count; i++)
|
||||||
|
{
|
||||||
|
var orig = (Line)original[i];
|
||||||
|
var rt = cutLines[i];
|
||||||
|
Assert.Equal(orig.StartPoint.X, rt.StartPoint.X, Tolerance);
|
||||||
|
Assert.Equal(orig.StartPoint.Y, rt.StartPoint.Y, Tolerance);
|
||||||
|
Assert.Equal(orig.EndPoint.X, rt.EndPoint.X, Tolerance);
|
||||||
|
Assert.Equal(orig.EndPoint.Y, rt.EndPoint.Y, Tolerance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_Circle_PreservesCenterAndRadius()
|
||||||
|
{
|
||||||
|
var original = new Circle(5, 3, 4);
|
||||||
|
var geometry = new List<Entity> { original };
|
||||||
|
|
||||||
|
var reimported = ExportAndReimport(geometry);
|
||||||
|
var circles = FilterByLayer<Circle>(reimported, "Cut");
|
||||||
|
|
||||||
|
Assert.Single(circles);
|
||||||
|
var rt = circles[0];
|
||||||
|
Assert.Equal(original.Center.X, rt.Center.X, Tolerance);
|
||||||
|
Assert.Equal(original.Center.Y, rt.Center.Y, Tolerance);
|
||||||
|
Assert.Equal(original.Radius, rt.Radius, Tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_Arc_PreservesCenterRadiusAndAngles()
|
||||||
|
{
|
||||||
|
var original = new Arc(5, 3, 4, 0.0, System.Math.PI / 2);
|
||||||
|
var geometry = new List<Entity> { original };
|
||||||
|
|
||||||
|
var reimported = ExportAndReimport(geometry);
|
||||||
|
var arcs = FilterByLayer<Arc>(reimported, "Cut");
|
||||||
|
|
||||||
|
Assert.Single(arcs);
|
||||||
|
var rt = arcs[0];
|
||||||
|
Assert.Equal(original.Center.X, rt.Center.X, Tolerance);
|
||||||
|
Assert.Equal(original.Center.Y, rt.Center.Y, Tolerance);
|
||||||
|
Assert.Equal(original.Radius, rt.Radius, Tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_Mixed_PreservesEntityTypes()
|
||||||
|
{
|
||||||
|
var original = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(0, 0, 10, 0),
|
||||||
|
new Line(10, 0, 10, 5),
|
||||||
|
new Circle(20, 20, 3),
|
||||||
|
new Arc(15, 15, 5, 0.0, System.Math.PI)
|
||||||
|
};
|
||||||
|
|
||||||
|
var reimported = ExportAndReimport(original);
|
||||||
|
|
||||||
|
var cutLines = FilterByLayer<Line>(reimported, "Cut");
|
||||||
|
var cutCircles = FilterByLayer<Circle>(reimported, "Cut");
|
||||||
|
var cutArcs = FilterByLayer<Arc>(reimported, "Cut");
|
||||||
|
|
||||||
|
Assert.Equal(2, cutLines.Count);
|
||||||
|
Assert.Single(cutCircles);
|
||||||
|
Assert.Single(cutArcs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_Rectangle_PreservesBoundingBox()
|
||||||
|
{
|
||||||
|
// Rectangle with distinct width/height so optimizer won't merge
|
||||||
|
var original = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(0, 0, 20, 0),
|
||||||
|
new Line(20, 0, 20, 10),
|
||||||
|
new Line(20, 10, 0, 10),
|
||||||
|
new Line(0, 10, 0, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
var reimported = ExportAndReimport(original);
|
||||||
|
var cutLines = FilterByLayer<Line>(reimported, "Cut");
|
||||||
|
|
||||||
|
// Verify bounding box is preserved regardless of line order
|
||||||
|
var origMinX = original.Cast<Line>().Min(l => System.Math.Min(l.StartPoint.X, l.EndPoint.X));
|
||||||
|
var origMaxX = original.Cast<Line>().Max(l => System.Math.Max(l.StartPoint.X, l.EndPoint.X));
|
||||||
|
var origMinY = original.Cast<Line>().Min(l => System.Math.Min(l.StartPoint.Y, l.EndPoint.Y));
|
||||||
|
var origMaxY = original.Cast<Line>().Max(l => System.Math.Max(l.StartPoint.Y, l.EndPoint.Y));
|
||||||
|
|
||||||
|
var rtMinX = cutLines.Min(l => System.Math.Min(l.StartPoint.X, l.EndPoint.X));
|
||||||
|
var rtMaxX = cutLines.Max(l => System.Math.Max(l.StartPoint.X, l.EndPoint.X));
|
||||||
|
var rtMinY = cutLines.Min(l => System.Math.Min(l.StartPoint.Y, l.EndPoint.Y));
|
||||||
|
var rtMaxY = cutLines.Max(l => System.Math.Max(l.StartPoint.Y, l.EndPoint.Y));
|
||||||
|
|
||||||
|
Assert.Equal(origMinX, rtMinX, Tolerance);
|
||||||
|
Assert.Equal(origMaxX, rtMaxX, Tolerance);
|
||||||
|
Assert.Equal(origMinY, rtMinY, Tolerance);
|
||||||
|
Assert.Equal(origMaxY, rtMaxY, Tolerance);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
OpenNest.Tests/PatternTilerTests.cs
Normal file
105
OpenNest.Tests/PatternTilerTests.cs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
using OpenNest;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
public class PatternTilerTests
|
||||||
|
{
|
||||||
|
private static Drawing MakeSquareDrawing(double size)
|
||||||
|
{
|
||||||
|
var pgm = new CNC.Program();
|
||||||
|
pgm.Codes.Add(new CNC.LinearMove(size, 0));
|
||||||
|
pgm.Codes.Add(new CNC.LinearMove(size, size));
|
||||||
|
pgm.Codes.Add(new CNC.LinearMove(0, size));
|
||||||
|
pgm.Codes.Add(new CNC.LinearMove(0, 0));
|
||||||
|
return new Drawing("square", pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_SinglePart_FillsGrid()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(10);
|
||||||
|
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||||
|
var plateSize = new Size(30, 20);
|
||||||
|
var partSpacing = 0.0;
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||||
|
|
||||||
|
Assert.Equal(6, result.Count);
|
||||||
|
|
||||||
|
foreach (var part in result)
|
||||||
|
{
|
||||||
|
Assert.True(part.BoundingBox.Right <= plateSize.Width + 0.001);
|
||||||
|
Assert.True(part.BoundingBox.Top <= plateSize.Length + 0.001);
|
||||||
|
Assert.True(part.BoundingBox.Left >= -0.001);
|
||||||
|
Assert.True(part.BoundingBox.Bottom >= -0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_TwoParts_TilesUnitCell()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(10);
|
||||||
|
var partA = Part.CreateAtOrigin(drawing);
|
||||||
|
var partB = Part.CreateAtOrigin(drawing);
|
||||||
|
partB.Offset(10, 0);
|
||||||
|
|
||||||
|
var cell = new List<Part> { partA, partB };
|
||||||
|
var plateSize = new Size(40, 20);
|
||||||
|
var partSpacing = 0.0;
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||||
|
|
||||||
|
Assert.Equal(8, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_WithSpacing_ReducesCount()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(10);
|
||||||
|
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||||
|
var plateSize = new Size(30, 20);
|
||||||
|
var partSpacing = 2.0;
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_EmptyCell_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = PatternTiler.Tile(new List<Part>(), new Size(100, 100), 0);
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_NonSquarePlate_CorrectAxes()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(10);
|
||||||
|
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||||
|
var plateSize = new Size(50, 10);
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, 0);
|
||||||
|
|
||||||
|
Assert.Equal(5, result.Count);
|
||||||
|
|
||||||
|
var maxRight = result.Max(p => p.BoundingBox.Right);
|
||||||
|
var maxTop = result.Max(p => p.BoundingBox.Top);
|
||||||
|
Assert.True(maxRight <= 50.001);
|
||||||
|
Assert.True(maxTop <= 10.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_CellLargerThanPlate_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(50);
|
||||||
|
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||||
|
var plateSize = new Size(30, 30);
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, 0);
|
||||||
|
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
OpenNest.Tests/Strategies/FillPipelineTests.cs
Normal file
62
OpenNest.Tests/Strategies/FillPipelineTests.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Strategies;
|
||||||
|
|
||||||
|
public class FillPipelineTests
|
||||||
|
{
|
||||||
|
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||||
|
return new Drawing(name, pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pipeline_PopulatesPhaseResults()
|
||||||
|
{
|
||||||
|
var plate = new Plate(120, 60);
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(engine.PhaseResults.Count >= 4,
|
||||||
|
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pipeline_SetsWinnerPhase()
|
||||||
|
{
|
||||||
|
var plate = new Plate(120, 60);
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(parts.Count > 0);
|
||||||
|
Assert.True(engine.WinnerPhase == NestPhase.Pairs ||
|
||||||
|
engine.WinnerPhase == NestPhase.Linear ||
|
||||||
|
engine.WinnerPhase == NestPhase.RectBestFit ||
|
||||||
|
engine.WinnerPhase == NestPhase.Extents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pipeline_RespectsCancellation()
|
||||||
|
{
|
||||||
|
var plate = new Plate(120, 60);
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
var cts = new System.Threading.CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
// Pre-cancelled token should return empty or partial results without throwing
|
||||||
|
var parts = engine.Fill(item, plate.WorkArea(), null, cts.Token);
|
||||||
|
|
||||||
|
// Should not throw — graceful degradation
|
||||||
|
Assert.NotNull(parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs
Normal file
35
OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
namespace OpenNest.Tests.Strategies;
|
||||||
|
|
||||||
|
public class FillStrategyRegistryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Registry_DiscoversBuiltInStrategies()
|
||||||
|
{
|
||||||
|
var strategies = FillStrategyRegistry.Strategies;
|
||||||
|
|
||||||
|
Assert.True(strategies.Count >= 4, $"Expected at least 4 built-in strategies, got {strategies.Count}");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "Pairs");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "RectBestFit");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "Extents");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "Linear");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_StrategiesAreOrderedByOrder()
|
||||||
|
{
|
||||||
|
var strategies = FillStrategyRegistry.Strategies;
|
||||||
|
|
||||||
|
for (var i = 1; i < strategies.Count; i++)
|
||||||
|
Assert.True(strategies[i].Order >= strategies[i - 1].Order,
|
||||||
|
$"Strategy '{strategies[i].Name}' (Order={strategies[i].Order}) should not precede '{strategies[i - 1].Name}' (Order={strategies[i - 1].Order})");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_LinearIsLast()
|
||||||
|
{
|
||||||
|
var strategies = FillStrategyRegistry.Strategies;
|
||||||
|
var last = strategies[strategies.Count - 1];
|
||||||
|
|
||||||
|
Assert.Equal("Linear", last.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,28 +141,28 @@ namespace OpenNest.Actions
|
|||||||
{
|
{
|
||||||
if ((Control.ModifierKeys & Keys.Shift) == Keys.Shift)
|
if ((Control.ModifierKeys & Keys.Shift) == Keys.Shift)
|
||||||
{
|
{
|
||||||
|
var movingParts = parts.Select(p => p.BasePart).ToList();
|
||||||
|
|
||||||
|
PushDirection hDir, vDir;
|
||||||
switch (plateView.Plate.Quadrant)
|
switch (plateView.Plate.Quadrant)
|
||||||
{
|
{
|
||||||
case 1:
|
case 1: hDir = PushDirection.Left; vDir = PushDirection.Down; break;
|
||||||
plateView.PushSelected(PushDirection.Left);
|
case 2: hDir = PushDirection.Right; vDir = PushDirection.Down; break;
|
||||||
plateView.PushSelected(PushDirection.Down);
|
case 3: hDir = PushDirection.Right; vDir = PushDirection.Up; break;
|
||||||
break;
|
case 4: hDir = PushDirection.Left; vDir = PushDirection.Up; break;
|
||||||
|
default: hDir = PushDirection.Left; vDir = PushDirection.Down; break;
|
||||||
case 2:
|
|
||||||
plateView.PushSelected(PushDirection.Right);
|
|
||||||
plateView.PushSelected(PushDirection.Down);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 3:
|
|
||||||
plateView.PushSelected(PushDirection.Right);
|
|
||||||
plateView.PushSelected(PushDirection.Up);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 4:
|
|
||||||
plateView.PushSelected(PushDirection.Left);
|
|
||||||
plateView.PushSelected(PushDirection.Up);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1: BB-only push to get past irregular geometry quickly.
|
||||||
|
Compactor.PushBoundingBox(movingParts, plateView.Plate, hDir);
|
||||||
|
Compactor.PushBoundingBox(movingParts, plateView.Plate, vDir);
|
||||||
|
|
||||||
|
// Phase 2: Geometry push to settle against actual contours.
|
||||||
|
Compactor.Push(movingParts, plateView.Plate, hDir);
|
||||||
|
Compactor.Push(movingParts, plateView.Plate, vDir);
|
||||||
|
|
||||||
|
parts.ForEach(p => p.IsDirty = true);
|
||||||
|
plateView.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.ForEach(p => plateView.Plate.Parts.Add(p.BasePart.Clone() as Part));
|
parts.ForEach(p => plateView.Plate.Parts.Add(p.BasePart.Clone() as Part));
|
||||||
|
|||||||
15
OpenNest/Forms/MainForm.Designer.cs
generated
15
OpenNest/Forms/MainForm.Designer.cs
generated
@@ -61,6 +61,7 @@
|
|||||||
mnuTools = new System.Windows.Forms.ToolStripMenuItem();
|
mnuTools = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuToolsMeasureArea = new System.Windows.Forms.ToolStripMenuItem();
|
mnuToolsMeasureArea = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem();
|
mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
|
mnuToolsPatternTile = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuToolsAlign = new System.Windows.Forms.ToolStripMenuItem();
|
mnuToolsAlign = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuToolsAlignLeft = new System.Windows.Forms.ToolStripMenuItem();
|
mnuToolsAlignLeft = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
mnuToolsAlignRight = new System.Windows.Forms.ToolStripMenuItem();
|
mnuToolsAlignRight = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
@@ -380,7 +381,7 @@
|
|||||||
//
|
//
|
||||||
// mnuTools
|
// mnuTools
|
||||||
//
|
//
|
||||||
mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsAlign, toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement, toolStripMenuItem15, mnuToolsOptions });
|
mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsPatternTile, mnuToolsAlign, toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement, toolStripMenuItem15, mnuToolsOptions });
|
||||||
mnuTools.Name = "mnuTools";
|
mnuTools.Name = "mnuTools";
|
||||||
mnuTools.Size = new System.Drawing.Size(47, 20);
|
mnuTools.Size = new System.Drawing.Size(47, 20);
|
||||||
mnuTools.Text = "&Tools";
|
mnuTools.Text = "&Tools";
|
||||||
@@ -398,9 +399,16 @@
|
|||||||
mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22);
|
mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22);
|
||||||
mnuToolsBestFitViewer.Text = "Best-Fit Viewer";
|
mnuToolsBestFitViewer.Text = "Best-Fit Viewer";
|
||||||
mnuToolsBestFitViewer.Click += BestFitViewer_Click;
|
mnuToolsBestFitViewer.Click += BestFitViewer_Click;
|
||||||
//
|
//
|
||||||
|
// mnuToolsPatternTile
|
||||||
|
//
|
||||||
|
this.mnuToolsPatternTile.Name = "mnuToolsPatternTile";
|
||||||
|
this.mnuToolsPatternTile.Size = new System.Drawing.Size(214, 22);
|
||||||
|
this.mnuToolsPatternTile.Text = "Pattern Tile";
|
||||||
|
this.mnuToolsPatternTile.Click += PatternTile_Click;
|
||||||
|
//
|
||||||
// mnuToolsAlign
|
// mnuToolsAlign
|
||||||
//
|
//
|
||||||
mnuToolsAlign.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsAlignLeft, mnuToolsAlignRight, mnuToolsAlignTop, mnuToolsAlignBottom, toolStripMenuItem11, mnuToolsAlignHorizontal, mnuToolsAlignVertically, toolStripMenuItem8, mnuToolsEvenlySpaceHorizontal, mnuToolsEvenlySpaceVertical });
|
mnuToolsAlign.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsAlignLeft, mnuToolsAlignRight, mnuToolsAlignTop, mnuToolsAlignBottom, toolStripMenuItem11, mnuToolsAlignHorizontal, mnuToolsAlignVertically, toolStripMenuItem8, mnuToolsEvenlySpaceHorizontal, mnuToolsEvenlySpaceVertical });
|
||||||
mnuToolsAlign.Name = "mnuToolsAlign";
|
mnuToolsAlign.Name = "mnuToolsAlign";
|
||||||
mnuToolsAlign.Size = new System.Drawing.Size(214, 22);
|
mnuToolsAlign.Size = new System.Drawing.Size(214, 22);
|
||||||
@@ -1169,6 +1177,7 @@
|
|||||||
private System.Windows.Forms.ToolStripMenuItem autoSequenceAllPlatesToolStripMenuItem;
|
private System.Windows.Forms.ToolStripMenuItem autoSequenceAllPlatesToolStripMenuItem;
|
||||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsMeasureArea;
|
private System.Windows.Forms.ToolStripMenuItem mnuToolsMeasureArea;
|
||||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer;
|
private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer;
|
||||||
|
private System.Windows.Forms.ToolStripMenuItem mnuToolsPatternTile;
|
||||||
private System.Windows.Forms.ToolStripButton btnSaveAs;
|
private System.Windows.Forms.ToolStripButton btnSaveAs;
|
||||||
private System.Windows.Forms.ToolStripMenuItem centerPartsToolStripMenuItem;
|
private System.Windows.Forms.ToolStripMenuItem centerPartsToolStripMenuItem;
|
||||||
private System.Windows.Forms.ToolStripStatusLabel gpuStatusLabel;
|
private System.Windows.Forms.ToolStripStatusLabel gpuStatusLabel;
|
||||||
|
|||||||
@@ -596,6 +596,49 @@ namespace OpenNest.Forms
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void PatternTile_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (activeForm == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (activeForm.Nest.Drawings.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show("No drawings available.", "Pattern Tile",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var form = new PatternTileForm(activeForm.Nest))
|
||||||
|
{
|
||||||
|
if (form.ShowDialog(this) != DialogResult.OK || form.Result == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = form.Result;
|
||||||
|
|
||||||
|
if (result.Target == PatternTileTarget.CurrentPlate)
|
||||||
|
{
|
||||||
|
activeForm.PlateView.Plate.Parts.Clear();
|
||||||
|
|
||||||
|
foreach (var part in result.Parts)
|
||||||
|
activeForm.PlateView.Plate.Parts.Add(part);
|
||||||
|
|
||||||
|
activeForm.PlateView.ZoomToFit();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var plate = activeForm.Nest.CreatePlate();
|
||||||
|
plate.Size = result.PlateSize;
|
||||||
|
|
||||||
|
foreach (var part in result.Parts)
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
|
||||||
|
activeForm.LoadLastPlate();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeForm.Nest.UpdateDrawingQuantities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void SetOffsetIncrement_Click(object sender, EventArgs e)
|
private void SetOffsetIncrement_Click(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (activeForm == null) return;
|
if (activeForm == null) return;
|
||||||
|
|||||||
165
OpenNest/Forms/PatternTileForm.Designer.cs
generated
Normal file
165
OpenNest/Forms/PatternTileForm.Designer.cs
generated
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
namespace OpenNest.Forms
|
||||||
|
{
|
||||||
|
partial class PatternTileForm
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing && (components != null))
|
||||||
|
components.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
this.topPanel = new System.Windows.Forms.FlowLayoutPanel();
|
||||||
|
this.lblDrawingA = new System.Windows.Forms.Label();
|
||||||
|
this.cboDrawingA = new System.Windows.Forms.ComboBox();
|
||||||
|
this.lblDrawingB = new System.Windows.Forms.Label();
|
||||||
|
this.cboDrawingB = new System.Windows.Forms.ComboBox();
|
||||||
|
this.lblPlateSize = new System.Windows.Forms.Label();
|
||||||
|
this.txtPlateSize = new System.Windows.Forms.TextBox();
|
||||||
|
this.lblPartSpacing = new System.Windows.Forms.Label();
|
||||||
|
this.nudPartSpacing = new System.Windows.Forms.NumericUpDown();
|
||||||
|
this.btnAutoArrange = new System.Windows.Forms.Button();
|
||||||
|
this.btnApply = new System.Windows.Forms.Button();
|
||||||
|
this.splitContainer = new System.Windows.Forms.SplitContainer();
|
||||||
|
this.topPanel.SuspendLayout();
|
||||||
|
((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).BeginInit();
|
||||||
|
((System.ComponentModel.ISupportInitialize)(this.splitContainer)).BeginInit();
|
||||||
|
this.splitContainer.SuspendLayout();
|
||||||
|
this.SuspendLayout();
|
||||||
|
//
|
||||||
|
// topPanel
|
||||||
|
//
|
||||||
|
this.topPanel.Controls.Add(this.lblDrawingA);
|
||||||
|
this.topPanel.Controls.Add(this.cboDrawingA);
|
||||||
|
this.topPanel.Controls.Add(this.lblDrawingB);
|
||||||
|
this.topPanel.Controls.Add(this.cboDrawingB);
|
||||||
|
this.topPanel.Controls.Add(this.lblPlateSize);
|
||||||
|
this.topPanel.Controls.Add(this.txtPlateSize);
|
||||||
|
this.topPanel.Controls.Add(this.lblPartSpacing);
|
||||||
|
this.topPanel.Controls.Add(this.nudPartSpacing);
|
||||||
|
this.topPanel.Controls.Add(this.btnAutoArrange);
|
||||||
|
this.topPanel.Controls.Add(this.btnApply);
|
||||||
|
this.topPanel.Dock = System.Windows.Forms.DockStyle.Top;
|
||||||
|
this.topPanel.Height = 36;
|
||||||
|
this.topPanel.Name = "topPanel";
|
||||||
|
this.topPanel.WrapContents = false;
|
||||||
|
this.topPanel.Padding = new System.Windows.Forms.Padding(4, 2, 4, 2);
|
||||||
|
//
|
||||||
|
// lblDrawingA
|
||||||
|
//
|
||||||
|
this.lblDrawingA.AutoSize = true;
|
||||||
|
this.lblDrawingA.Margin = new System.Windows.Forms.Padding(3, 5, 0, 0);
|
||||||
|
this.lblDrawingA.Name = "lblDrawingA";
|
||||||
|
this.lblDrawingA.Text = "Drawing A:";
|
||||||
|
//
|
||||||
|
// cboDrawingA
|
||||||
|
//
|
||||||
|
this.cboDrawingA.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||||
|
this.cboDrawingA.Margin = new System.Windows.Forms.Padding(3, 3, 0, 0);
|
||||||
|
this.cboDrawingA.Name = "cboDrawingA";
|
||||||
|
this.cboDrawingA.Width = 130;
|
||||||
|
//
|
||||||
|
// lblDrawingB
|
||||||
|
//
|
||||||
|
this.lblDrawingB.AutoSize = true;
|
||||||
|
this.lblDrawingB.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||||
|
this.lblDrawingB.Name = "lblDrawingB";
|
||||||
|
this.lblDrawingB.Text = "Drawing B:";
|
||||||
|
//
|
||||||
|
// cboDrawingB
|
||||||
|
//
|
||||||
|
this.cboDrawingB.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||||
|
this.cboDrawingB.Margin = new System.Windows.Forms.Padding(3, 3, 0, 0);
|
||||||
|
this.cboDrawingB.Name = "cboDrawingB";
|
||||||
|
this.cboDrawingB.Width = 130;
|
||||||
|
//
|
||||||
|
// lblPlateSize
|
||||||
|
//
|
||||||
|
this.lblPlateSize.AutoSize = true;
|
||||||
|
this.lblPlateSize.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||||
|
this.lblPlateSize.Name = "lblPlateSize";
|
||||||
|
this.lblPlateSize.Text = "Plate:";
|
||||||
|
//
|
||||||
|
// txtPlateSize
|
||||||
|
//
|
||||||
|
this.txtPlateSize.Margin = new System.Windows.Forms.Padding(3, 3, 0, 0);
|
||||||
|
this.txtPlateSize.Name = "txtPlateSize";
|
||||||
|
this.txtPlateSize.Width = 90;
|
||||||
|
//
|
||||||
|
// lblPartSpacing
|
||||||
|
//
|
||||||
|
this.lblPartSpacing.AutoSize = true;
|
||||||
|
this.lblPartSpacing.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||||
|
this.lblPartSpacing.Name = "lblPartSpacing";
|
||||||
|
this.lblPartSpacing.Text = "Spacing:";
|
||||||
|
//
|
||||||
|
// nudPartSpacing
|
||||||
|
//
|
||||||
|
this.nudPartSpacing.DecimalPlaces = 2;
|
||||||
|
this.nudPartSpacing.Increment = new decimal(new int[] { 25, 0, 0, 131072 });
|
||||||
|
this.nudPartSpacing.Maximum = new decimal(new int[] { 100, 0, 0, 0 });
|
||||||
|
this.nudPartSpacing.Minimum = new decimal(new int[] { 0, 0, 0, 0 });
|
||||||
|
this.nudPartSpacing.Margin = new System.Windows.Forms.Padding(3, 3, 0, 0);
|
||||||
|
this.nudPartSpacing.Name = "nudPartSpacing";
|
||||||
|
this.nudPartSpacing.Width = 70;
|
||||||
|
//
|
||||||
|
// btnAutoArrange
|
||||||
|
//
|
||||||
|
this.btnAutoArrange.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||||
|
this.btnAutoArrange.Margin = new System.Windows.Forms.Padding(10, 3, 0, 0);
|
||||||
|
this.btnAutoArrange.Name = "btnAutoArrange";
|
||||||
|
this.btnAutoArrange.Size = new System.Drawing.Size(100, 26);
|
||||||
|
this.btnAutoArrange.Text = "Auto Arrange";
|
||||||
|
//
|
||||||
|
// btnApply
|
||||||
|
//
|
||||||
|
this.btnApply.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||||
|
this.btnApply.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
|
||||||
|
this.btnApply.Name = "btnApply";
|
||||||
|
this.btnApply.Size = new System.Drawing.Size(80, 26);
|
||||||
|
this.btnApply.Text = "Apply";
|
||||||
|
//
|
||||||
|
// splitContainer
|
||||||
|
//
|
||||||
|
this.splitContainer.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||||
|
this.splitContainer.Name = "splitContainer";
|
||||||
|
this.splitContainer.SplitterDistance = 350;
|
||||||
|
this.splitContainer.TabIndex = 1;
|
||||||
|
//
|
||||||
|
// PatternTileForm
|
||||||
|
//
|
||||||
|
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||||
|
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||||
|
this.ClientSize = new System.Drawing.Size(900, 550);
|
||||||
|
this.Controls.Add(this.splitContainer);
|
||||||
|
this.Controls.Add(this.topPanel);
|
||||||
|
this.MinimumSize = new System.Drawing.Size(700, 400);
|
||||||
|
this.Name = "PatternTileForm";
|
||||||
|
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||||
|
this.Text = "Pattern Tile";
|
||||||
|
this.topPanel.ResumeLayout(false);
|
||||||
|
this.topPanel.PerformLayout();
|
||||||
|
((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).EndInit();
|
||||||
|
((System.ComponentModel.ISupportInitialize)(this.splitContainer)).EndInit();
|
||||||
|
this.splitContainer.ResumeLayout(false);
|
||||||
|
this.ResumeLayout(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private System.Windows.Forms.FlowLayoutPanel topPanel;
|
||||||
|
private System.Windows.Forms.Label lblDrawingA;
|
||||||
|
private System.Windows.Forms.ComboBox cboDrawingA;
|
||||||
|
private System.Windows.Forms.Label lblDrawingB;
|
||||||
|
private System.Windows.Forms.ComboBox cboDrawingB;
|
||||||
|
private System.Windows.Forms.Label lblPlateSize;
|
||||||
|
private System.Windows.Forms.TextBox txtPlateSize;
|
||||||
|
private System.Windows.Forms.Label lblPartSpacing;
|
||||||
|
private System.Windows.Forms.NumericUpDown nudPartSpacing;
|
||||||
|
private System.Windows.Forms.Button btnAutoArrange;
|
||||||
|
private System.Windows.Forms.Button btnApply;
|
||||||
|
private System.Windows.Forms.SplitContainer splitContainer;
|
||||||
|
}
|
||||||
|
}
|
||||||
403
OpenNest/Forms/PatternTileForm.cs
Normal file
403
OpenNest/Forms/PatternTileForm.cs
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Controls;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using GeoSize = OpenNest.Geometry.Size;
|
||||||
|
|
||||||
|
namespace OpenNest.Forms
|
||||||
|
{
|
||||||
|
public enum PatternTileTarget
|
||||||
|
{
|
||||||
|
CurrentPlate,
|
||||||
|
NewPlate
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PatternTileResult
|
||||||
|
{
|
||||||
|
public List<Part> Parts { get; set; }
|
||||||
|
public PatternTileTarget Target { get; set; }
|
||||||
|
public GeoSize PlateSize { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class PatternTileForm : Form
|
||||||
|
{
|
||||||
|
private readonly Nest nest;
|
||||||
|
private readonly PlateView cellView;
|
||||||
|
private readonly PlateView hPreview;
|
||||||
|
private readonly PlateView vPreview;
|
||||||
|
private readonly Label hLabel;
|
||||||
|
private readonly Label vLabel;
|
||||||
|
|
||||||
|
public PatternTileResult Result { get; private set; }
|
||||||
|
|
||||||
|
public PatternTileForm(Nest nest)
|
||||||
|
{
|
||||||
|
this.nest = nest;
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
// Unit cell editor — plate outline hidden via zero-size plate
|
||||||
|
cellView = new PlateView();
|
||||||
|
cellView.Plate.Size = new GeoSize(0, 0);
|
||||||
|
cellView.Plate.Quantity = 0; // prevent Drawing.Quantity.Nested side-effects
|
||||||
|
cellView.DrawOrigin = false;
|
||||||
|
cellView.DrawBounds = false; // hide selection bounding box overlay
|
||||||
|
cellView.Dock = DockStyle.Fill;
|
||||||
|
splitContainer.Panel1.Controls.Add(cellView);
|
||||||
|
|
||||||
|
// Right side: vertical split with horizontal and vertical preview
|
||||||
|
var previewSplit = new SplitContainer
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
SplitterDistance = 250
|
||||||
|
};
|
||||||
|
splitContainer.Panel2.Controls.Add(previewSplit);
|
||||||
|
|
||||||
|
hLabel = new Label
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Top,
|
||||||
|
Height = 20,
|
||||||
|
Text = "Horizontal — 0 parts",
|
||||||
|
TextAlign = System.Drawing.ContentAlignment.MiddleLeft,
|
||||||
|
Font = new System.Drawing.Font("Segoe UI", 9f, System.Drawing.FontStyle.Bold),
|
||||||
|
ForeColor = System.Drawing.Color.FromArgb(80, 80, 80),
|
||||||
|
Padding = new Padding(4, 0, 0, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
hPreview = CreatePreviewView();
|
||||||
|
previewSplit.Panel1.Controls.Add(hPreview);
|
||||||
|
previewSplit.Panel1.Controls.Add(hLabel);
|
||||||
|
|
||||||
|
vLabel = new Label
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Top,
|
||||||
|
Height = 20,
|
||||||
|
Text = "Vertical — 0 parts",
|
||||||
|
TextAlign = System.Drawing.ContentAlignment.MiddleLeft,
|
||||||
|
Font = new System.Drawing.Font("Segoe UI", 9f, System.Drawing.FontStyle.Bold),
|
||||||
|
ForeColor = System.Drawing.Color.FromArgb(80, 80, 80),
|
||||||
|
Padding = new Padding(4, 0, 0, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
vPreview = CreatePreviewView();
|
||||||
|
previewSplit.Panel2.Controls.Add(vPreview);
|
||||||
|
previewSplit.Panel2.Controls.Add(vLabel);
|
||||||
|
|
||||||
|
// Populate drawing dropdowns
|
||||||
|
var drawings = nest.Drawings.OrderBy(d => d.Name).ToList();
|
||||||
|
cboDrawingA.Items.Add("(none)");
|
||||||
|
cboDrawingB.Items.Add("(none)");
|
||||||
|
foreach (var d in drawings)
|
||||||
|
{
|
||||||
|
cboDrawingA.Items.Add(d);
|
||||||
|
cboDrawingB.Items.Add(d);
|
||||||
|
}
|
||||||
|
cboDrawingA.SelectedIndex = 0;
|
||||||
|
cboDrawingB.SelectedIndex = 0;
|
||||||
|
|
||||||
|
// Default plate size from nest defaults
|
||||||
|
var defaults = nest.PlateDefaults;
|
||||||
|
txtPlateSize.Text = defaults.Size.ToString();
|
||||||
|
nudPartSpacing.Value = (decimal)defaults.PartSpacing;
|
||||||
|
|
||||||
|
// Wire events
|
||||||
|
cboDrawingA.SelectedIndexChanged += OnDrawingChanged;
|
||||||
|
cboDrawingB.SelectedIndexChanged += OnDrawingChanged;
|
||||||
|
txtPlateSize.TextChanged += OnPlateSettingsChanged;
|
||||||
|
nudPartSpacing.ValueChanged += OnPlateSettingsChanged;
|
||||||
|
btnAutoArrange.Click += OnAutoArrangeClick;
|
||||||
|
btnApply.Click += OnApplyClick;
|
||||||
|
cellView.MouseUp += OnCellMouseUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Drawing SelectedDrawingA =>
|
||||||
|
cboDrawingA.SelectedItem as Drawing;
|
||||||
|
|
||||||
|
private Drawing SelectedDrawingB =>
|
||||||
|
cboDrawingB.SelectedItem as Drawing;
|
||||||
|
|
||||||
|
private double PartSpacing =>
|
||||||
|
(double)nudPartSpacing.Value;
|
||||||
|
|
||||||
|
private bool TryGetPlateSize(out GeoSize size)
|
||||||
|
{
|
||||||
|
return GeoSize.TryParse(txtPlateSize.Text, out size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDrawingChanged(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
RebuildCell();
|
||||||
|
RebuildPreview();
|
||||||
|
btnAutoArrange.Enabled = SelectedDrawingA != null && SelectedDrawingB != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlateSettingsChanged(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
UpdatePreviewPlateSize();
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCellMouseUp(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Button == MouseButtons.Left && cellView.Plate.Parts.Count >= 2)
|
||||||
|
{
|
||||||
|
CompactCellParts();
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildCell()
|
||||||
|
{
|
||||||
|
cellView.Plate.Parts.Clear();
|
||||||
|
|
||||||
|
var drawingA = SelectedDrawingA;
|
||||||
|
var drawingB = SelectedDrawingB;
|
||||||
|
|
||||||
|
if (drawingA == null && drawingB == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (drawingA != null)
|
||||||
|
{
|
||||||
|
var partA = Part.CreateAtOrigin(drawingA);
|
||||||
|
cellView.Plate.Parts.Add(partA);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawingB != null)
|
||||||
|
{
|
||||||
|
var partB = Part.CreateAtOrigin(drawingB);
|
||||||
|
|
||||||
|
// Place B to the right of A (or at origin if A is null)
|
||||||
|
if (drawingA != null && cellView.Plate.Parts.Count > 0)
|
||||||
|
{
|
||||||
|
var aBox = cellView.Plate.Parts[0].BoundingBox;
|
||||||
|
partB.Offset(aBox.Right + PartSpacing, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
cellView.Plate.Parts.Add(partB);
|
||||||
|
}
|
||||||
|
|
||||||
|
cellView.ZoomToFit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompactCellParts()
|
||||||
|
{
|
||||||
|
var parts = cellView.Plate.Parts.ToList();
|
||||||
|
if (parts.Count < 2)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CompactTowardCentroid(parts, PartSpacing);
|
||||||
|
cellView.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CompactTowardCentroid(List<Part> parts, double spacing)
|
||||||
|
{
|
||||||
|
// Use a fixed centroid as the attractor — close enough for 2-part cells
|
||||||
|
// and avoids oscillation from recomputing each iteration.
|
||||||
|
var centroid = parts.GetBoundingBox().Center;
|
||||||
|
var syntheticWorkArea = new Box(-10000, -10000, 20000, 20000);
|
||||||
|
|
||||||
|
for (var iteration = 0; iteration < 10; iteration++)
|
||||||
|
{
|
||||||
|
var totalMoved = 0.0;
|
||||||
|
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
var partCenter = part.BoundingBox.Center;
|
||||||
|
var dx = centroid.X - partCenter.X;
|
||||||
|
var dy = centroid.Y - partCenter.Y;
|
||||||
|
|
||||||
|
if (System.Math.Sqrt(dx * dx + dy * dy) < 0.01)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var angle = System.Math.Atan2(dy, dx);
|
||||||
|
var single = new List<Part> { part };
|
||||||
|
var obstacles = parts.Where(p => p != part).ToList();
|
||||||
|
|
||||||
|
totalMoved += Compactor.Push(single, obstacles,
|
||||||
|
syntheticWorkArea, spacing, angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalMoved < 0.01)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlateView CreatePreviewView()
|
||||||
|
{
|
||||||
|
var view = new PlateView();
|
||||||
|
view.Plate.Quantity = 0;
|
||||||
|
view.AllowSelect = false;
|
||||||
|
view.AllowDrop = false;
|
||||||
|
view.DrawBounds = false;
|
||||||
|
view.Dock = DockStyle.Fill;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePreviewPlateSize()
|
||||||
|
{
|
||||||
|
if (!TryGetPlateSize(out var size))
|
||||||
|
return;
|
||||||
|
|
||||||
|
hPreview.Plate.Size = size;
|
||||||
|
vPreview.Plate.Size = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Pattern BuildCellPattern()
|
||||||
|
{
|
||||||
|
var cellParts = cellView.Plate.Parts.ToList();
|
||||||
|
if (cellParts.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var pattern = new Pattern();
|
||||||
|
foreach (var part in cellParts)
|
||||||
|
pattern.Parts.Add(part);
|
||||||
|
pattern.UpdateBounds();
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildPreview()
|
||||||
|
{
|
||||||
|
hPreview.Plate.Parts.Clear();
|
||||||
|
vPreview.Plate.Parts.Clear();
|
||||||
|
|
||||||
|
if (!TryGetPlateSize(out var plateSize))
|
||||||
|
return;
|
||||||
|
|
||||||
|
hPreview.Plate.Size = plateSize;
|
||||||
|
hPreview.Plate.PartSpacing = PartSpacing;
|
||||||
|
vPreview.Plate.Size = plateSize;
|
||||||
|
vPreview.Plate.PartSpacing = PartSpacing;
|
||||||
|
|
||||||
|
var pattern = BuildCellPattern();
|
||||||
|
if (pattern == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var workArea = new Box(0, 0, plateSize.Width, plateSize.Length);
|
||||||
|
var filler = new FillLinear(workArea, PartSpacing);
|
||||||
|
|
||||||
|
var hParts = filler.Fill(pattern, NestDirection.Horizontal);
|
||||||
|
foreach (var part in hParts)
|
||||||
|
hPreview.Plate.Parts.Add(part);
|
||||||
|
hLabel.Text = $"Horizontal — {hParts.Count} parts";
|
||||||
|
hPreview.ZoomToFit();
|
||||||
|
|
||||||
|
var vFiller = new FillLinear(workArea, PartSpacing);
|
||||||
|
var vParts = vFiller.Fill(pattern, NestDirection.Vertical);
|
||||||
|
foreach (var part in vParts)
|
||||||
|
vPreview.Plate.Parts.Add(part);
|
||||||
|
vLabel.Text = $"Vertical — {vParts.Count} parts";
|
||||||
|
vPreview.ZoomToFit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAutoArrangeClick(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var drawingA = SelectedDrawingA;
|
||||||
|
var drawingB = SelectedDrawingB;
|
||||||
|
|
||||||
|
if (drawingA == null || drawingB == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!TryGetPlateSize(out var plateSize))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Cursor = Cursors.WaitCursor;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var angles = new[] { 0.0, Math.Angle.ToRadians(90), Math.Angle.ToRadians(180), Math.Angle.ToRadians(270) };
|
||||||
|
var bestCell = (List<Part>)null;
|
||||||
|
var bestArea = double.MaxValue;
|
||||||
|
|
||||||
|
foreach (var angleA in angles)
|
||||||
|
{
|
||||||
|
foreach (var angleB in angles)
|
||||||
|
{
|
||||||
|
var partA = Part.CreateAtOrigin(drawingA, angleA);
|
||||||
|
var partB = Part.CreateAtOrigin(drawingB, angleB);
|
||||||
|
partB.Offset(partA.BoundingBox.Right + PartSpacing, 0);
|
||||||
|
|
||||||
|
var cell = new List<Part> { partA, partB };
|
||||||
|
CompactTowardCentroid(cell, PartSpacing);
|
||||||
|
|
||||||
|
var finalBox = cell.GetBoundingBox();
|
||||||
|
var area = finalBox.Width * finalBox.Length;
|
||||||
|
|
||||||
|
if (area < bestArea)
|
||||||
|
{
|
||||||
|
bestArea = area;
|
||||||
|
bestCell = cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestCell != null)
|
||||||
|
{
|
||||||
|
cellView.Plate.Parts.Clear();
|
||||||
|
foreach (var part in bestCell)
|
||||||
|
cellView.Plate.Parts.Add(part);
|
||||||
|
cellView.ZoomToFit();
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Cursor = Cursors.Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnApplyClick(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var hCount = hPreview.Plate.Parts.Count;
|
||||||
|
var vCount = vPreview.Plate.Parts.Count;
|
||||||
|
|
||||||
|
if (hCount == 0 && vCount == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!TryGetPlateSize(out var plateSize))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Pick which direction to apply — use the one with more parts,
|
||||||
|
// or ask if they're equal and both > 0
|
||||||
|
NestDirection applyDirection;
|
||||||
|
|
||||||
|
if (hCount > vCount)
|
||||||
|
applyDirection = NestDirection.Horizontal;
|
||||||
|
else if (vCount > hCount)
|
||||||
|
applyDirection = NestDirection.Vertical;
|
||||||
|
else
|
||||||
|
applyDirection = NestDirection.Horizontal; // tie-break
|
||||||
|
|
||||||
|
var choice = MessageBox.Show(
|
||||||
|
$"Apply {applyDirection} pattern ({(applyDirection == NestDirection.Horizontal ? hCount : vCount)} parts) to current plate?" +
|
||||||
|
"\n\nYes = Current plate (clears existing parts)\nNo = New plate",
|
||||||
|
"Apply Pattern",
|
||||||
|
MessageBoxButtons.YesNoCancel,
|
||||||
|
MessageBoxIcon.Question);
|
||||||
|
|
||||||
|
if (choice == DialogResult.Cancel)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Rebuild a fresh set of tiled parts for the caller
|
||||||
|
var pattern = BuildCellPattern();
|
||||||
|
if (pattern == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var filler = new FillLinear(new Box(0, 0, plateSize.Width, plateSize.Length), PartSpacing);
|
||||||
|
var tiledParts = filler.Fill(pattern, applyDirection);
|
||||||
|
|
||||||
|
Result = new PatternTileResult
|
||||||
|
{
|
||||||
|
Parts = tiledParts,
|
||||||
|
Target = choice == DialogResult.Yes
|
||||||
|
? PatternTileTarget.CurrentPlate
|
||||||
|
: PatternTileTarget.NewPlate,
|
||||||
|
PlateSize = plateSize
|
||||||
|
};
|
||||||
|
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
877
docs/superpowers/plans/2026-03-18-pattern-tile-layout.md
Normal file
877
docs/superpowers/plans/2026-03-18-pattern-tile-layout.md
Normal file
@@ -0,0 +1,877 @@
|
|||||||
|
# Pattern Tile Layout Window Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a tool window where the user selects two drawings, arranges them as a unit cell, and tiles the pattern across a configurable plate with an option to apply the result.
|
||||||
|
|
||||||
|
**Architecture:** A `PatternTileForm` dialog with a horizontal `SplitContainer` — left panel is a `PlateView` for unit cell editing (plate outline hidden via zero-size plate), right panel is a read-only `PlateView` for tile preview. A `PatternTiler` helper in Engine handles the tiling math (deliberate addition beyond the spec for separation of concerns — the spec says "no engine changes" but the tiler is pure logic with no side-effects). The form is opened from the Tools menu and returns a result to `EditNestForm` for placement.
|
||||||
|
|
||||||
|
**Tech Stack:** WinForms (.NET 8), existing `PlateView`/`DrawControl` controls, `Compactor.Push` (angle-based overload), `Part.CloneAtOffset`
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-18-pattern-tile-layout-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: PatternTiler — tiling algorithm in Engine
|
||||||
|
|
||||||
|
The pure logic component that takes a unit cell (list of parts) and tiles it across a plate work area. No UI dependency.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest.Engine/PatternTiler.cs`
|
||||||
|
- Test: `OpenNest.Tests/PatternTilerTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for PatternTiler**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// OpenNest.Tests/PatternTilerTests.cs
|
||||||
|
using OpenNest;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
public class PatternTilerTests
|
||||||
|
{
|
||||||
|
private static Drawing MakeSquareDrawing(double size)
|
||||||
|
{
|
||||||
|
var pgm = new CNC.Program();
|
||||||
|
pgm.Add(new CNC.LinearMove(size, 0));
|
||||||
|
pgm.Add(new CNC.LinearMove(size, size));
|
||||||
|
pgm.Add(new CNC.LinearMove(0, size));
|
||||||
|
pgm.Add(new CNC.LinearMove(0, 0));
|
||||||
|
return new Drawing("square", pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_SinglePart_FillsGrid()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(10);
|
||||||
|
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||||
|
// Size(width=X, length=Y) — 30 wide, 20 tall
|
||||||
|
var plateSize = new Size(30, 20);
|
||||||
|
var partSpacing = 0.0;
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||||
|
|
||||||
|
// 3 columns (30/10) x 2 rows (20/10) = 6 parts
|
||||||
|
Assert.Equal(6, result.Count);
|
||||||
|
|
||||||
|
// Verify all parts are within plate bounds
|
||||||
|
foreach (var part in result)
|
||||||
|
{
|
||||||
|
Assert.True(part.BoundingBox.Right <= plateSize.Width + 0.001);
|
||||||
|
Assert.True(part.BoundingBox.Top <= plateSize.Length + 0.001);
|
||||||
|
Assert.True(part.BoundingBox.Left >= -0.001);
|
||||||
|
Assert.True(part.BoundingBox.Bottom >= -0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_TwoParts_TilesUnitCell()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(10);
|
||||||
|
var partA = Part.CreateAtOrigin(drawing);
|
||||||
|
var partB = Part.CreateAtOrigin(drawing);
|
||||||
|
partB.Offset(10, 0); // side by side, cell = 20x10
|
||||||
|
|
||||||
|
var cell = new List<Part> { partA, partB };
|
||||||
|
var plateSize = new Size(40, 20);
|
||||||
|
var partSpacing = 0.0;
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||||
|
|
||||||
|
// 2 columns (40/20) x 2 rows (20/10) = 4 cells x 2 parts = 8
|
||||||
|
Assert.Equal(8, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_WithSpacing_ReducesCount()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(10);
|
||||||
|
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||||
|
var plateSize = new Size(30, 20);
|
||||||
|
var partSpacing = 2.0;
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, partSpacing);
|
||||||
|
|
||||||
|
// cell width = 10 + 2 = 12, cols = floor(30/12) = 2
|
||||||
|
// cell height = 10 + 2 = 12, rows = floor(20/12) = 1
|
||||||
|
// 2 x 1 = 2 parts
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_EmptyCell_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var result = PatternTiler.Tile(new List<Part>(), new Size(100, 100), 0);
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_NonSquarePlate_CorrectAxes()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(10);
|
||||||
|
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||||
|
// Wide plate: 50 in X (Width), 10 in Y (Length) — should fit 5x1
|
||||||
|
var plateSize = new Size(50, 10);
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, 0);
|
||||||
|
|
||||||
|
Assert.Equal(5, result.Count);
|
||||||
|
|
||||||
|
// Verify parts span the X axis, not Y
|
||||||
|
var maxRight = result.Max(p => p.BoundingBox.Right);
|
||||||
|
var maxTop = result.Max(p => p.BoundingBox.Top);
|
||||||
|
Assert.True(maxRight <= 50.001);
|
||||||
|
Assert.True(maxTop <= 10.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tile_CellLargerThanPlate_ReturnsSingleCell()
|
||||||
|
{
|
||||||
|
var drawing = MakeSquareDrawing(50);
|
||||||
|
var cell = new List<Part> { Part.CreateAtOrigin(drawing) };
|
||||||
|
var plateSize = new Size(30, 30);
|
||||||
|
|
||||||
|
var result = PatternTiler.Tile(cell, plateSize, 0);
|
||||||
|
|
||||||
|
// Cell doesn't fit at all — 0 parts
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PatternTilerTests" -v n`
|
||||||
|
Expected: Build error — `PatternTiler` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement PatternTiler**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// OpenNest.Engine/PatternTiler.cs
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine
|
||||||
|
{
|
||||||
|
public static class PatternTiler
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tiles a unit cell across a plate, returning cloned parts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cell">The unit cell parts (positioned relative to each other).</param>
|
||||||
|
/// <param name="plateSize">The plate size to tile across.</param>
|
||||||
|
/// <param name="partSpacing">Spacing to add around each cell.</param>
|
||||||
|
/// <returns>List of cloned parts filling the plate.</returns>
|
||||||
|
public static List<Part> Tile(List<Part> cell, Size plateSize, double partSpacing)
|
||||||
|
{
|
||||||
|
if (cell == null || cell.Count == 0)
|
||||||
|
return new List<Part>();
|
||||||
|
|
||||||
|
var cellBox = cell.GetBoundingBox();
|
||||||
|
var halfSpacing = partSpacing / 2;
|
||||||
|
|
||||||
|
var cellWidth = cellBox.Width + partSpacing;
|
||||||
|
var cellHeight = cellBox.Length + partSpacing;
|
||||||
|
|
||||||
|
if (cellWidth <= 0 || cellHeight <= 0)
|
||||||
|
return new List<Part>();
|
||||||
|
|
||||||
|
// Size.Width = X-axis, Size.Length = Y-axis
|
||||||
|
var cols = (int)System.Math.Floor(plateSize.Width / cellWidth);
|
||||||
|
var rows = (int)System.Math.Floor(plateSize.Length / cellHeight);
|
||||||
|
|
||||||
|
if (cols <= 0 || rows <= 0)
|
||||||
|
return new List<Part>();
|
||||||
|
|
||||||
|
// Offset to normalize cell origin to (halfSpacing, halfSpacing)
|
||||||
|
var cellOrigin = cellBox.Location;
|
||||||
|
var baseOffset = new Vector(halfSpacing - cellOrigin.X, halfSpacing - cellOrigin.Y);
|
||||||
|
|
||||||
|
var result = new List<Part>(cols * rows * cell.Count);
|
||||||
|
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
for (var col = 0; col < cols; col++)
|
||||||
|
{
|
||||||
|
var tileOffset = baseOffset + new Vector(col * cellWidth, row * cellHeight);
|
||||||
|
|
||||||
|
foreach (var part in cell)
|
||||||
|
{
|
||||||
|
result.Add(part.CloneAtOffset(tileOffset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test OpenNest.Tests --filter "FullyQualifiedName~PatternTilerTests" -v n`
|
||||||
|
Expected: All 6 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add OpenNest.Engine/PatternTiler.cs OpenNest.Tests/PatternTilerTests.cs
|
||||||
|
git commit -m "feat(engine): add PatternTiler for unit cell tiling across plates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: PatternTileForm — the dialog window
|
||||||
|
|
||||||
|
The WinForms dialog with split layout, drawing pickers, plate size controls, and the two `PlateView` panels. This task builds the form shell and layout — interaction logic comes in Task 3.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest/Forms/PatternTileForm.cs`
|
||||||
|
- Create: `OpenNest/Forms/PatternTileForm.Designer.cs`
|
||||||
|
|
||||||
|
**Key reference files:**
|
||||||
|
- `OpenNest/Forms/BestFitViewerForm.cs` — similar standalone tool form pattern
|
||||||
|
- `OpenNest/Controls/PlateView.cs` — the control used in both panels
|
||||||
|
- `OpenNest/Forms/EditPlateForm.cs` — plate size input pattern
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create PatternTileForm.Designer.cs**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// OpenNest/Forms/PatternTileForm.Designer.cs
|
||||||
|
namespace OpenNest.Forms
|
||||||
|
{
|
||||||
|
partial class PatternTileForm
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing && (components != null))
|
||||||
|
components.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
this.topPanel = new System.Windows.Forms.FlowLayoutPanel();
|
||||||
|
this.lblDrawingA = new System.Windows.Forms.Label();
|
||||||
|
this.cboDrawingA = new System.Windows.Forms.ComboBox();
|
||||||
|
this.lblDrawingB = new System.Windows.Forms.Label();
|
||||||
|
this.cboDrawingB = new System.Windows.Forms.ComboBox();
|
||||||
|
this.lblPlateSize = new System.Windows.Forms.Label();
|
||||||
|
this.txtPlateSize = new System.Windows.Forms.TextBox();
|
||||||
|
this.lblPartSpacing = new System.Windows.Forms.Label();
|
||||||
|
this.nudPartSpacing = new System.Windows.Forms.NumericUpDown();
|
||||||
|
this.btnAutoArrange = new System.Windows.Forms.Button();
|
||||||
|
this.btnApply = new System.Windows.Forms.Button();
|
||||||
|
this.splitContainer = new System.Windows.Forms.SplitContainer();
|
||||||
|
this.topPanel.SuspendLayout();
|
||||||
|
((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).BeginInit();
|
||||||
|
((System.ComponentModel.ISupportInitialize)(this.splitContainer)).BeginInit();
|
||||||
|
this.splitContainer.SuspendLayout();
|
||||||
|
this.SuspendLayout();
|
||||||
|
//
|
||||||
|
// topPanel — FlowLayoutPanel for correct left-to-right ordering
|
||||||
|
//
|
||||||
|
this.topPanel.Controls.Add(this.lblDrawingA);
|
||||||
|
this.topPanel.Controls.Add(this.cboDrawingA);
|
||||||
|
this.topPanel.Controls.Add(this.lblDrawingB);
|
||||||
|
this.topPanel.Controls.Add(this.cboDrawingB);
|
||||||
|
this.topPanel.Controls.Add(this.lblPlateSize);
|
||||||
|
this.topPanel.Controls.Add(this.txtPlateSize);
|
||||||
|
this.topPanel.Controls.Add(this.lblPartSpacing);
|
||||||
|
this.topPanel.Controls.Add(this.nudPartSpacing);
|
||||||
|
this.topPanel.Controls.Add(this.btnAutoArrange);
|
||||||
|
this.topPanel.Controls.Add(this.btnApply);
|
||||||
|
this.topPanel.Dock = System.Windows.Forms.DockStyle.Top;
|
||||||
|
this.topPanel.Height = 36;
|
||||||
|
this.topPanel.Padding = new System.Windows.Forms.Padding(4, 4, 4, 4);
|
||||||
|
this.topPanel.WrapContents = false;
|
||||||
|
//
|
||||||
|
// lblDrawingA
|
||||||
|
//
|
||||||
|
this.lblDrawingA.Text = "Drawing A:";
|
||||||
|
this.lblDrawingA.AutoSize = true;
|
||||||
|
this.lblDrawingA.Margin = new System.Windows.Forms.Padding(3, 5, 0, 0);
|
||||||
|
//
|
||||||
|
// cboDrawingA
|
||||||
|
//
|
||||||
|
this.cboDrawingA.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||||
|
this.cboDrawingA.Width = 130;
|
||||||
|
//
|
||||||
|
// lblDrawingB
|
||||||
|
//
|
||||||
|
this.lblDrawingB.Text = "Drawing B:";
|
||||||
|
this.lblDrawingB.AutoSize = true;
|
||||||
|
this.lblDrawingB.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||||
|
//
|
||||||
|
// cboDrawingB
|
||||||
|
//
|
||||||
|
this.cboDrawingB.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||||
|
this.cboDrawingB.Width = 130;
|
||||||
|
//
|
||||||
|
// lblPlateSize
|
||||||
|
//
|
||||||
|
this.lblPlateSize.Text = "Plate Size:";
|
||||||
|
this.lblPlateSize.AutoSize = true;
|
||||||
|
this.lblPlateSize.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||||
|
//
|
||||||
|
// txtPlateSize
|
||||||
|
//
|
||||||
|
this.txtPlateSize.Width = 80;
|
||||||
|
//
|
||||||
|
// lblPartSpacing
|
||||||
|
//
|
||||||
|
this.lblPartSpacing.Text = "Spacing:";
|
||||||
|
this.lblPartSpacing.AutoSize = true;
|
||||||
|
this.lblPartSpacing.Margin = new System.Windows.Forms.Padding(10, 5, 0, 0);
|
||||||
|
//
|
||||||
|
// nudPartSpacing
|
||||||
|
//
|
||||||
|
this.nudPartSpacing.Width = 60;
|
||||||
|
this.nudPartSpacing.DecimalPlaces = 2;
|
||||||
|
this.nudPartSpacing.Increment = 0.25m;
|
||||||
|
this.nudPartSpacing.Maximum = 100;
|
||||||
|
this.nudPartSpacing.Minimum = 0;
|
||||||
|
//
|
||||||
|
// btnAutoArrange
|
||||||
|
//
|
||||||
|
this.btnAutoArrange.Text = "Auto-Arrange";
|
||||||
|
this.btnAutoArrange.Width = 100;
|
||||||
|
this.btnAutoArrange.Margin = new System.Windows.Forms.Padding(10, 0, 0, 0);
|
||||||
|
//
|
||||||
|
// btnApply
|
||||||
|
//
|
||||||
|
this.btnApply.Text = "Apply...";
|
||||||
|
this.btnApply.Width = 80;
|
||||||
|
//
|
||||||
|
// splitContainer
|
||||||
|
//
|
||||||
|
this.splitContainer.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||||
|
this.splitContainer.SplitterDistance = 350;
|
||||||
|
//
|
||||||
|
// PatternTileForm
|
||||||
|
//
|
||||||
|
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||||
|
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||||
|
this.ClientSize = new System.Drawing.Size(900, 550);
|
||||||
|
this.Controls.Add(this.splitContainer);
|
||||||
|
this.Controls.Add(this.topPanel);
|
||||||
|
this.MinimumSize = new System.Drawing.Size(700, 400);
|
||||||
|
this.Name = "PatternTileForm";
|
||||||
|
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||||
|
this.Text = "Pattern Tile Layout";
|
||||||
|
this.topPanel.ResumeLayout(false);
|
||||||
|
this.topPanel.PerformLayout();
|
||||||
|
((System.ComponentModel.ISupportInitialize)(this.nudPartSpacing)).EndInit();
|
||||||
|
((System.ComponentModel.ISupportInitialize)(this.splitContainer)).EndInit();
|
||||||
|
this.splitContainer.ResumeLayout(false);
|
||||||
|
this.ResumeLayout(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private System.Windows.Forms.FlowLayoutPanel topPanel;
|
||||||
|
private System.Windows.Forms.Label lblDrawingA;
|
||||||
|
private System.Windows.Forms.ComboBox cboDrawingA;
|
||||||
|
private System.Windows.Forms.Label lblDrawingB;
|
||||||
|
private System.Windows.Forms.ComboBox cboDrawingB;
|
||||||
|
private System.Windows.Forms.Label lblPlateSize;
|
||||||
|
private System.Windows.Forms.TextBox txtPlateSize;
|
||||||
|
private System.Windows.Forms.Label lblPartSpacing;
|
||||||
|
private System.Windows.Forms.NumericUpDown nudPartSpacing;
|
||||||
|
private System.Windows.Forms.Button btnAutoArrange;
|
||||||
|
private System.Windows.Forms.Button btnApply;
|
||||||
|
private System.Windows.Forms.SplitContainer splitContainer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create PatternTileForm.cs — form shell with PlateViews**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// OpenNest/Forms/PatternTileForm.cs
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Controls;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Forms
|
||||||
|
{
|
||||||
|
public enum PatternTileTarget
|
||||||
|
{
|
||||||
|
CurrentPlate,
|
||||||
|
NewPlate
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PatternTileResult
|
||||||
|
{
|
||||||
|
public List<Part> Parts { get; set; }
|
||||||
|
public PatternTileTarget Target { get; set; }
|
||||||
|
public Size PlateSize { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class PatternTileForm : Form
|
||||||
|
{
|
||||||
|
private readonly Nest nest;
|
||||||
|
private readonly PlateView cellView;
|
||||||
|
private readonly PlateView previewView;
|
||||||
|
|
||||||
|
public PatternTileResult Result { get; private set; }
|
||||||
|
|
||||||
|
public PatternTileForm(Nest nest)
|
||||||
|
{
|
||||||
|
this.nest = nest;
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
// Unit cell editor — plate outline hidden via zero-size plate
|
||||||
|
cellView = new PlateView();
|
||||||
|
cellView.Plate.Size = new Size(0, 0);
|
||||||
|
cellView.Plate.Quantity = 0; // prevent Drawing.Quantity.Nested side-effects
|
||||||
|
cellView.DrawOrigin = false;
|
||||||
|
cellView.DrawBounds = false; // hide selection bounding box overlay
|
||||||
|
cellView.Dock = DockStyle.Fill;
|
||||||
|
splitContainer.Panel1.Controls.Add(cellView);
|
||||||
|
|
||||||
|
// Tile preview — plate visible, read-only
|
||||||
|
previewView = new PlateView();
|
||||||
|
previewView.Plate.Quantity = 0; // prevent Drawing.Quantity.Nested side-effects
|
||||||
|
previewView.AllowSelect = false;
|
||||||
|
previewView.AllowDrop = false;
|
||||||
|
previewView.DrawBounds = false;
|
||||||
|
previewView.Dock = DockStyle.Fill;
|
||||||
|
splitContainer.Panel2.Controls.Add(previewView);
|
||||||
|
|
||||||
|
// Populate drawing dropdowns
|
||||||
|
var drawings = nest.Drawings.OrderBy(d => d.Name).ToList();
|
||||||
|
cboDrawingA.Items.Add("(none)");
|
||||||
|
cboDrawingB.Items.Add("(none)");
|
||||||
|
foreach (var d in drawings)
|
||||||
|
{
|
||||||
|
cboDrawingA.Items.Add(d);
|
||||||
|
cboDrawingB.Items.Add(d);
|
||||||
|
}
|
||||||
|
cboDrawingA.SelectedIndex = 0;
|
||||||
|
cboDrawingB.SelectedIndex = 0;
|
||||||
|
|
||||||
|
// Default plate size from nest defaults
|
||||||
|
var defaults = nest.PlateDefaults;
|
||||||
|
txtPlateSize.Text = defaults.Size.ToString();
|
||||||
|
nudPartSpacing.Value = (decimal)defaults.PartSpacing;
|
||||||
|
|
||||||
|
// Wire events
|
||||||
|
cboDrawingA.SelectedIndexChanged += OnDrawingChanged;
|
||||||
|
cboDrawingB.SelectedIndexChanged += OnDrawingChanged;
|
||||||
|
txtPlateSize.TextChanged += OnPlateSettingsChanged;
|
||||||
|
nudPartSpacing.ValueChanged += OnPlateSettingsChanged;
|
||||||
|
btnAutoArrange.Click += OnAutoArrangeClick;
|
||||||
|
btnApply.Click += OnApplyClick;
|
||||||
|
cellView.MouseUp += OnCellMouseUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Drawing SelectedDrawingA =>
|
||||||
|
cboDrawingA.SelectedItem as Drawing;
|
||||||
|
|
||||||
|
private Drawing SelectedDrawingB =>
|
||||||
|
cboDrawingB.SelectedItem as Drawing;
|
||||||
|
|
||||||
|
private double PartSpacing =>
|
||||||
|
(double)nudPartSpacing.Value;
|
||||||
|
|
||||||
|
private bool TryGetPlateSize(out Size size)
|
||||||
|
{
|
||||||
|
return Size.TryParse(txtPlateSize.Text, out size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDrawingChanged(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
RebuildCell();
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlateSettingsChanged(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
UpdatePreviewPlateSize();
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCellMouseUp(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Button == MouseButtons.Left && cellView.Plate.Parts.Count == 2)
|
||||||
|
{
|
||||||
|
CompactCellParts();
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildCell()
|
||||||
|
{
|
||||||
|
cellView.Plate.Parts.Clear();
|
||||||
|
|
||||||
|
var drawingA = SelectedDrawingA;
|
||||||
|
var drawingB = SelectedDrawingB;
|
||||||
|
|
||||||
|
if (drawingA == null && drawingB == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (drawingA != null)
|
||||||
|
{
|
||||||
|
var partA = Part.CreateAtOrigin(drawingA);
|
||||||
|
cellView.Plate.Parts.Add(partA);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawingB != null)
|
||||||
|
{
|
||||||
|
var partB = Part.CreateAtOrigin(drawingB);
|
||||||
|
|
||||||
|
// Place B to the right of A (or at origin if A is null)
|
||||||
|
if (drawingA != null && cellView.Plate.Parts.Count > 0)
|
||||||
|
{
|
||||||
|
var aBox = cellView.Plate.Parts[0].BoundingBox;
|
||||||
|
partB.Offset(aBox.Right + PartSpacing, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
cellView.Plate.Parts.Add(partB);
|
||||||
|
}
|
||||||
|
|
||||||
|
cellView.ZoomToFit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompactCellParts()
|
||||||
|
{
|
||||||
|
var parts = cellView.Plate.Parts.ToList();
|
||||||
|
if (parts.Count < 2)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var combinedBox = parts.GetBoundingBox();
|
||||||
|
var centroid = combinedBox.Center;
|
||||||
|
var syntheticWorkArea = new Box(-10000, -10000, 20000, 20000);
|
||||||
|
|
||||||
|
for (var iteration = 0; iteration < 10; iteration++)
|
||||||
|
{
|
||||||
|
var totalMoved = 0.0;
|
||||||
|
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
var partCenter = part.BoundingBox.Center;
|
||||||
|
var dx = centroid.X - partCenter.X;
|
||||||
|
var dy = centroid.Y - partCenter.Y;
|
||||||
|
var dist = System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < 0.01)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var angle = System.Math.Atan2(dy, dx);
|
||||||
|
var single = new List<Part> { part };
|
||||||
|
var obstacles = parts.Where(p => p != part).ToList();
|
||||||
|
|
||||||
|
totalMoved += Compactor.Push(single, obstacles,
|
||||||
|
syntheticWorkArea, PartSpacing, angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalMoved < 0.01)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
cellView.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePreviewPlateSize()
|
||||||
|
{
|
||||||
|
if (TryGetPlateSize(out var size))
|
||||||
|
previewView.Plate.Size = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildPreview()
|
||||||
|
{
|
||||||
|
previewView.Plate.Parts.Clear();
|
||||||
|
|
||||||
|
if (!TryGetPlateSize(out var plateSize))
|
||||||
|
return;
|
||||||
|
|
||||||
|
previewView.Plate.Size = plateSize;
|
||||||
|
previewView.Plate.PartSpacing = PartSpacing;
|
||||||
|
|
||||||
|
var cellParts = cellView.Plate.Parts.ToList();
|
||||||
|
if (cellParts.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var tiled = Engine.PatternTiler.Tile(cellParts, plateSize, PartSpacing);
|
||||||
|
|
||||||
|
foreach (var part in tiled)
|
||||||
|
previewView.Plate.Parts.Add(part);
|
||||||
|
|
||||||
|
previewView.ZoomToFit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAutoArrangeClick(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var drawingA = SelectedDrawingA;
|
||||||
|
var drawingB = SelectedDrawingB;
|
||||||
|
|
||||||
|
if (drawingA == null || drawingB == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!TryGetPlateSize(out var plateSize))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Cursor = Cursors.WaitCursor;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var angles = new[] { 0.0, Math.Angle.ToRadians(90), Math.Angle.ToRadians(180), Math.Angle.ToRadians(270) };
|
||||||
|
var bestCell = (List<Part>)null;
|
||||||
|
var bestArea = double.MaxValue;
|
||||||
|
|
||||||
|
foreach (var angleA in angles)
|
||||||
|
{
|
||||||
|
foreach (var angleB in angles)
|
||||||
|
{
|
||||||
|
var partA = Part.CreateAtOrigin(drawingA, angleA);
|
||||||
|
var partB = Part.CreateAtOrigin(drawingB, angleB);
|
||||||
|
partB.Offset(partA.BoundingBox.Right + PartSpacing, 0);
|
||||||
|
|
||||||
|
var cell = new List<Part> { partA, partB };
|
||||||
|
|
||||||
|
// Compact toward center
|
||||||
|
var box = cell.GetBoundingBox();
|
||||||
|
var centroid = box.Center;
|
||||||
|
var syntheticWorkArea = new Box(-10000, -10000, 20000, 20000);
|
||||||
|
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var moved = 0.0;
|
||||||
|
foreach (var part in cell)
|
||||||
|
{
|
||||||
|
var pc = part.BoundingBox.Center;
|
||||||
|
var dx = centroid.X - pc.X;
|
||||||
|
var dy = centroid.Y - pc.Y;
|
||||||
|
if (System.Math.Sqrt(dx * dx + dy * dy) < 0.01)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var angle = System.Math.Atan2(dy, dx);
|
||||||
|
var single = new List<Part> { part };
|
||||||
|
var obstacles = cell.Where(p => p != part).ToList();
|
||||||
|
moved += Compactor.Push(single, obstacles, syntheticWorkArea, PartSpacing, angle);
|
||||||
|
}
|
||||||
|
if (moved < 0.01) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalBox = cell.GetBoundingBox();
|
||||||
|
var area = finalBox.Width * finalBox.Length;
|
||||||
|
|
||||||
|
if (area < bestArea)
|
||||||
|
{
|
||||||
|
bestArea = area;
|
||||||
|
bestCell = cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestCell != null)
|
||||||
|
{
|
||||||
|
cellView.Plate.Parts.Clear();
|
||||||
|
foreach (var part in bestCell)
|
||||||
|
cellView.Plate.Parts.Add(part);
|
||||||
|
cellView.ZoomToFit();
|
||||||
|
RebuildPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Cursor = Cursors.Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnApplyClick(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (previewView.Plate.Parts.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!TryGetPlateSize(out var plateSize))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var choice = MessageBox.Show(
|
||||||
|
"Apply pattern to current plate?\n\nYes = Current plate (clears existing parts)\nNo = New plate",
|
||||||
|
"Apply Pattern",
|
||||||
|
MessageBoxButtons.YesNoCancel,
|
||||||
|
MessageBoxIcon.Question);
|
||||||
|
|
||||||
|
if (choice == DialogResult.Cancel)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Rebuild a fresh set of tiled parts for the caller
|
||||||
|
var cellParts = cellView.Plate.Parts.ToList();
|
||||||
|
var tiledParts = Engine.PatternTiler.Tile(cellParts, plateSize, PartSpacing);
|
||||||
|
|
||||||
|
Result = new PatternTileResult
|
||||||
|
{
|
||||||
|
Parts = tiledParts,
|
||||||
|
Target = choice == DialogResult.Yes
|
||||||
|
? PatternTileTarget.CurrentPlate
|
||||||
|
: PatternTileTarget.NewPlate,
|
||||||
|
PlateSize = plateSize
|
||||||
|
};
|
||||||
|
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build to verify compilation**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeds with no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add OpenNest/Forms/PatternTileForm.cs OpenNest/Forms/PatternTileForm.Designer.cs
|
||||||
|
git commit -m "feat(ui): add PatternTileForm dialog with unit cell editor and tile preview"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Wire up menu entry and apply logic in MainForm
|
||||||
|
|
||||||
|
Add a "Pattern Tile" menu item under Tools, wire it to open `PatternTileForm`, and handle the result by placing parts on the target plate.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `OpenNest/Forms/MainForm.Designer.cs` — add menu item
|
||||||
|
- Modify: `OpenNest/Forms/MainForm.cs` — add click handler and apply logic
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add menu item to MainForm.Designer.cs**
|
||||||
|
|
||||||
|
In the `InitializeComponent` method:
|
||||||
|
|
||||||
|
1. Add field declaration at end of class (near the other `mnuTools*` fields):
|
||||||
|
```csharp
|
||||||
|
private System.Windows.Forms.ToolStripMenuItem mnuToolsPatternTile;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In `InitializeComponent`, add initialization (near the other `mnuTools*` instantiations):
|
||||||
|
```csharp
|
||||||
|
this.mnuToolsPatternTile = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add to the `mnuTools.DropDownItems` array — insert `mnuToolsPatternTile` after `mnuToolsBestFitViewer`:
|
||||||
|
```csharp
|
||||||
|
mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
|
||||||
|
mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsPatternTile, mnuToolsAlign,
|
||||||
|
toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement,
|
||||||
|
toolStripMenuItem15, mnuToolsOptions });
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add configuration block:
|
||||||
|
```csharp
|
||||||
|
// mnuToolsPatternTile
|
||||||
|
this.mnuToolsPatternTile.Name = "mnuToolsPatternTile";
|
||||||
|
this.mnuToolsPatternTile.Size = new System.Drawing.Size(214, 22);
|
||||||
|
this.mnuToolsPatternTile.Text = "Pattern Tile";
|
||||||
|
this.mnuToolsPatternTile.Click += PatternTile_Click;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add click handler and apply logic to MainForm.cs**
|
||||||
|
|
||||||
|
Add in the `#region Tools Menu Events` section, after `BestFitViewer_Click`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void PatternTile_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (activeForm == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (activeForm.Nest.Drawings.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show("No drawings available.", "Pattern Tile",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var form = new PatternTileForm(activeForm.Nest))
|
||||||
|
{
|
||||||
|
if (form.ShowDialog(this) != DialogResult.OK || form.Result == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var result = form.Result;
|
||||||
|
|
||||||
|
if (result.Target == PatternTileTarget.CurrentPlate)
|
||||||
|
{
|
||||||
|
activeForm.PlateView.Plate.Parts.Clear();
|
||||||
|
|
||||||
|
foreach (var part in result.Parts)
|
||||||
|
activeForm.PlateView.Plate.Parts.Add(part);
|
||||||
|
|
||||||
|
activeForm.PlateView.ZoomToFit();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var plate = activeForm.Nest.CreatePlate();
|
||||||
|
plate.Size = result.PlateSize;
|
||||||
|
|
||||||
|
foreach (var part in result.Parts)
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
|
||||||
|
activeForm.LoadLastPlate();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeForm.Nest.UpdateDrawingQuantities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build and manually test**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeds. Launch the app, create a nest, import some DXFs, then Tools > Pattern Tile opens the form.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add OpenNest/Forms/MainForm.cs OpenNest/Forms/MainForm.Designer.cs
|
||||||
|
git commit -m "feat(ui): wire Pattern Tile menu item and apply logic in MainForm"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Manual testing and polish
|
||||||
|
|
||||||
|
Final integration testing and any adjustments.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Possibly modify: `OpenNest/Forms/PatternTileForm.cs`, `OpenNest/Forms/PatternTileForm.Designer.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: End-to-end test workflow**
|
||||||
|
|
||||||
|
1. Launch the app, create a new nest
|
||||||
|
2. Import 2+ DXF drawings
|
||||||
|
3. Open Tools > Pattern Tile
|
||||||
|
4. Select Drawing A and Drawing B
|
||||||
|
5. Verify parts appear in left panel, can be dragged
|
||||||
|
6. Verify compaction on mouse release closes gaps
|
||||||
|
7. Verify tile preview updates on the right
|
||||||
|
8. Change plate size — verify preview updates
|
||||||
|
9. Change spacing — verify preview updates
|
||||||
|
10. Click Auto-Arrange — verify it picks a tight arrangement
|
||||||
|
11. Click Apply > Yes (current plate) — verify parts placed
|
||||||
|
12. Reopen, Apply > No (new plate) — verify new plate created with parts
|
||||||
|
13. Test single drawing only (one dropdown set, other on "(none)")
|
||||||
|
14. Test same drawing in both dropdowns
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix any issues found during testing**
|
||||||
|
|
||||||
|
Address any layout, interaction, or rendering issues discovered.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix(ui): polish PatternTileForm after manual testing"
|
||||||
|
```
|
||||||
917
docs/superpowers/plans/2026-03-18-pluggable-fill-strategies.md
Normal file
917
docs/superpowers/plans/2026-03-18-pluggable-fill-strategies.md
Normal file
@@ -0,0 +1,917 @@
|
|||||||
|
# Pluggable Fill Strategies Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Extract the four hard-wired fill phases from `DefaultNestEngine.FindBestFill` into pluggable `IFillStrategy` implementations behind a pipeline orchestrator.
|
||||||
|
|
||||||
|
**Architecture:** Each fill phase (Pairs, RectBestFit, Extents, Linear) becomes a stateless `IFillStrategy` adapter around its existing filler class. A `FillContext` carries inputs and pipeline state. `FillStrategyRegistry` discovers strategies via reflection. `DefaultNestEngine.FindBestFill` is replaced by `RunPipeline` which iterates strategies in order.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, C#, xUnit (OpenNest.Tests)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-18-pluggable-fill-strategies-design.md`
|
||||||
|
|
||||||
|
**Deliberate behavioral change:** The phase execution order changes from Pairs/Linear/RectBestFit/Extents to Pairs/RectBestFit/Extents/Linear. Linear is moved last because it is the most expensive phase and rarely wins. The final result is equivalent (the pipeline always picks the globally best result), but intermediate progress reports during the fill will differ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `NestPhase.Custom` enum value
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `OpenNest.Engine/NestProgress.cs:6-13`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add Custom to NestPhase enum**
|
||||||
|
|
||||||
|
In `OpenNest.Engine/NestProgress.cs`, add `Custom` after `Extents`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum NestPhase
|
||||||
|
{
|
||||||
|
Linear,
|
||||||
|
RectBestFit,
|
||||||
|
Pairs,
|
||||||
|
Nfp,
|
||||||
|
Extents,
|
||||||
|
Custom
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add Custom to FormatPhaseName in NestEngineBase**
|
||||||
|
|
||||||
|
In `OpenNest.Engine/NestEngineBase.cs`, add a case in `FormatPhaseName`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
case NestPhase.Custom: return "Custom";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build to verify no errors**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeded
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run existing tests to verify no regression**
|
||||||
|
|
||||||
|
Run: `dotnet test OpenNest.Tests`
|
||||||
|
Expected: All tests pass
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(engine): add NestPhase.Custom for plugin fill strategies
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create `IFillStrategy` and `FillContext`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest.Engine/Strategies/IFillStrategy.cs`
|
||||||
|
- Create: `OpenNest.Engine/Strategies/FillContext.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the Strategies directory**
|
||||||
|
|
||||||
|
Verify `OpenNest.Engine/Strategies/` exists (create if needed).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write IFillStrategy.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Engine/Strategies/IFillStrategy.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public interface IFillStrategy
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
NestPhase Phase { get; }
|
||||||
|
int Order { get; }
|
||||||
|
List<Part> Fill(FillContext context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write FillContext.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Engine/Strategies/FillContext.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class FillContext
|
||||||
|
{
|
||||||
|
public NestItem Item { get; init; }
|
||||||
|
public Box WorkArea { get; init; }
|
||||||
|
public Plate Plate { get; init; }
|
||||||
|
public int PlateNumber { get; init; }
|
||||||
|
public CancellationToken Token { get; init; }
|
||||||
|
public IProgress<NestProgress> Progress { get; init; }
|
||||||
|
|
||||||
|
public List<Part> CurrentBest { get; set; }
|
||||||
|
public FillScore CurrentBestScore { get; set; }
|
||||||
|
public NestPhase WinnerPhase { get; set; }
|
||||||
|
public List<PhaseResult> PhaseResults { get; } = new();
|
||||||
|
public List<AngleResult> AngleResults { get; } = new();
|
||||||
|
|
||||||
|
public Dictionary<string, object> SharedState { get; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build to verify no errors**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeded
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(engine): add IFillStrategy interface and FillContext
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create `FillStrategyRegistry`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest.Engine/Strategies/FillStrategyRegistry.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write FillStrategyRegistry.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Engine/Strategies/FillStrategyRegistry.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public static class FillStrategyRegistry
|
||||||
|
{
|
||||||
|
private static readonly List<IFillStrategy> strategies = new();
|
||||||
|
private static List<IFillStrategy> sorted;
|
||||||
|
|
||||||
|
static FillStrategyRegistry()
|
||||||
|
{
|
||||||
|
LoadFrom(typeof(FillStrategyRegistry).Assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||||
|
sorted ??= strategies.OrderBy(s => s.Order).ToList();
|
||||||
|
|
||||||
|
public static void LoadFrom(Assembly assembly)
|
||||||
|
{
|
||||||
|
foreach (var type in assembly.GetTypes())
|
||||||
|
{
|
||||||
|
if (type.IsAbstract || type.IsInterface || !typeof(IFillStrategy).IsAssignableFrom(type))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var ctor = type.GetConstructor(Type.EmptyTypes);
|
||||||
|
if (ctor == null)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Skipping {type.Name}: no parameterless constructor");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var instance = (IFillStrategy)ctor.Invoke(null);
|
||||||
|
|
||||||
|
if (strategies.Any(s => s.Name.Equals(instance.Name, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Duplicate strategy '{instance.Name}' skipped");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
strategies.Add(instance);
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Registered: {instance.Name} (Order={instance.Order})");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Failed to instantiate {type.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void LoadPlugins(string directory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var assembly = Assembly.LoadFrom(dll);
|
||||||
|
LoadFrom(assembly);
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Loaded plugin assembly: {Path.GetFileName(dll)}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[FillStrategyRegistry] Failed to load {Path.GetFileName(dll)}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to verify no errors**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeded (no strategies registered yet — static constructor finds nothing)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(engine): add FillStrategyRegistry with reflection-based discovery
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Extract `FillHelpers` from `DefaultNestEngine`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest.Engine/Strategies/FillHelpers.cs`
|
||||||
|
- Modify: `OpenNest.Engine/DefaultNestEngine.cs` (remove `BuildRotatedPattern` and `FillPattern`)
|
||||||
|
- Modify: `OpenNest.Engine/PairFiller.cs` (update references)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create FillHelpers.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Engine/Strategies/FillHelpers.cs` with the two static methods moved from `DefaultNestEngine`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public static class FillHelpers
|
||||||
|
{
|
||||||
|
public static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
||||||
|
{
|
||||||
|
var pattern = new Pattern();
|
||||||
|
var center = ((IEnumerable<IBoundable>)groupParts).GetBoundingBox().Center;
|
||||||
|
|
||||||
|
foreach (var part in groupParts)
|
||||||
|
{
|
||||||
|
var clone = (Part)part.Clone();
|
||||||
|
clone.UpdateBounds();
|
||||||
|
|
||||||
|
if (!angle.IsEqualTo(0))
|
||||||
|
clone.Rotate(angle, center);
|
||||||
|
|
||||||
|
pattern.Parts.Add(clone);
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern.UpdateBounds();
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||||
|
{
|
||||||
|
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
|
||||||
|
|
||||||
|
Parallel.ForEach(angles, angle =>
|
||||||
|
{
|
||||||
|
var pattern = BuildRotatedPattern(groupParts, angle);
|
||||||
|
|
||||||
|
if (pattern.Parts.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||||
|
if (h != null && h.Count > 0)
|
||||||
|
results.Add((h, FillScore.Compute(h, workArea)));
|
||||||
|
|
||||||
|
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||||
|
if (v != null && v.Count > 0)
|
||||||
|
results.Add((v, FillScore.Compute(v, workArea)));
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Part> best = null;
|
||||||
|
var bestScore = default(FillScore);
|
||||||
|
|
||||||
|
foreach (var res in results)
|
||||||
|
{
|
||||||
|
if (best == null || res.Score > bestScore)
|
||||||
|
{
|
||||||
|
best = res.Parts;
|
||||||
|
bestScore = res.Score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update DefaultNestEngine to delegate to FillHelpers**
|
||||||
|
|
||||||
|
In `OpenNest.Engine/DefaultNestEngine.cs`:
|
||||||
|
- Change `BuildRotatedPattern` and `FillPattern` to forward to `FillHelpers`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal static Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
|
||||||
|
=> FillHelpers.BuildRotatedPattern(groupParts, angle);
|
||||||
|
|
||||||
|
internal static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||||
|
=> FillHelpers.FillPattern(engine, groupParts, angles, workArea);
|
||||||
|
```
|
||||||
|
|
||||||
|
This preserves the existing `internal static` API so `PairFiller` and `Fill(List<Part> groupParts, ...)` don't break.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build and run tests**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests`
|
||||||
|
Expected: Build succeeded, all tests pass
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor(engine): extract FillHelpers from DefaultNestEngine
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Create `PairsFillStrategy`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest.Engine/Strategies/PairsFillStrategy.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write PairsFillStrategy.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Engine/Strategies/PairsFillStrategy.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class PairsFillStrategy : IFillStrategy
|
||||||
|
{
|
||||||
|
public string Name => "Pairs";
|
||||||
|
public NestPhase Phase => NestPhase.Pairs;
|
||||||
|
public int Order => 100;
|
||||||
|
|
||||||
|
public List<Part> Fill(FillContext context)
|
||||||
|
{
|
||||||
|
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing);
|
||||||
|
var result = filler.Fill(context.Item, context.WorkArea,
|
||||||
|
context.PlateNumber, context.Token, context.Progress);
|
||||||
|
|
||||||
|
// Cache hit — PairFiller already called GetOrCompute internally.
|
||||||
|
var bestFits = BestFitCache.GetOrCompute(
|
||||||
|
context.Item.Drawing, context.Plate.Size.Length,
|
||||||
|
context.Plate.Size.Width, context.Plate.PartSpacing);
|
||||||
|
context.SharedState["BestFits"] = bestFits;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to verify**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeded (strategy auto-registered by `FillStrategyRegistry`)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(engine): add PairsFillStrategy adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Create `RectBestFitStrategy`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest.Engine/Strategies/RectBestFitStrategy.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write RectBestFitStrategy.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Engine/Strategies/RectBestFitStrategy.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.RectanglePacking;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class RectBestFitStrategy : IFillStrategy
|
||||||
|
{
|
||||||
|
public string Name => "RectBestFit";
|
||||||
|
public NestPhase Phase => NestPhase.RectBestFit;
|
||||||
|
public int Order => 200;
|
||||||
|
|
||||||
|
public List<Part> Fill(FillContext context)
|
||||||
|
{
|
||||||
|
var binItem = BinConverter.ToItem(context.Item, context.Plate.PartSpacing);
|
||||||
|
var bin = BinConverter.CreateBin(context.WorkArea, context.Plate.PartSpacing);
|
||||||
|
|
||||||
|
var engine = new FillBestFit(bin);
|
||||||
|
engine.Fill(binItem);
|
||||||
|
|
||||||
|
return BinConverter.ToParts(bin, new List<NestItem> { context.Item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to verify**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeded
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(engine): add RectBestFitStrategy adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Create `ExtentsFillStrategy`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest.Engine/Strategies/ExtentsFillStrategy.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write ExtentsFillStrategy.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Engine/Strategies/ExtentsFillStrategy.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class ExtentsFillStrategy : IFillStrategy
|
||||||
|
{
|
||||||
|
public string Name => "Extents";
|
||||||
|
public NestPhase Phase => NestPhase.Extents;
|
||||||
|
public int Order => 300;
|
||||||
|
|
||||||
|
public List<Part> Fill(FillContext context)
|
||||||
|
{
|
||||||
|
var filler = new FillExtents(context.WorkArea, context.Plate.PartSpacing);
|
||||||
|
|
||||||
|
var bestRotation = context.SharedState.TryGetValue("BestRotation", out var rot)
|
||||||
|
? (double)rot
|
||||||
|
: RotationAnalysis.FindBestRotation(context.Item);
|
||||||
|
|
||||||
|
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
||||||
|
|
||||||
|
var bestFits = context.SharedState.TryGetValue("BestFits", out var cached)
|
||||||
|
? (List<BestFitResult>)cached
|
||||||
|
: null;
|
||||||
|
|
||||||
|
List<Part> best = null;
|
||||||
|
var bestScore = default(FillScore);
|
||||||
|
|
||||||
|
foreach (var angle in angles)
|
||||||
|
{
|
||||||
|
context.Token.ThrowIfCancellationRequested();
|
||||||
|
var result = filler.Fill(context.Item.Drawing, angle,
|
||||||
|
context.PlateNumber, context.Token, context.Progress, bestFits);
|
||||||
|
if (result != null && result.Count > 0)
|
||||||
|
{
|
||||||
|
var score = FillScore.Compute(result, context.WorkArea);
|
||||||
|
if (best == null || score > bestScore)
|
||||||
|
{
|
||||||
|
best = result;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best ?? new List<Part>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to verify**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeded
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(engine): add ExtentsFillStrategy adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Create `LinearFillStrategy`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest.Engine/Strategies/LinearFillStrategy.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write LinearFillStrategy.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Engine/Strategies/LinearFillStrategy.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
public class LinearFillStrategy : IFillStrategy
|
||||||
|
{
|
||||||
|
public string Name => "Linear";
|
||||||
|
public NestPhase Phase => NestPhase.Linear;
|
||||||
|
public int Order => 400;
|
||||||
|
|
||||||
|
public List<Part> Fill(FillContext context)
|
||||||
|
{
|
||||||
|
var angles = context.SharedState.TryGetValue("AngleCandidates", out var cached)
|
||||||
|
? (List<double>)cached
|
||||||
|
: new List<double> { 0, Angle.HalfPI };
|
||||||
|
|
||||||
|
var workArea = context.WorkArea;
|
||||||
|
List<Part> best = null;
|
||||||
|
var bestScore = default(FillScore);
|
||||||
|
|
||||||
|
for (var ai = 0; ai < angles.Count; ai++)
|
||||||
|
{
|
||||||
|
context.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var angle = angles[ai];
|
||||||
|
var engine = new FillLinear(workArea, context.Plate.PartSpacing);
|
||||||
|
var h = engine.Fill(context.Item.Drawing, angle, NestDirection.Horizontal);
|
||||||
|
var v = engine.Fill(context.Item.Drawing, angle, NestDirection.Vertical);
|
||||||
|
|
||||||
|
var angleDeg = Angle.ToDegrees(angle);
|
||||||
|
|
||||||
|
if (h != null && h.Count > 0)
|
||||||
|
{
|
||||||
|
var scoreH = FillScore.Compute(h, workArea);
|
||||||
|
context.AngleResults.Add(new AngleResult
|
||||||
|
{
|
||||||
|
AngleDeg = angleDeg,
|
||||||
|
Direction = NestDirection.Horizontal,
|
||||||
|
PartCount = h.Count
|
||||||
|
});
|
||||||
|
|
||||||
|
if (best == null || scoreH > bestScore)
|
||||||
|
{
|
||||||
|
best = h;
|
||||||
|
bestScore = scoreH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v != null && v.Count > 0)
|
||||||
|
{
|
||||||
|
var scoreV = FillScore.Compute(v, workArea);
|
||||||
|
context.AngleResults.Add(new AngleResult
|
||||||
|
{
|
||||||
|
AngleDeg = angleDeg,
|
||||||
|
Direction = NestDirection.Vertical,
|
||||||
|
PartCount = v.Count
|
||||||
|
});
|
||||||
|
|
||||||
|
if (best == null || scoreV > bestScore)
|
||||||
|
{
|
||||||
|
best = v;
|
||||||
|
bestScore = scoreV;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
|
||||||
|
context.PlateNumber, best, workArea,
|
||||||
|
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
|
||||||
|
}
|
||||||
|
|
||||||
|
return best ?? new List<Part>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to verify**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeded
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(engine): add LinearFillStrategy adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Wire `RunPipeline` into `DefaultNestEngine`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `OpenNest.Engine/DefaultNestEngine.cs`
|
||||||
|
|
||||||
|
This is the key refactoring step. Replace `FindBestFill` with `RunPipeline` and delete dead code.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace FindBestFill with RunPipeline**
|
||||||
|
|
||||||
|
Delete the entire `FindBestFill` method (the large `private List<Part> FindBestFill(...)` method). Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void RunPipeline(FillContext context)
|
||||||
|
{
|
||||||
|
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||||
|
context.SharedState["BestRotation"] = bestRotation;
|
||||||
|
|
||||||
|
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
|
||||||
|
context.SharedState["AngleCandidates"] = angles;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var strategy in FillStrategyRegistry.Strategies)
|
||||||
|
{
|
||||||
|
context.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var result = strategy.Fill(context);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
var phaseResult = new PhaseResult(
|
||||||
|
strategy.Phase, result?.Count ?? 0, sw.ElapsedMilliseconds);
|
||||||
|
context.PhaseResults.Add(phaseResult);
|
||||||
|
|
||||||
|
// Keep engine's PhaseResults in sync so BuildProgressSummary() works
|
||||||
|
// during progress reporting.
|
||||||
|
PhaseResults.Add(phaseResult);
|
||||||
|
|
||||||
|
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
|
||||||
|
{
|
||||||
|
context.CurrentBest = result;
|
||||||
|
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||||
|
context.WinnerPhase = strategy.Phase;
|
||||||
|
ReportProgress(context.Progress, strategy.Phase, PlateNumber,
|
||||||
|
result, context.WorkArea, BuildProgressSummary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
|
||||||
|
}
|
||||||
|
|
||||||
|
angleBuilder.RecordProductive(context.AngleResults);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update Fill(NestItem, ...) to use RunPipeline**
|
||||||
|
|
||||||
|
Replace the body of the `Fill(NestItem item, Box workArea, ...)` override:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override List<Part> Fill(NestItem item, Box workArea,
|
||||||
|
IProgress<NestProgress> progress, CancellationToken token)
|
||||||
|
{
|
||||||
|
PhaseResults.Clear();
|
||||||
|
AngleResults.Clear();
|
||||||
|
|
||||||
|
var context = new FillContext
|
||||||
|
{
|
||||||
|
Item = item,
|
||||||
|
WorkArea = workArea,
|
||||||
|
Plate = Plate,
|
||||||
|
PlateNumber = PlateNumber,
|
||||||
|
Token = token,
|
||||||
|
Progress = progress,
|
||||||
|
};
|
||||||
|
|
||||||
|
RunPipeline(context);
|
||||||
|
|
||||||
|
// PhaseResults already synced during RunPipeline.
|
||||||
|
AngleResults.AddRange(context.AngleResults);
|
||||||
|
WinnerPhase = context.WinnerPhase;
|
||||||
|
|
||||||
|
var best = context.CurrentBest ?? new List<Part>();
|
||||||
|
|
||||||
|
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||||
|
best = best.Take(item.Quantity).ToList();
|
||||||
|
|
||||||
|
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary());
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete FillRectangleBestFit**
|
||||||
|
|
||||||
|
Remove the private `FillRectangleBestFit` method entirely. It is now inside `RectBestFitStrategy`.
|
||||||
|
|
||||||
|
Note: `Fill(List<Part> groupParts, ...)` also calls `FillRectangleBestFit` at line 125. Inline the logic there:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var binItem = BinConverter.ToItem(nestItem, Plate.PartSpacing);
|
||||||
|
var bin = BinConverter.CreateBin(workArea, Plate.PartSpacing);
|
||||||
|
var rectEngine = new FillBestFit(bin);
|
||||||
|
rectEngine.Fill(binItem);
|
||||||
|
var rectResult = BinConverter.ToParts(bin, new List<NestItem> { nestItem });
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Delete QuickFillCount**
|
||||||
|
|
||||||
|
Remove the `QuickFillCount` method entirely (dead code, zero callers).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build and run tests**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln && dotnet test OpenNest.Tests`
|
||||||
|
Expected: Build succeeded, **all existing tests pass** — this is the critical regression gate.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor(engine): replace FindBestFill with strategy pipeline
|
||||||
|
|
||||||
|
DefaultNestEngine.Fill(NestItem, ...) now delegates to RunPipeline
|
||||||
|
which iterates FillStrategyRegistry.Strategies in order.
|
||||||
|
|
||||||
|
Removed: FindBestFill, FillRectangleBestFit, QuickFillCount.
|
||||||
|
Kept: AngleCandidateBuilder, ForceFullAngleSweep, group-fill overload.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Add pipeline-specific tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs`
|
||||||
|
- Create: `OpenNest.Tests/Strategies/FillPipelineTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write FillStrategyRegistryTests.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Strategies;
|
||||||
|
|
||||||
|
public class FillStrategyRegistryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Registry_DiscoversBuiltInStrategies()
|
||||||
|
{
|
||||||
|
var strategies = FillStrategyRegistry.Strategies;
|
||||||
|
|
||||||
|
Assert.True(strategies.Count >= 4, $"Expected at least 4 built-in strategies, got {strategies.Count}");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "Pairs");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "RectBestFit");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "Extents");
|
||||||
|
Assert.Contains(strategies, s => s.Name == "Linear");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_StrategiesAreOrderedByOrder()
|
||||||
|
{
|
||||||
|
var strategies = FillStrategyRegistry.Strategies;
|
||||||
|
|
||||||
|
for (var i = 1; i < strategies.Count; i++)
|
||||||
|
Assert.True(strategies[i].Order >= strategies[i - 1].Order,
|
||||||
|
$"Strategy '{strategies[i].Name}' (Order={strategies[i].Order}) should not precede '{strategies[i - 1].Name}' (Order={strategies[i - 1].Order})");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_LinearIsLast()
|
||||||
|
{
|
||||||
|
var strategies = FillStrategyRegistry.Strategies;
|
||||||
|
var last = strategies[strategies.Count - 1];
|
||||||
|
|
||||||
|
Assert.Equal("Linear", last.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write FillPipelineTests.cs**
|
||||||
|
|
||||||
|
Create `OpenNest.Tests/Strategies/FillPipelineTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Strategies;
|
||||||
|
|
||||||
|
public class FillPipelineTests
|
||||||
|
{
|
||||||
|
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||||
|
return new Drawing(name, pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pipeline_PopulatesPhaseResults()
|
||||||
|
{
|
||||||
|
var plate = new Plate(120, 60);
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(engine.PhaseResults.Count >= 4,
|
||||||
|
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pipeline_SetsWinnerPhase()
|
||||||
|
{
|
||||||
|
var plate = new Plate(120, 60);
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(parts.Count > 0);
|
||||||
|
// WinnerPhase should be set to one of the built-in phases
|
||||||
|
Assert.True(engine.WinnerPhase == NestPhase.Pairs ||
|
||||||
|
engine.WinnerPhase == NestPhase.Linear ||
|
||||||
|
engine.WinnerPhase == NestPhase.RectBestFit ||
|
||||||
|
engine.WinnerPhase == NestPhase.Extents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pipeline_RespectsCancellation()
|
||||||
|
{
|
||||||
|
var plate = new Plate(120, 60);
|
||||||
|
var engine = new DefaultNestEngine(plate);
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
var cts = new System.Threading.CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
// Pre-cancelled token should return empty or partial results without throwing
|
||||||
|
var parts = engine.Fill(item, plate.WorkArea(), null, cts.Token);
|
||||||
|
|
||||||
|
// Should not throw — graceful degradation
|
||||||
|
Assert.NotNull(parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run all tests**
|
||||||
|
|
||||||
|
Run: `dotnet test OpenNest.Tests`
|
||||||
|
Expected: All tests pass (old and new)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
test(engine): add FillStrategyRegistry and pipeline tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Final verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `dotnet test OpenNest.Tests -v normal`
|
||||||
|
Expected: All tests pass
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build entire solution**
|
||||||
|
|
||||||
|
Run: `dotnet build OpenNest.sln`
|
||||||
|
Expected: Build succeeded, no warnings from Engine project
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify EngineRefactorSmokeTests still pass**
|
||||||
|
|
||||||
|
Run: `dotnet test OpenNest.Tests --filter EngineRefactorSmokeTests`
|
||||||
|
Expected: All 5 smoke tests pass (DefaultEngine_FillNestItem, DefaultEngine_FillGroupParts, DefaultEngine_ForceFullAngleSweep, StripEngine_Nest, BruteForceRunner_StillWorks)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify file layout matches spec**
|
||||||
|
|
||||||
|
Confirm these files exist under `OpenNest.Engine/Strategies/`:
|
||||||
|
- `IFillStrategy.cs`
|
||||||
|
- `FillContext.cs`
|
||||||
|
- `FillStrategyRegistry.cs`
|
||||||
|
- `FillHelpers.cs`
|
||||||
|
- `PairsFillStrategy.cs`
|
||||||
|
- `RectBestFitStrategy.cs`
|
||||||
|
- `ExtentsFillStrategy.cs`
|
||||||
|
- `LinearFillStrategy.cs`
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Pattern Tile Layout Window
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A standalone tool window for designing two-part tile patterns and previewing how they fill a plate. The user selects two drawings, arranges them into a unit cell by dragging, and sees the pattern tiled across a configurable plate. The unit cell compacts on release using the existing angle-based `Compactor.Push`. The tiled result can be applied to the current plate or a new plate.
|
||||||
|
|
||||||
|
## Window Layout
|
||||||
|
|
||||||
|
`PatternTileForm` is a non-MDI dialog opened from the main menu/toolbar. It receives a reference to the active `Nest` (for drawing list and plate creation). Horizontal `SplitContainer`:
|
||||||
|
|
||||||
|
- **Left panel (Unit Cell Editor):** A `PlateView` with `Plate.Size = (0, 0)` — no plate outline drawn. `Plate.Quantity = 0` to prevent `Drawing.Quantity.Nested` side-effects when parts are added/removed. Shows only the two parts. The user drags parts freely to position them relative to each other. Standard `PlateView` interactions (shift+scroll rotation, middle-click 90-degree rotation) are available. On mouse up after a drag, gravity compaction fires toward the combined center of gravity. Part spacing from the preview plate is used as the minimum gap during compaction.
|
||||||
|
|
||||||
|
- **Right panel (Tile Preview):** A read-only `PlateView` (`AllowSelect = false`, `AllowDrop = false`) with `Plate.Quantity = 0` (same isolation from quantity tracking). Shows the unit cell pattern tiled across a plate with a visible plate outline. Plate size is user-configurable, defaulting to the current nest's `PlateDefaults` size. Rebuilds on mouse up in the unit cell editor (not during drag).
|
||||||
|
|
||||||
|
- **Top control strip:** Two `ComboBox` dropdowns ("Drawing A", "Drawing B") populated from the active nest's `DrawingCollection`. Both may select the same drawing. Plate size inputs (length, width). An "Auto-Arrange" button. An "Apply" button.
|
||||||
|
|
||||||
|
## Drawing Selection & Unit Cell
|
||||||
|
|
||||||
|
When both dropdowns have a selection, two parts are created and placed side by side horizontally in the left `PlateView`, centered in the view. Selecting the same drawing for both is allowed.
|
||||||
|
|
||||||
|
When only one dropdown has a selection, a single part is shown in the unit cell editor. The tile preview tiles that single part across the plate (simple grid fill). The compaction step is skipped since there is only one part.
|
||||||
|
|
||||||
|
When neither dropdown has a selection, both panels are empty.
|
||||||
|
|
||||||
|
## Compaction on Mouse Up
|
||||||
|
|
||||||
|
On mouse up after a drag, each part is pushed individually toward the combined centroid of both parts:
|
||||||
|
|
||||||
|
1. Compute the centroid of the two parts' combined bounding box.
|
||||||
|
2. For each part, compute the angle from that part's bounding box center to the centroid.
|
||||||
|
3. Call the existing `Compactor.Push(List<Part>, List<Part>, Box, double, double angle)` overload for each part individually, treating the other part as the sole obstacle. Use a large synthetic work area (e.g., `new Box(-10000, -10000, 20000, 20000)`) since the unit cell editor has no real plate boundary — the work area just needs to not constrain the push.
|
||||||
|
4. The push uses part spacing from the preview plate as the minimum gap.
|
||||||
|
|
||||||
|
This avoids the zero-size plate `WorkArea()` issue and uses the existing angle-based push that already exists in `Compactor`.
|
||||||
|
|
||||||
|
## Auto-Arrange
|
||||||
|
|
||||||
|
A button that tries rotation combinations (0, 90, 180, 270 for each part — 16 combinations) and picks the pair arrangement with the tightest bounding box after compaction. The user can fine-tune from there.
|
||||||
|
|
||||||
|
## Tiling Algorithm
|
||||||
|
|
||||||
|
1. Compute the unit cell bounding box from the two parts' combined bounds.
|
||||||
|
2. Add half the part spacing as a margin on all sides of the cell, so adjacent cells have the correct spacing between parts at cell boundaries.
|
||||||
|
3. Calculate grid dimensions: `cols = floor(plateWorkAreaWidth / cellWidth)`, `rows = floor(plateWorkAreaHeight / cellHeight)`.
|
||||||
|
4. For each grid position `(col, row)`, clone the two parts offset by `(col * cellWidth, row * cellHeight)`.
|
||||||
|
5. Place all cloned parts on the preview plate.
|
||||||
|
|
||||||
|
Tiling recalculates only on mouse up in the unit cell editor, or when drawing selection or plate size changes.
|
||||||
|
|
||||||
|
## Apply to Plate
|
||||||
|
|
||||||
|
The "Apply" button opens a dialog with two choices:
|
||||||
|
- **Apply to current plate** — clears the current plate, then places the tiled parts onto it in `EditNestForm`.
|
||||||
|
- **Apply to new plate** — creates a new plate in the nest with the preview plate's size, then places the parts.
|
||||||
|
|
||||||
|
`PatternTileForm` returns a result object containing the list of parts and the target choice. The caller (`EditNestForm`) handles actual placement and quantity updates.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Project | Purpose |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| `PatternTileForm` | OpenNest (WinForms) | The dialog window with split layout, controls, and apply logic |
|
||||||
|
| Menu/toolbar integration | OpenNest (WinForms) | Entry point from `EditNestForm` toolbar |
|
||||||
|
|
||||||
|
Note: The angle-based `Compactor.Push(movingParts, obstacleParts, workArea, partSpacing, angle)` overload already exists in `OpenNest.Engine/Compactor.cs` — no engine changes are needed.
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# Pluggable Fill Strategies Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`DefaultNestEngine.FindBestFill` is a monolithic method that hard-wires four fill phases (Pairs, Linear, RectBestFit, Extents) in a fixed order. Adding a new fill strategy or changing the execution order requires modifying `DefaultNestEngine` directly. The Linear phase is expensive and rarely wins, but there's no way to skip or reorder it without editing the orchestration code.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Extract fill strategies into pluggable components behind a common interface. Engines compose strategies in a pipeline where each strategy receives the current best result from prior strategies and can decide whether to run. New strategies can be added by implementing the interface — including from plugin DLLs discovered via reflection.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This refactoring targets only the **single-item fill path** (`DefaultNestEngine.FindBestFill`, called from the `Fill(NestItem, ...)` overload). The following are explicitly **out of scope** and remain unchanged:
|
||||||
|
|
||||||
|
- `Fill(List<Part> groupParts, ...)` — group-fill overload, has its own inline orchestration with different conditions (multi-phase block only runs when `groupParts.Count == 1`). May be refactored to use strategies in a future pass once the single-item pipeline is proven.
|
||||||
|
- `PackArea` — packing is a different operation (bin-packing single-quantity items).
|
||||||
|
- `Nest` — multi-item orchestration on `NestEngineBase`, uses `Fill` and `PackArea` as building blocks.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### `IFillStrategy` Interface
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IFillStrategy
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
NestPhase Phase { get; }
|
||||||
|
int Order { get; } // lower runs first; gaps of 100 for plugin insertion
|
||||||
|
List<Part> Fill(FillContext context);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Strategies must be **stateless**. All mutable state lives in `FillContext`. This avoids leaking state between calls when strategies are shared across invocations.
|
||||||
|
|
||||||
|
Strategies **may** call `NestEngineBase.ReportProgress` for intermediate progress updates (e.g., `LinearFillStrategy` reports per-angle progress). The `FillContext` carries `Progress` and `PlateNumber` for this purpose. The pipeline orchestrator reports progress only when the overall best improves; strategies report their own internal progress as they work.
|
||||||
|
|
||||||
|
For plugin strategies that don't map to a built-in `NestPhase`, use `NestPhase.Custom` (a new enum value added as part of this work). The `Name` property provides the human-readable label.
|
||||||
|
|
||||||
|
### `FillContext`
|
||||||
|
|
||||||
|
Carries inputs and pipeline state through the strategy chain:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class FillContext
|
||||||
|
{
|
||||||
|
// Inputs
|
||||||
|
public NestItem Item { get; init; }
|
||||||
|
public Box WorkArea { get; init; }
|
||||||
|
public Plate Plate { get; init; }
|
||||||
|
public int PlateNumber { get; init; }
|
||||||
|
public CancellationToken Token { get; init; }
|
||||||
|
public IProgress<NestProgress> Progress { get; init; }
|
||||||
|
|
||||||
|
// Pipeline state
|
||||||
|
public List<Part> CurrentBest { get; set; }
|
||||||
|
public FillScore CurrentBestScore { get; set; }
|
||||||
|
public NestPhase WinnerPhase { get; set; }
|
||||||
|
public List<PhaseResult> PhaseResults { get; } = new();
|
||||||
|
public List<AngleResult> AngleResults { get; } = new();
|
||||||
|
|
||||||
|
// Shared resources (populated by earlier strategies, available to later ones)
|
||||||
|
public Dictionary<string, object> SharedState { get; } = new();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SharedState` enables cross-strategy data sharing without direct coupling. Well-known keys:
|
||||||
|
|
||||||
|
| Key | Type | Producer | Consumer |
|
||||||
|
|-----|------|----------|----------|
|
||||||
|
| `"BestFits"` | `List<BestFitResult>` | `PairsFillStrategy` | `ExtentsFillStrategy` |
|
||||||
|
| `"BestRotation"` | `double` | Pipeline setup | `ExtentsFillStrategy`, `LinearFillStrategy` |
|
||||||
|
| `"AngleCandidates"` | `List<double>` | Pipeline setup | `LinearFillStrategy` |
|
||||||
|
|
||||||
|
### Pipeline Setup
|
||||||
|
|
||||||
|
Before iterating strategies, `RunPipeline` performs shared pre-computation and stores results in `SharedState`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void RunPipeline(FillContext context)
|
||||||
|
{
|
||||||
|
// Pre-pipeline setup: shared across strategies
|
||||||
|
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||||
|
context.SharedState["BestRotation"] = bestRotation;
|
||||||
|
|
||||||
|
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
|
||||||
|
context.SharedState["AngleCandidates"] = angles;
|
||||||
|
|
||||||
|
foreach (var strategy in FillStrategyRegistry.Strategies)
|
||||||
|
{
|
||||||
|
// ... strategy loop ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-pipeline: record productive angles for cross-run learning
|
||||||
|
angleBuilder.RecordProductive(context.AngleResults);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `AngleCandidateBuilder` instance stays on `DefaultNestEngine` (not inside a strategy) because it accumulates cross-run learning state via `RecordProductive`. Strategies read the pre-built angle list from `SharedState["AngleCandidates"]`.
|
||||||
|
|
||||||
|
### `FillStrategyRegistry`
|
||||||
|
|
||||||
|
Discovers strategies via reflection, similar to `NestEngineRegistry.LoadPlugins`. Stores strategy **instances** (not factories) because strategies are stateless:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static class FillStrategyRegistry
|
||||||
|
{
|
||||||
|
private static readonly List<IFillStrategy> strategies = new();
|
||||||
|
|
||||||
|
static FillStrategyRegistry()
|
||||||
|
{
|
||||||
|
LoadFrom(typeof(FillStrategyRegistry).Assembly);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<IFillStrategy> sorted;
|
||||||
|
|
||||||
|
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||||
|
sorted ??= strategies.OrderBy(s => s.Order).ToList();
|
||||||
|
|
||||||
|
public static void LoadFrom(Assembly assembly)
|
||||||
|
{
|
||||||
|
/* scan for IFillStrategy implementations */
|
||||||
|
sorted = null; // invalidate cache
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void LoadPlugins(string directory)
|
||||||
|
{
|
||||||
|
/* load DLLs and scan each */
|
||||||
|
sorted = null; // invalidate cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Strategy plugins use a `Strategies/` directory (separate from the `Engines/` directory used by `NestEngineRegistry`). Note: plugin strategies cannot use `internal` types like `BinConverter` from `OpenNest.Engine`. If a plugin needs rectangle packing, `BinConverter` would need to be made `public` — defer this until a plugin actually needs it.
|
||||||
|
|
||||||
|
### Built-in Strategy Order
|
||||||
|
|
||||||
|
| Strategy | Order | Notes |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| `PairsFillStrategy` | 100 | Populates `SharedState["BestFits"]` for Extents |
|
||||||
|
| `RectBestFitStrategy` | 200 | |
|
||||||
|
| `ExtentsFillStrategy` | 300 | Reads `SharedState["BestFits"]` from Pairs |
|
||||||
|
| `LinearFillStrategy` | 400 | Expensive, rarely wins, runs last |
|
||||||
|
|
||||||
|
Gaps of 100 allow plugins to slot in between (e.g., Order 150 runs after Pairs, before RectBestFit).
|
||||||
|
|
||||||
|
### Strategy Implementations
|
||||||
|
|
||||||
|
Each strategy is a thin stateless adapter around the existing filler class. Strategies construct filler instances using `context.Plate` properties:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class PairsFillStrategy : IFillStrategy
|
||||||
|
{
|
||||||
|
public string Name => "Pairs";
|
||||||
|
public NestPhase Phase => NestPhase.Pairs;
|
||||||
|
public int Order => 100;
|
||||||
|
|
||||||
|
public List<Part> Fill(FillContext context)
|
||||||
|
{
|
||||||
|
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing);
|
||||||
|
var result = filler.Fill(context.Item, context.WorkArea,
|
||||||
|
context.PlateNumber, context.Token, context.Progress);
|
||||||
|
|
||||||
|
// Share the BestFitCache for Extents to use later.
|
||||||
|
// This is a cache hit (PairFiller already called GetOrCompute internally),
|
||||||
|
// so it's a dictionary lookup, not a recomputation.
|
||||||
|
var bestFits = BestFitCache.GetOrCompute(
|
||||||
|
context.Item.Drawing, context.Plate.Size.Length,
|
||||||
|
context.Plate.Size.Width, context.Plate.PartSpacing);
|
||||||
|
context.SharedState["BestFits"] = bestFits;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Summary of all four:
|
||||||
|
|
||||||
|
- **`PairsFillStrategy`** — constructs `PairFiller(context.Plate.Size, context.Plate.PartSpacing)`, stores `BestFitCache` in `SharedState`
|
||||||
|
- **`RectBestFitStrategy`** — uses `BinConverter.ToItem(item, partSpacing)` and `BinConverter.CreateBin(workArea, partSpacing)` to delegate to `FillBestFit`
|
||||||
|
- **`ExtentsFillStrategy`** — constructs `FillExtents(context.WorkArea, context.Plate.PartSpacing)`, reads `SharedState["BestRotation"]` for angles, reads `SharedState["BestFits"]` from Pairs
|
||||||
|
- **`LinearFillStrategy`** — constructs `FillLinear(context.WorkArea, context.Plate.PartSpacing)`, reads `SharedState["AngleCandidates"]` for angle list. Internally iterates all angle candidates, tracks its own best, writes per-angle `AngleResults` to context, and calls `ReportProgress` for per-angle updates (preserving the existing UX). Returns only its single best result.
|
||||||
|
|
||||||
|
The underlying classes (`PairFiller`, `FillLinear`, `FillExtents`, `FillBestFit`) are unchanged.
|
||||||
|
|
||||||
|
### Changes to `DefaultNestEngine`
|
||||||
|
|
||||||
|
`FindBestFill` is replaced by `RunPipeline`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void RunPipeline(FillContext context)
|
||||||
|
{
|
||||||
|
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||||
|
context.SharedState["BestRotation"] = bestRotation;
|
||||||
|
|
||||||
|
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
|
||||||
|
context.SharedState["AngleCandidates"] = angles;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var strategy in FillStrategyRegistry.Strategies)
|
||||||
|
{
|
||||||
|
context.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var result = strategy.Fill(context);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
context.PhaseResults.Add(new PhaseResult(
|
||||||
|
strategy.Phase, result?.Count ?? 0, sw.ElapsedMilliseconds));
|
||||||
|
|
||||||
|
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
|
||||||
|
{
|
||||||
|
context.CurrentBest = result;
|
||||||
|
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||||
|
context.WinnerPhase = strategy.Phase;
|
||||||
|
ReportProgress(context.Progress, strategy.Phase, PlateNumber,
|
||||||
|
result, context.WorkArea, BuildProgressSummary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Graceful degradation: return whatever best has been accumulated so far.
|
||||||
|
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
|
||||||
|
}
|
||||||
|
|
||||||
|
angleBuilder.RecordProductive(context.AngleResults);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After `RunPipeline`, the engine copies `context.PhaseResults` and `context.AngleResults` back to the `NestEngineBase` properties so existing UI and test consumers continue to work:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
PhaseResults.AddRange(context.PhaseResults);
|
||||||
|
AngleResults.AddRange(context.AngleResults);
|
||||||
|
WinnerPhase = context.WinnerPhase;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Removed from `DefaultNestEngine`:**
|
||||||
|
- `FindBestFill` method (replaced by `RunPipeline`)
|
||||||
|
- `FillRectangleBestFit` method (moves into `RectBestFitStrategy`)
|
||||||
|
- `QuickFillCount` method (dead code — has zero callers, delete it)
|
||||||
|
|
||||||
|
**Stays on `DefaultNestEngine`:**
|
||||||
|
- `AngleCandidateBuilder` field — owns cross-run learning state, used in pipeline setup/teardown
|
||||||
|
- `ForceFullAngleSweep` property — forwards to `angleBuilder.ForceFullSweep`, keeps existing public API for `BruteForceRunner` and tests
|
||||||
|
- `Fill(List<Part> groupParts, ...)` overload — out of scope (see Scope section)
|
||||||
|
- `PackArea` — out of scope
|
||||||
|
|
||||||
|
**Static helpers `BuildRotatedPattern` and `FillPattern`** move to `Strategies/FillHelpers.cs`.
|
||||||
|
|
||||||
|
### File Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
OpenNest.Engine/
|
||||||
|
Strategies/
|
||||||
|
IFillStrategy.cs
|
||||||
|
FillContext.cs
|
||||||
|
FillStrategyRegistry.cs
|
||||||
|
FillHelpers.cs
|
||||||
|
PairsFillStrategy.cs
|
||||||
|
LinearFillStrategy.cs
|
||||||
|
RectBestFitStrategy.cs
|
||||||
|
ExtentsFillStrategy.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
### What Doesn't Change
|
||||||
|
|
||||||
|
- `PairFiller.cs`, `FillLinear.cs`, `FillExtents.cs`, `RectanglePacking/FillBestFit.cs` — underlying implementations
|
||||||
|
- `FillScore.cs`, `NestProgress.cs`, `Compactor.cs` — shared infrastructure
|
||||||
|
- `NestEngineBase.cs` — base class
|
||||||
|
- `NestEngineRegistry.cs` — engine-level registry (separate concern)
|
||||||
|
- `StripNestEngine.cs` — delegates to `DefaultNestEngine` internally
|
||||||
|
|
||||||
|
### Minor Changes to `NestPhase`
|
||||||
|
|
||||||
|
Add `Custom` to the `NestPhase` enum for plugin strategies that don't map to a built-in phase:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum NestPhase
|
||||||
|
{
|
||||||
|
Linear,
|
||||||
|
RectBestFit,
|
||||||
|
Pairs,
|
||||||
|
Nfp,
|
||||||
|
Extents,
|
||||||
|
Custom
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Existing `EngineRefactorSmokeTests` serve as the regression gate — they must pass unchanged after refactoring.
|
||||||
|
- `BruteForceRunner` continues to access `ForceFullAngleSweep` via the forwarding property on `DefaultNestEngine`.
|
||||||
|
- Individual strategy adapters do not need their own unit tests initially — the existing smoke tests cover the end-to-end pipeline. Strategy-level tests can be added as the strategy count grows.
|
||||||
Reference in New Issue
Block a user