Compare commits

...

5 Commits

Author SHA1 Message Date
aj 9cba3a6cd7 fix: plate optimizer skips oversized items instead of rejecting all plate options
When an item was too large for every plate option, its dimensions dominated
the global min-dimension filter, causing all candidate plates to be rejected.
This made auto-nesting exit immediately with no results even when the other
items could fit. Oversized items are now excluded from the filter so the
remaining items nest normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:41:39 -04:00
aj e93523d7a2 perf: optimize best fit computation and plate optimizer
- Try all valid best fit pairs instead of only the first when qty=2,
  picking the best via IsBetterFill comparer (fixes suboptimal plate
  selection during auto-nesting)
- Pre-compute best fits across all plate sizes once via
  BestFitCache.ComputeForSizes instead of per-size GPU evaluation
- Early exit plate optimizer when all items fit (salvage < 100%)
- Trim slide offset sweep range to 50% overlap to reduce candidates
- Use actual geometry (ray-arc/ray-circle intersection) instead of
  tessellated polygons for slide distance computation — eliminates
  the massive line count from circle/arc tessellation
- Add RayArcDistance and RayCircleDistance to SpatialQuery
- Add PartGeometry.GetOffsetPerimeterEntities for non-tessellated
  perimeter extraction
- Disable GPU slide computer (slower than CPU currently)
- Remove dead SelectBestFitPair virtual method and overrides

Reduces best fit computation from 7+ minutes to ~4 seconds for a
73x25" part with 30+ holes on a 48x96 plate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:21:44 -04:00
aj 3bdbf21881 fix: plate optimizer tiebreak prefers highest utilization over smallest area
When plate costs are equal (e.g. all zero), the optimizer now picks the
plate size with the tightest density instead of the smallest plate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:19:12 -04:00
aj a8e42fb4b5 feat: use nest template for BOM import spacing defaults, editable per group
BOM import now loads the nest template to populate plate size, part
spacing, edge spacing, and quadrant instead of hard-coding defaults.
Spacing columns are shown per material+thickness group on the Groups
tab so each combo can be adjusted independently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:10:06 -04:00
aj ea3c6afbdd fix: re-add drawings to list when parts are deleted with hide-depleted active
The timer-based list update only removed depleted drawings but never
added them back when they became un-depleted (e.g., after deleting a
part from the plate).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:43:01 -04:00
16 changed files with 564 additions and 172 deletions
+89
View File
@@ -104,6 +104,95 @@ namespace OpenNest.Geometry
return double.MaxValue;
}
/// <summary>
/// Computes the distance from a point along a direction to an arc.
/// Solves ray-circle intersection, then constrains hits to the arc's
/// angular span. Returns double.MaxValue if no hit.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static double RayArcDistance(
double vx, double vy,
double cx, double cy, double r,
double startAngle, double endAngle, bool reversed,
double dirX, double dirY)
{
// Ray: P = (vx,vy) + t*(dirX,dirY)
// Circle: (x-cx)^2 + (y-cy)^2 = r^2
var ox = vx - cx;
var oy = vy - cy;
// a = dirX^2 + dirY^2 = 1 for unit direction, but handle general case
var a = dirX * dirX + dirY * dirY;
var b = 2.0 * (ox * dirX + oy * dirY);
var c = ox * ox + oy * oy - r * r;
var discriminant = b * b - 4.0 * a * c;
if (discriminant < 0)
return double.MaxValue;
var sqrtD = System.Math.Sqrt(discriminant);
var inv2a = 1.0 / (2.0 * a);
var t1 = (-b - sqrtD) * inv2a;
var t2 = (-b + sqrtD) * inv2a;
var best = double.MaxValue;
if (t1 > -Tolerance.Epsilon)
{
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
vy + t1 * dirY - cy, vx + t1 * dirX - cx));
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t1 > Tolerance.Epsilon ? t1 : 0;
}
if (t2 > -Tolerance.Epsilon && t2 < best)
{
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
vy + t2 * dirY - cy, vx + t2 * dirX - cx));
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
best = t2 > Tolerance.Epsilon ? t2 : 0;
}
return best;
}
/// <summary>
/// Computes the distance from a point along a direction to a full circle.
/// Returns double.MaxValue if no hit.
/// </summary>
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public static double RayCircleDistance(
double vx, double vy,
double cx, double cy, double r,
double dirX, double dirY)
{
var ox = vx - cx;
var oy = vy - cy;
var a = dirX * dirX + dirY * dirY;
var b = 2.0 * (ox * dirX + oy * dirY);
var c = ox * ox + oy * oy - r * r;
var discriminant = b * b - 4.0 * a * c;
if (discriminant < 0)
return double.MaxValue;
var sqrtD = System.Math.Sqrt(discriminant);
var t = (-b - sqrtD) / (2.0 * a);
if (t > Tolerance.Epsilon) return t;
if (t >= -Tolerance.Epsilon) return 0;
// First root is behind us, try the second
t = (-b + sqrtD) / (2.0 * a);
if (t > Tolerance.Epsilon) return t;
if (t >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
/// <summary>
/// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines.
+30 -4
View File
@@ -39,7 +39,30 @@ namespace OpenNest
return lines;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
/// <summary>
/// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied,
/// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs.
/// </summary>
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
if (offsetShape == null)
return new List<Entity>();
// Offset the shape's entities to the part's location.
// OffsetOutward creates a new Shape, so mutating is safe.
foreach (var entity in offsetShape.Entities)
entity.Offset(part.Location);
return offsetShape.Entities;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001,
bool perimeterOnly = false)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
@@ -50,9 +73,12 @@ namespace OpenNest
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location);
foreach (var cutout in profile.Cutouts)
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location);
if (!perimeterOnly)
{
foreach (var cutout in profile.Cutouts)
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location);
}
return lines;
}
+3
View File
@@ -4,6 +4,7 @@ using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
@@ -49,6 +50,8 @@ namespace OpenNest.Engine.BestFit
var allCandidates = candidateBags.SelectMany(c => c).ToList();
Debug.WriteLine($"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates");
var results = _evaluator.EvaluateAll(allCandidates);
_filter.Apply(results);
+173 -8
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
@@ -17,7 +18,6 @@ namespace OpenNest.Engine.BestFit
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
@@ -43,7 +43,6 @@ namespace OpenNest.Engine.BestFit
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary edges
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
@@ -66,7 +65,6 @@ namespace OpenNest.Engine.BestFit
}
}
// Case 2: Facing stationary vertices → moving edges (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
@@ -95,6 +93,178 @@ namespace OpenNest.Engine.BestFit
return results;
}
public double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets)
{
var count = offsets.Length;
var results = new double[count];
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
foreach (var offset in offsets)
{
var key = (offset.DirX, offset.DirY);
if (vertexCache.ContainsKey(key))
continue;
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
vertexCache[key] = (leading, facing);
}
System.Threading.Tasks.Parallel.For(0, count, i =>
{
var offset = offsets[i];
var dirX = offset.DirX;
var dirY = offset.DirY;
var oppX = -dirX;
var oppY = -dirY;
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
var minDist = double.MaxValue;
// Case 1: Leading moving vertices → stationary entities
for (var v = 0; v < leadingMoving.Length; v++)
{
var vx = leadingMoving[v].X + offset.Dx;
var vy = leadingMoving[v].Y + offset.Dy;
for (var j = 0; j < stationaryEntities.Count; j++)
{
var d = RayEntityDistance(vx, vy, stationaryEntities[j], 0, 0, dirX, dirY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
// Case 2: Facing stationary vertices → moving entities (opposite direction)
for (var v = 0; v < facingStationary.Length; v++)
{
var svx = facingStationary[v].X;
var svy = facingStationary[v].Y;
for (var j = 0; j < movingEntities.Count; j++)
{
var d = RayEntityDistance(svx, svy, movingEntities[j], offset.Dx, offset.Dy, oppX, oppY);
if (d < minDist)
{
minDist = d;
if (d <= 0) { results[i] = 0; return; }
}
}
}
results[i] = minDist;
});
return results;
}
private static double RayEntityDistance(
double vx, double vy, Entity entity,
double entityOffsetX, double entityOffsetY,
double dirX, double dirY)
{
if (entity is Line line)
{
return SpatialQuery.RayEdgeDistance(
vx, vy,
line.StartPoint.X + entityOffsetX, line.StartPoint.Y + entityOffsetY,
line.EndPoint.X + entityOffsetX, line.EndPoint.Y + entityOffsetY,
dirX, dirY);
}
if (entity is Arc arc)
{
return SpatialQuery.RayArcDistance(
vx, vy,
arc.Center.X + entityOffsetX, arc.Center.Y + entityOffsetY,
arc.Radius,
arc.StartAngle, arc.EndAngle, arc.IsReversed,
dirX, dirY);
}
if (entity is Circle circle)
{
return SpatialQuery.RayCircleDistance(
vx, vy,
circle.Center.X + entityOffsetX, circle.Center.Y + entityOffsetY,
circle.Radius,
dirX, dirY);
}
return double.MaxValue;
}
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < entities.Count; i++)
{
var entity = entities[i];
if (entity is Line line)
{
vertices.Add(line.StartPoint);
vertices.Add(line.EndPoint);
}
else if (entity is Arc arc)
{
vertices.Add(arc.StartPoint());
vertices.Add(arc.EndPoint());
AddArcExtremes(vertices, arc);
}
else if (entity is Circle circle)
{
// Four cardinal points
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
}
}
return vertices.ToArray();
}
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
var reversed = arc.IsReversed;
if (reversed)
Generic.Swap(ref a1, ref a2);
// Right (0°)
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
// Top (90°)
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
// Left (180°)
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
// Bottom (270°)
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
private static Vector[] ExtractUniqueVertices(List<Line> lines)
{
var vertices = new HashSet<Vector>();
@@ -106,11 +276,6 @@ namespace OpenNest.Engine.BestFit
return vertices.ToArray();
}
/// <summary>
/// Filters vertices by their projection onto the push direction.
/// keepHigh=true returns the leading half (front face, closest to target).
/// keepHigh=false returns the facing half (side facing the approaching part).
/// </summary>
private static Vector[] FilterVerticesByProjection(
Vector[] vertices, double dirX, double dirY, bool keepHigh)
{
@@ -36,6 +36,16 @@ namespace OpenNest.Engine.BestFit
flatOffsets, count, directions);
}
public double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets)
{
// GPU path doesn't support native entities yet — fall back to CPU.
var cpu = new CpuDistanceComputer();
return cpu.ComputeDistances(stationaryEntities, movingEntities, offsets);
}
/// <summary>
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
/// Left=0, Down=1, Right=2, Up=3.
@@ -9,5 +9,10 @@ namespace OpenNest.Engine.BestFit
List<Line> stationaryLines,
List<Line> movingTemplateLines,
SlideOffset[] offsets);
double[] ComputeDistances(
List<Entity> stationaryEntities,
List<Entity> movingEntities,
SlideOffset[] offsets);
}
}
@@ -36,8 +36,8 @@ namespace OpenNest.Engine.BestFit
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
var halfSpacing = spacing / 2;
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing);
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing);
var part1Entities = PartGeometry.GetOffsetPerimeterEntities(part1, halfSpacing);
var part2Entities = PartGeometry.GetOffsetPerimeterEntities(part2Template, halfSpacing);
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
@@ -48,7 +48,7 @@ namespace OpenNest.Engine.BestFit
return candidates;
var distances = _distanceComputer.ComputeDistances(
part1Lines, part2TemplateLines, offsets);
part1Entities, part2Entities, offsets);
var testNumber = 0;
@@ -90,15 +90,18 @@ namespace OpenNest.Engine.BestFit
if (isHorizontalPush)
{
// Perpendicular sweep along Y → Width; push extent along X → Length
perpMin = -(bbox2.Width + spacing);
perpMax = bbox1.Width + bbox2.Width + spacing;
// Trim to offsets where the parts overlap by at least 50%.
var halfOverlap = bbox2.Width * 0.5;
perpMin = -(halfOverlap - spacing);
perpMax = bbox1.Width + halfOverlap + spacing;
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
}
else
{
// Perpendicular sweep along X → Length; push extent along Y → Width
perpMin = -(bbox2.Length + spacing);
perpMax = bbox1.Length + bbox2.Length + spacing;
var halfOverlap = bbox2.Length * 0.5;
perpMin = -(halfOverlap - spacing);
perpMax = bbox1.Length + halfOverlap + spacing;
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
}
+33 -15
View File
@@ -139,24 +139,42 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
var best = SelectBestFitPair(bestFits);
if (best == null)
return null;
List<Part> bestPlacement = null;
// BuildParts produces landscape orientation (Width >= Height).
// Try both landscape and portrait (90° rotated) and let the
// engine's comparer pick the better orientation.
var landscape = best.BuildParts(drawing);
var portrait = RotatePair90(landscape);
foreach (var fit in bestFits)
{
if (!fit.Keep)
continue;
var lFits = TryOffsetToWorkArea(landscape, workArea);
var pFits = TryOffsetToWorkArea(portrait, workArea);
// Skip pairs that can't possibly fit the work area in either orientation.
if (fit.ShortestSide > System.Math.Min(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue;
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue;
if (!lFits && !pFits)
return null;
if (lFits && pFits)
return IsBetterFill(portrait, landscape, workArea) ? portrait : landscape;
return lFits ? landscape : portrait;
var landscape = fit.BuildParts(drawing);
var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea);
var pFits = TryOffsetToWorkArea(portrait, workArea);
// Pick the better orientation for this pair.
List<Part> candidate = null;
if (lFits && pFits)
candidate = IsBetterFill(portrait, landscape, workArea) ? portrait : landscape;
else if (lFits)
candidate = landscape;
else if (pFits)
candidate = portrait;
if (candidate == null)
continue;
if (bestPlacement == null || IsBetterFill(candidate, bestPlacement, workArea))
bestPlacement = candidate;
}
return bestPlacement;
}
private static List<Part> RotatePair90(List<Part> parts)
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -27,20 +26,6 @@ namespace OpenNest
public override ShrinkAxis TrimAxis => ShrinkAxis.Length;
protected override BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
BestFitResult best = null;
foreach (var r in results)
{
if (!r.Keep) continue;
if (best == null || r.BoundingHeight < best.BoundingHeight)
best = r;
}
return best;
}
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
+37 -31
View File
@@ -56,11 +56,6 @@ namespace OpenNest
protected FillPolicy BuildPolicy() => new FillPolicy(Comparer, PreferredDirection);
protected virtual BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
return results.FirstOrDefault(r => r.Keep);
}
// --- Virtual methods (side-effect-free, return parts) ---
public virtual List<Part> Fill(NestItem item, Box workArea,
@@ -338,45 +333,56 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
var bestFit = SelectBestFitPair(bestFits);
if (bestFit == null) continue;
var parts = bestFit.BuildParts(item.Drawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width;
var pairL = pairBbox.Length;
var minDim = System.Math.Min(pairW, pairL);
List<Part> bestPlacement = null;
Box bestTarget = null;
var remnants = finder.FindRemnants(minDim);
Box target = null;
foreach (var r in remnants)
foreach (var fit in bestFits)
{
if (pairW <= r.Width + Tolerance.Epsilon &&
pairL <= r.Length + Tolerance.Epsilon)
if (!fit.Keep)
continue;
var parts = fit.BuildParts(item.Drawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width;
var pairL = pairBbox.Length;
var minDim = System.Math.Min(pairW, pairL);
var remnants = finder.FindRemnants(minDim);
foreach (var r in remnants)
{
target = r;
break;
if (pairW <= r.Width + Tolerance.Epsilon &&
pairL <= r.Length + Tolerance.Epsilon)
{
var offset = r.Location - pairBbox.Location;
foreach (var p in parts)
{
p.Offset(offset);
p.UpdateBounds();
}
if (bestPlacement == null || IsBetterFill(parts, bestPlacement, r))
{
bestPlacement = parts;
bestTarget = r;
}
break;
}
}
}
if (target == null) continue;
if (bestPlacement == null) continue;
var offset = target.Location - pairBbox.Location;
foreach (var p in parts)
{
p.Offset(offset);
p.UpdateBounds();
}
result.AddRange(parts);
result.AddRange(bestPlacement);
item.Quantity = 0;
var envelope = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var envelope = ((IEnumerable<IBoundable>)bestPlacement).GetBoundingBox();
finder.AddObstacle(envelope.Offset(Plate.PartSpacing));
Debug.WriteLine($"[Nest] Placed best-fit pair for {item.Drawing.Name} " +
$"at ({target.X:F1},{target.Y:F1}), size {pairW:F1}x{pairL:F1}");
$"at ({bestTarget.X:F1},{bestTarget.Y:F1}), " +
$"size {envelope.Width:F1}x{envelope.Length:F1}");
}
return result;
+30 -6
View File
@@ -1,4 +1,5 @@
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
@@ -22,7 +23,8 @@ namespace OpenNest
if (items == null || items.Count == 0 || plateOptions == null || plateOptions.Count == 0)
return null;
// Find the minimum dimension needed to fit the largest part.
// Find the minimum dimension needed to fit the largest part,
// skipping items that are too large for every plate option.
var minPartWidth = 0.0;
var minPartLength = 0.0;
foreach (var item in items)
@@ -31,6 +33,14 @@ namespace OpenNest
var bb = item.Drawing.Program.BoundingBox();
var shortSide = System.Math.Min(bb.Width, bb.Length);
var longSide = System.Math.Max(bb.Width, bb.Length);
if (!plateOptions.Any(o => FitsPart(o, shortSide, longSide, templatePlate.EdgeSpacing)))
{
Debug.WriteLine($"[PlateOptimizer] Skipping oversized item '{item.Drawing.Name}' " +
$"({shortSide:F1}x{longSide:F1}) — does not fit any plate option");
continue;
}
if (shortSide > minPartWidth) minPartWidth = shortSide;
if (longSide > minPartLength) minPartLength = longSide;
}
@@ -44,6 +54,19 @@ namespace OpenNest
if (candidates.Count == 0)
return null;
// Pre-compute best fits for all candidate plate sizes at once.
// This runs the expensive GPU evaluation once on the largest plate
// and filters the results for each smaller size.
var plateSizes = candidates
.Select(o => (Width: o.Length, Height: o.Width))
.ToList();
foreach (var item in items)
{
if (item.Quantity <= 0) continue;
BestFitCache.ComputeForSizes(item.Drawing, templatePlate.PartSpacing, plateSizes);
}
PlateOptimizerResult best = null;
foreach (var option in candidates)
@@ -58,9 +81,10 @@ namespace OpenNest
if (IsBetter(result, best))
best = result;
// Early exit: when salvage is zero, cheapest plate that fits everything wins.
// With salvage > 0, larger plates may have lower net cost, so keep searching.
if (salvageRate <= 0)
// Early exit: when all items fit, larger plates can only have
// worse utilization and higher cost. With salvage < 100%, the
// remnant credit never offsets the extra plate cost, so skip.
if (salvageRate < 1.0)
{
var allPlaced = items.All(i => i.Quantity <= 0 ||
result.Parts.Count(p => p.BaseDrawing.Name == i.Drawing.Name) >= i.Quantity);
@@ -158,8 +182,8 @@ namespace OpenNest
if (!candidate.NetCost.IsEqualTo(current.NetCost))
return candidate.NetCost < current.NetCost;
// 3. Smaller plate area as tiebreak.
return candidate.ChosenSize.Area < current.ChosenSize.Area;
// 3. Higher utilization (tighter density) as tiebreak.
return candidate.Utilization > current.Utilization;
}
}
}
-15
View File
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -25,20 +24,6 @@ namespace OpenNest
public override NestDirection? PreferredDirection => NestDirection.Horizontal;
protected override BestFitResult SelectBestFitPair(List<BestFitResult> results)
{
BestFitResult best = null;
foreach (var r in results)
{
if (!r.Keep) continue;
if (best == null || r.BoundingHeight < best.BoundingHeight)
best = r;
}
return best;
}
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
{
var baseAngles = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
+42 -42
View File
@@ -50,9 +50,9 @@ namespace OpenNest.Forms
((System.ComponentModel.ISupportInitialize)dgvGroups).BeginInit();
pnlBottom.SuspendLayout();
SuspendLayout();
//
//
// grpInput
//
//
grpInput.Controls.Add(tbl);
grpInput.Dock = System.Windows.Forms.DockStyle.Top;
grpInput.Location = new System.Drawing.Point(0, 0);
@@ -62,9 +62,9 @@ namespace OpenNest.Forms
grpInput.TabIndex = 0;
grpInput.TabStop = false;
grpInput.Text = "Input";
//
//
// tbl
//
//
tbl.ColumnCount = 3;
tbl.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
tbl.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
@@ -92,9 +92,9 @@ namespace OpenNest.Forms
tbl.RowStyles.Add(new System.Windows.Forms.RowStyle());
tbl.Size = new System.Drawing.Size(792, 172);
tbl.TabIndex = 0;
//
//
// lblJobName
//
//
lblJobName.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblJobName.AutoSize = true;
lblJobName.Location = new System.Drawing.Point(6, 13);
@@ -103,9 +103,9 @@ namespace OpenNest.Forms
lblJobName.Size = new System.Drawing.Size(63, 15);
lblJobName.TabIndex = 0;
lblJobName.Text = "Job Name:";
//
//
// txtJobName
//
//
tbl.SetColumnSpan(txtJobName, 2);
txtJobName.Dock = System.Windows.Forms.DockStyle.Fill;
txtJobName.Location = new System.Drawing.Point(79, 9);
@@ -113,9 +113,9 @@ namespace OpenNest.Forms
txtJobName.Name = "txtJobName";
txtJobName.Size = new System.Drawing.Size(707, 23);
txtJobName.TabIndex = 1;
//
//
// lblBomFile
//
//
lblBomFile.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblBomFile.AutoSize = true;
lblBomFile.Location = new System.Drawing.Point(6, 45);
@@ -124,9 +124,9 @@ namespace OpenNest.Forms
lblBomFile.Size = new System.Drawing.Size(58, 15);
lblBomFile.TabIndex = 2;
lblBomFile.Text = "BOM File:";
//
//
// txtBomFile
//
//
txtBomFile.Dock = System.Windows.Forms.DockStyle.Fill;
txtBomFile.Location = new System.Drawing.Point(79, 41);
txtBomFile.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
@@ -134,9 +134,9 @@ namespace OpenNest.Forms
txtBomFile.ReadOnly = true;
txtBomFile.Size = new System.Drawing.Size(669, 23);
txtBomFile.TabIndex = 3;
//
//
// btnBrowseBom
//
//
btnBrowseBom.Location = new System.Drawing.Point(751, 40);
btnBrowseBom.Margin = new System.Windows.Forms.Padding(0, 5, 3, 3);
btnBrowseBom.Name = "btnBrowseBom";
@@ -144,9 +144,9 @@ namespace OpenNest.Forms
btnBrowseBom.TabIndex = 4;
btnBrowseBom.Text = "...";
btnBrowseBom.Click += BrowseBom_Click;
//
//
// lblDxfFolder
//
//
lblDxfFolder.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblDxfFolder.AutoSize = true;
lblDxfFolder.Location = new System.Drawing.Point(6, 78);
@@ -155,9 +155,9 @@ namespace OpenNest.Forms
lblDxfFolder.Size = new System.Drawing.Size(67, 15);
lblDxfFolder.TabIndex = 5;
lblDxfFolder.Text = "DXF Folder:";
//
//
// txtDxfFolder
//
//
txtDxfFolder.Dock = System.Windows.Forms.DockStyle.Fill;
txtDxfFolder.Location = new System.Drawing.Point(79, 74);
txtDxfFolder.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
@@ -165,9 +165,9 @@ namespace OpenNest.Forms
txtDxfFolder.ReadOnly = true;
txtDxfFolder.Size = new System.Drawing.Size(669, 23);
txtDxfFolder.TabIndex = 6;
//
//
// btnBrowseDxf
//
//
btnBrowseDxf.Location = new System.Drawing.Point(751, 73);
btnBrowseDxf.Margin = new System.Windows.Forms.Padding(0, 5, 3, 3);
btnBrowseDxf.Name = "btnBrowseDxf";
@@ -175,9 +175,9 @@ namespace OpenNest.Forms
btnBrowseDxf.TabIndex = 7;
btnBrowseDxf.Text = "...";
btnBrowseDxf.Click += BrowseDxf_Click;
//
//
// lblPlateSize
//
//
lblPlateSize.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblPlateSize.AutoSize = true;
lblPlateSize.Location = new System.Drawing.Point(6, 112);
@@ -186,9 +186,9 @@ namespace OpenNest.Forms
lblPlateSize.Size = new System.Drawing.Size(59, 15);
lblPlateSize.TabIndex = 8;
lblPlateSize.Text = "Plate Size:";
//
//
// platePanel
//
//
platePanel.AutoSize = true;
platePanel.Controls.Add(txtPlateWidth);
platePanel.Controls.Add(lblPlateX);
@@ -199,17 +199,17 @@ namespace OpenNest.Forms
platePanel.Size = new System.Drawing.Size(156, 29);
platePanel.TabIndex = 9;
platePanel.WrapContents = false;
//
//
// txtPlateWidth
//
//
txtPlateWidth.Location = new System.Drawing.Point(3, 3);
txtPlateWidth.Name = "txtPlateWidth";
txtPlateWidth.Size = new System.Drawing.Size(60, 23);
txtPlateWidth.TabIndex = 0;
txtPlateWidth.Text = "60";
//
//
// lblPlateX
//
//
lblPlateX.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblPlateX.AutoSize = true;
lblPlateX.Location = new System.Drawing.Point(69, 7);
@@ -217,17 +217,17 @@ namespace OpenNest.Forms
lblPlateX.Size = new System.Drawing.Size(18, 15);
lblPlateX.TabIndex = 1;
lblPlateX.Text = " x ";
//
//
// txtPlateLength
//
//
txtPlateLength.Location = new System.Drawing.Point(93, 3);
txtPlateLength.Name = "txtPlateLength";
txtPlateLength.Size = new System.Drawing.Size(60, 23);
txtPlateLength.TabIndex = 2;
txtPlateLength.Text = "120";
//
//
// btnAnalyze
//
//
btnAnalyze.Anchor = System.Windows.Forms.AnchorStyles.Right;
tbl.SetColumnSpan(btnAnalyze, 2);
btnAnalyze.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
@@ -291,9 +291,9 @@ namespace OpenNest.Forms
dgvGroups.RowHeadersVisible = false;
dgvGroups.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
dgvGroups.TabIndex = 0;
//
//
// pnlBottom
//
//
pnlBottom.Controls.Add(lblSummary);
pnlBottom.Controls.Add(btnCreateNests);
pnlBottom.Controls.Add(btnClose);
@@ -303,9 +303,9 @@ namespace OpenNest.Forms
pnlBottom.Padding = new System.Windows.Forms.Padding(10);
pnlBottom.Size = new System.Drawing.Size(804, 50);
pnlBottom.TabIndex = 2;
//
//
// lblSummary
//
//
lblSummary.AutoSize = true;
lblSummary.Dock = System.Windows.Forms.DockStyle.Left;
lblSummary.ForeColor = System.Drawing.Color.Gray;
@@ -314,9 +314,9 @@ namespace OpenNest.Forms
lblSummary.Size = new System.Drawing.Size(0, 15);
lblSummary.TabIndex = 0;
lblSummary.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
//
// btnCreateNests
//
//
btnCreateNests.Dock = System.Windows.Forms.DockStyle.Right;
btnCreateNests.Enabled = false;
btnCreateNests.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
@@ -327,9 +327,9 @@ namespace OpenNest.Forms
btnCreateNests.TabIndex = 1;
btnCreateNests.Text = "Create Nests";
btnCreateNests.Click += CreateNests_Click;
//
//
// btnClose
//
//
btnClose.DialogResult = System.Windows.Forms.DialogResult.Cancel;
btnClose.Dock = System.Windows.Forms.DockStyle.Right;
btnClose.Location = new System.Drawing.Point(714, 10);
@@ -338,9 +338,9 @@ namespace OpenNest.Forms
btnClose.TabIndex = 2;
btnClose.Text = "Close";
btnClose.Click += BtnClose_Click;
//
//
// BomImportForm
//
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
CancelButton = btnClose;
+91 -27
View File
@@ -16,8 +16,9 @@ namespace OpenNest.Forms
public partial class BomImportForm : Form
{
private List<BomPartRow> _parts;
private Dictionary<string, (double Width, double Length)> _plateSizes;
private Dictionary<string, GroupSettings> _groupSettings;
private bool _suppressRegroup;
private Nest.PlateSettings _templateDefaults;
public Form MdiParentForm { get; set; }
@@ -25,7 +26,38 @@ namespace OpenNest.Forms
{
InitializeComponent();
_parts = new List<BomPartRow>();
_plateSizes = new Dictionary<string, (double, double)>();
_groupSettings = new Dictionary<string, GroupSettings>();
_templateDefaults = LoadTemplateDefaults();
ApplyTemplateDefaults();
}
private Nest.PlateSettings LoadTemplateDefaults()
{
var templatePath = Properties.Settings.Default.NestTemplatePath;
if (File.Exists(templatePath))
{
try
{
var nest = new NestReader(templatePath).Read();
return nest.PlateDefaults;
}
catch { }
}
// Fallback defaults matching CreateDefaultNest
return new Nest.PlateSettings
{
Size = new Geometry.Size(100, 100),
Quadrant = 1,
PartSpacing = 1,
EdgeSpacing = new Spacing(1, 1, 1, 1),
};
}
private void ApplyTemplateDefaults()
{
txtPlateWidth.Text = _templateDefaults.Size.Width.ToString("0.####");
txtPlateLength.Text = _templateDefaults.Size.Length.ToString("0.####");
}
#region File Browsing
@@ -154,7 +186,7 @@ namespace OpenNest.Forms
_parts.Add(row);
}
_plateSizes.Clear();
_groupSettings.Clear();
}
#endregion
@@ -244,11 +276,11 @@ namespace OpenNest.Forms
private void RebuildGroups()
{
// Save existing plate sizes before rebuilding
SavePlateSizes();
// Save existing settings before rebuilding
SaveGroupSettings();
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var w) ? w : 60;
var defaultLength = double.TryParse(txtPlateLength.Text, out var l) ? l : 120;
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var w) ? w : _templateDefaults.Size.Width;
var defaultLength = double.TryParse(txtPlateLength.Text, out var l) ? l : _templateDefaults.Size.Length;
var groups = _parts
.Where(p => p.IsEditable
@@ -270,6 +302,11 @@ namespace OpenNest.Forms
table.Columns.Add("Total Qty", typeof(int));
table.Columns.Add("Plate Width", typeof(double));
table.Columns.Add("Plate Length", typeof(double));
table.Columns.Add("Part Spacing", typeof(double));
table.Columns.Add("Edge Left", typeof(double));
table.Columns.Add("Edge Bottom", typeof(double));
table.Columns.Add("Edge Right", typeof(double));
table.Columns.Add("Edge Top", typeof(double));
foreach (var group in groups)
{
@@ -277,23 +314,27 @@ namespace OpenNest.Forms
var thickness = group.Key.Thickness;
var key = GroupKey(material, thickness);
var plateWidth = _plateSizes.TryGetValue(key, out var size) ? size.Width : defaultWidth;
var plateLength = _plateSizes.TryGetValue(key, out _) ? size.Length : defaultLength;
var existing = _groupSettings.TryGetValue(key, out var gs);
table.Rows.Add(
material,
thickness,
group.Count(),
group.Sum(p => p.Qty ?? 0),
plateWidth,
plateLength
existing ? gs.PlateWidth : defaultWidth,
existing ? gs.PlateLength : defaultLength,
existing ? gs.PartSpacing : _templateDefaults.PartSpacing,
existing ? gs.EdgeLeft : _templateDefaults.EdgeSpacing.Left,
existing ? gs.EdgeBottom : _templateDefaults.EdgeSpacing.Bottom,
existing ? gs.EdgeRight : _templateDefaults.EdgeSpacing.Right,
existing ? gs.EdgeTop : _templateDefaults.EdgeSpacing.Top
);
}
dgvGroups.DataSource = table;
// Material, Thickness, Parts, Total Qty are read-only
if (dgvGroups.Columns.Count >= 6)
if (dgvGroups.Columns.Count > 0)
{
dgvGroups.Columns["Material"].ReadOnly = true;
dgvGroups.Columns["Thickness"].ReadOnly = true;
@@ -304,22 +345,28 @@ namespace OpenNest.Forms
btnCreateNests.Enabled = table.Rows.Count > 0;
}
private void SavePlateSizes()
private void SaveGroupSettings()
{
if (dgvGroups.DataSource is not DataTable table)
return;
_plateSizes.Clear();
_groupSettings.Clear();
foreach (DataRow row in table.Rows)
{
var material = row["Material"]?.ToString() ?? "";
var thickness = row["Thickness"] is double t ? t : 0;
var key = GroupKey(material, thickness);
var width = row["Plate Width"] is double pw ? pw : 60;
var length = row["Plate Length"] is double pl ? pl : 120;
_plateSizes[key] = (width, length);
_groupSettings[key] = new GroupSettings
{
PlateWidth = row["Plate Width"] is double pw ? pw : _templateDefaults.Size.Width,
PlateLength = row["Plate Length"] is double pl ? pl : _templateDefaults.Size.Length,
PartSpacing = row["Part Spacing"] is double ps ? ps : _templateDefaults.PartSpacing,
EdgeLeft = row["Edge Left"] is double el ? el : _templateDefaults.EdgeSpacing.Left,
EdgeBottom = row["Edge Bottom"] is double eb ? eb : _templateDefaults.EdgeSpacing.Bottom,
EdgeRight = row["Edge Right"] is double er ? er : _templateDefaults.EdgeSpacing.Right,
EdgeTop = row["Edge Top"] is double et ? et : _templateDefaults.EdgeSpacing.Top,
};
}
}
@@ -356,11 +403,11 @@ namespace OpenNest.Forms
if (_parts == null || _parts.Count == 0)
return;
// Save latest plate size edits
SavePlateSizes();
// Save latest group edits
SaveGroupSettings();
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var dw) ? dw : 60;
var defaultLength = double.TryParse(txtPlateLength.Text, out var dl) ? dl : 120;
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var dw) ? dw : _templateDefaults.Size.Width;
var defaultLength = double.TryParse(txtPlateLength.Text, out var dl) ? dl : _templateDefaults.Size.Length;
var groups = _parts
.Where(p => p.IsEditable
@@ -391,8 +438,14 @@ namespace OpenNest.Forms
var thickness = group.Key.Thickness;
var key = GroupKey(material, thickness);
var plateWidth = _plateSizes.TryGetValue(key, out var size) ? size.Width : defaultWidth;
var plateLength = _plateSizes.TryGetValue(key, out _) ? size.Length : defaultLength;
var hasSettings = _groupSettings.TryGetValue(key, out var gs);
var plateWidth = hasSettings ? gs.PlateWidth : defaultWidth;
var plateLength = hasSettings ? gs.PlateLength : defaultLength;
var partSpacing = hasSettings ? gs.PartSpacing : _templateDefaults.PartSpacing;
var edgeLeft = hasSettings ? gs.EdgeLeft : _templateDefaults.EdgeSpacing.Left;
var edgeBottom = hasSettings ? gs.EdgeBottom : _templateDefaults.EdgeSpacing.Bottom;
var edgeRight = hasSettings ? gs.EdgeRight : _templateDefaults.EdgeSpacing.Right;
var edgeTop = hasSettings ? gs.EdgeTop : _templateDefaults.EdgeSpacing.Top;
var nestName = $"{jobName} - {thickness:0.###} {material}";
var nest = new Nest(nestName);
@@ -401,9 +454,9 @@ namespace OpenNest.Forms
nest.PlateDefaults.Size = new Geometry.Size(plateWidth, plateLength);
nest.Thickness = thickness;
nest.Material = new Material(material);
nest.PlateDefaults.Quadrant = 1;
nest.PlateDefaults.PartSpacing = 1;
nest.PlateDefaults.EdgeSpacing = new Spacing(1, 1, 1, 1);
nest.PlateDefaults.Quadrant = _templateDefaults.Quadrant;
nest.PlateDefaults.PartSpacing = partSpacing;
nest.PlateDefaults.EdgeSpacing = new Spacing(edgeLeft, edgeBottom, edgeRight, edgeTop);
foreach (var part in group)
{
@@ -486,4 +539,15 @@ namespace OpenNest.Forms
public string Status { get; set; }
public bool IsEditable { get; set; }
}
internal class GroupSettings
{
public double PlateWidth { get; set; }
public double PlateLength { get; set; }
public double PartSpacing { get; set; }
public double EdgeLeft { get; set; }
public double EdgeBottom { get; set; }
public double EdgeRight { get; set; }
public double EdgeTop { get; set; }
}
}
+9
View File
@@ -955,6 +955,15 @@ namespace OpenNest.Forms
drawingListBox1.Items.RemoveAt(i);
}
foreach (var dwg in Nest.Drawings.OrderBy(d => d.Name))
{
if (dwg.Quantity.Required > 0 && dwg.Quantity.Remaining == 0)
continue;
if (!drawingListBox1.Items.Contains(dwg))
drawingListBox1.Items.Add(dwg);
}
drawingListBox1.EndUpdate();
}
+2 -2
View File
@@ -64,8 +64,8 @@ namespace OpenNest.Forms
//if (GpuEvaluatorFactory.GpuAvailable)
// BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);
if (GpuEvaluatorFactory.GpuAvailable)
BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer();
//if (GpuEvaluatorFactory.GpuAvailable)
// BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer();
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
NestEngineRegistry.LoadPlugins(enginesDir);