Compare commits
17 Commits
bc3f1543ee
...
bf104309b4
| Author | SHA1 | Date | |
|---|---|---|---|
| bf104309b4 | |||
| 321c476b8b | |||
| 1db51b1cce | |||
| 10f9b5357c | |||
| a9aaab8337 | |||
| 65ded42120 | |||
| d6ffa77f35 | |||
| 3133228fc9 | |||
| 1440d2a16a | |||
| 152e057a46 | |||
| de70999975 | |||
| 1a9bd795a8 | |||
| 891bb49548 | |||
| 4c1ac418a0 | |||
| 183d169cc1 | |||
| 97dfe27953 | |||
| b509a4139d |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -202,5 +202,9 @@ FakesAssemblies/
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# SQLite databases
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
@@ -10,5 +10,9 @@
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,6 +6,11 @@ using System.Linq;
|
||||
using OpenNest;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using Color = System.Drawing.Color;
|
||||
using OpenNest.Console;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.ML;
|
||||
using OpenNest.Gpu;
|
||||
|
||||
// Parse arguments.
|
||||
var nestFile = (string)null;
|
||||
@@ -21,11 +26,27 @@ var noSave = false;
|
||||
var noLog = false;
|
||||
var keepParts = false;
|
||||
var autoNest = false;
|
||||
var collectDataDir = (string)null;
|
||||
var dbPath = "nesting_training.db";
|
||||
var saveNestsDir = (string)null;
|
||||
var templateFile = (string)null;
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--db" when i + 1 < args.Length:
|
||||
dbPath = args[++i];
|
||||
break;
|
||||
case "--save-nests" when i + 1 < args.Length:
|
||||
saveNestsDir = args[++i];
|
||||
break;
|
||||
case "--template" when i + 1 < args.Length:
|
||||
templateFile = args[++i];
|
||||
break;
|
||||
case "--collect" when i + 1 < args.Length:
|
||||
collectDataDir = args[++i];
|
||||
break;
|
||||
case "--drawing" when i + 1 < args.Length:
|
||||
drawingName = args[++i];
|
||||
break;
|
||||
@@ -75,6 +96,22 @@ for (var i = 0; i < args.Length; i++)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize GPU if available.
|
||||
if (GpuEvaluatorFactory.GpuAvailable)
|
||||
{
|
||||
BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer();
|
||||
Console.WriteLine($"GPU: {GpuEvaluatorFactory.DeviceName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("GPU: not available (using CPU)");
|
||||
}
|
||||
|
||||
if (collectDataDir != null)
|
||||
{
|
||||
return RunDataCollection(collectDataDir, dbPath, saveNestsDir, spacing ?? 0.5, templateFile);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile))
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -225,24 +262,253 @@ if (!noSave)
|
||||
|
||||
return checkOverlaps && overlapCount > 0 ? 1 : 0;
|
||||
|
||||
int RunDataCollection(string dir, string dbPath, string saveDir, double s, string template)
|
||||
{
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Directory not found: {dir}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Load template nest for plate defaults if provided.
|
||||
Nest templateNest = null;
|
||||
if (template != null)
|
||||
{
|
||||
if (!File.Exists(template))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Template not found: {template}");
|
||||
return 1;
|
||||
}
|
||||
templateNest = new NestReader(template).Read();
|
||||
Console.WriteLine($"Using template: {template}");
|
||||
}
|
||||
|
||||
var PartColors = new[]
|
||||
{
|
||||
Color.FromArgb(205, 92, 92),
|
||||
Color.FromArgb(148, 103, 189),
|
||||
Color.FromArgb(75, 180, 175),
|
||||
Color.FromArgb(210, 190, 75),
|
||||
Color.FromArgb(190, 85, 175),
|
||||
Color.FromArgb(185, 115, 85),
|
||||
Color.FromArgb(120, 100, 190),
|
||||
Color.FromArgb(200, 100, 140),
|
||||
Color.FromArgb(80, 175, 155),
|
||||
Color.FromArgb(195, 160, 85),
|
||||
Color.FromArgb(175, 95, 160),
|
||||
Color.FromArgb(215, 130, 130),
|
||||
};
|
||||
|
||||
var sheetSuite = new[]
|
||||
{
|
||||
new Size(96, 48), new Size(120, 48), new Size(144, 48),
|
||||
new Size(96, 60), new Size(120, 60), new Size(144, 60),
|
||||
new Size(96, 72), new Size(120, 72), new Size(144, 72),
|
||||
new Size(48, 24), new Size(120, 10)
|
||||
};
|
||||
|
||||
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories);
|
||||
Console.WriteLine($"Found {dxfFiles.Length} DXF files");
|
||||
Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}");
|
||||
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
|
||||
Console.WriteLine($"Spacing: {s:F2}");
|
||||
if (saveDir != null) Console.WriteLine($"Saving nests to: {saveDir}");
|
||||
Console.WriteLine("---");
|
||||
|
||||
using var db = new TrainingDatabase(dbPath);
|
||||
|
||||
var importer = new DxfImporter();
|
||||
var colorIndex = 0;
|
||||
var processed = 0;
|
||||
var skippedGeometry = 0;
|
||||
var skippedFeatures = 0;
|
||||
var skippedExisting = 0;
|
||||
var totalRuns = 0;
|
||||
var totalSw = Stopwatch.StartNew();
|
||||
|
||||
foreach (var file in dxfFiles)
|
||||
{
|
||||
var fileNum = processed + skippedGeometry + skippedFeatures + skippedExisting + 1;
|
||||
var partNo = Path.GetFileNameWithoutExtension(file);
|
||||
Console.Write($"[{fileNum}/{dxfFiles.Length}] {partNo}");
|
||||
|
||||
try
|
||||
{
|
||||
var existingRuns = db.RunCount(Path.GetFileName(file));
|
||||
if (existingRuns >= sheetSuite.Length)
|
||||
{
|
||||
Console.WriteLine(" - SKIP (all sizes done)");
|
||||
skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!importer.GetGeometry(file, out var entities))
|
||||
{
|
||||
Console.WriteLine(" - SKIP (no geometry)");
|
||||
skippedGeometry++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var drawing = new Drawing(Path.GetFileName(file));
|
||||
drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(entities);
|
||||
drawing.UpdateArea();
|
||||
drawing.Color = PartColors[colorIndex % PartColors.Length];
|
||||
colorIndex++;
|
||||
|
||||
var features = FeatureExtractor.Extract(drawing);
|
||||
if (features == null)
|
||||
{
|
||||
Console.WriteLine(" - SKIP (feature extraction failed)");
|
||||
skippedFeatures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine($" (area={features.Area:F1}, verts={features.VertexCount})");
|
||||
|
||||
// Precompute best-fits once for all sheet sizes.
|
||||
var sizes = sheetSuite.Select(sz => (sz.Width, sz.Length)).ToList();
|
||||
var bfSw = Stopwatch.StartNew();
|
||||
BestFitCache.ComputeForSizes(drawing, s, sizes);
|
||||
bfSw.Stop();
|
||||
Console.WriteLine($" Best-fits computed in {bfSw.ElapsedMilliseconds}ms");
|
||||
|
||||
using var txn = db.BeginTransaction();
|
||||
|
||||
var partId = db.GetOrAddPart(Path.GetFileName(file), features, drawing.Program.ToString());
|
||||
var partSw = Stopwatch.StartNew();
|
||||
var runsThisPart = 0;
|
||||
var bestUtil = 0.0;
|
||||
var bestCount = 0;
|
||||
|
||||
foreach (var size in sheetSuite)
|
||||
{
|
||||
if (db.HasRun(Path.GetFileName(file), size.Width, size.Length, s))
|
||||
{
|
||||
Console.WriteLine($" {size.Length}x{size.Width} - skip (exists)");
|
||||
continue;
|
||||
}
|
||||
|
||||
Plate runPlate;
|
||||
if (templateNest != null)
|
||||
{
|
||||
runPlate = templateNest.PlateDefaults.CreateNew();
|
||||
runPlate.Size = size;
|
||||
runPlate.PartSpacing = s;
|
||||
}
|
||||
else
|
||||
{
|
||||
runPlate = new Plate { Size = size, PartSpacing = s };
|
||||
}
|
||||
|
||||
var sizeSw = Stopwatch.StartNew();
|
||||
var result = BruteForceRunner.Run(drawing, runPlate);
|
||||
sizeSw.Stop();
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
Console.WriteLine($" {size.Length}x{size.Width} - no fit");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.Utilization > bestUtil)
|
||||
{
|
||||
bestUtil = result.Utilization;
|
||||
bestCount = result.PartCount;
|
||||
}
|
||||
|
||||
Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms");
|
||||
|
||||
string savedFilePath = null;
|
||||
if (saveDir != null)
|
||||
{
|
||||
// Deterministic bucket (00-FF) based on filename hash
|
||||
uint hash = 0;
|
||||
foreach (char c in partNo) hash = (hash * 31) + c;
|
||||
var bucket = (hash % 256).ToString("X2");
|
||||
|
||||
var partDir = Path.Combine(saveDir, bucket, partNo);
|
||||
Directory.CreateDirectory(partDir);
|
||||
|
||||
var nestName = $"{partNo}-{size.Length}x{size.Width}-{result.PartCount}pcs";
|
||||
var fileName = nestName + ".zip";
|
||||
savedFilePath = Path.Combine(partDir, fileName);
|
||||
|
||||
// Create nest from template or from scratch
|
||||
Nest nestObj;
|
||||
if (templateNest != null)
|
||||
{
|
||||
nestObj = new Nest(nestName)
|
||||
{
|
||||
Units = templateNest.Units,
|
||||
DateCreated = DateTime.Now
|
||||
};
|
||||
nestObj.PlateDefaults.SetFromExisting(templateNest.PlateDefaults.CreateNew());
|
||||
}
|
||||
else
|
||||
{
|
||||
nestObj = new Nest(nestName) { Units = Units.Inches, DateCreated = DateTime.Now };
|
||||
}
|
||||
|
||||
nestObj.Drawings.Add(drawing);
|
||||
var plateObj = nestObj.CreatePlate();
|
||||
plateObj.Size = size;
|
||||
plateObj.PartSpacing = s;
|
||||
plateObj.Parts.AddRange(result.PlacedParts);
|
||||
|
||||
var writer = new NestWriter(nestObj);
|
||||
writer.Write(savedFilePath);
|
||||
}
|
||||
|
||||
db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath);
|
||||
runsThisPart++;
|
||||
totalRuns++;
|
||||
}
|
||||
|
||||
txn.Commit();
|
||||
BestFitCache.Invalidate(drawing);
|
||||
partSw.Stop();
|
||||
processed++;
|
||||
Console.WriteLine($" Total: {runsThisPart} runs, best={bestCount}pcs @ {bestUtil:P1}, {partSw.ElapsedMilliseconds}ms");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.Error.WriteLine($" ERROR: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
totalSw.Stop();
|
||||
Console.WriteLine("---");
|
||||
Console.WriteLine($"Processed: {processed} parts, {totalRuns} total runs");
|
||||
Console.WriteLine($"Skipped: {skippedExisting} (existing) + {skippedGeometry} (no geometry) + {skippedFeatures} (no features)");
|
||||
Console.WriteLine($"Time: {totalSw.Elapsed:h\\:mm\\:ss}");
|
||||
Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("Usage: OpenNest.Console <nest-file> [options]");
|
||||
Console.Error.WriteLine(" OpenNest.Console --collect <dxf-dir> [options]");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Arguments:");
|
||||
Console.Error.WriteLine(" nest-file Path to a .zip nest file");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
|
||||
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
|
||||
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing");
|
||||
Console.Error.WriteLine(" --size <WxH> Override plate size (e.g. 120x60)");
|
||||
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.zip)");
|
||||
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||
Console.Error.WriteLine(" --no-log Skip writing debug log file");
|
||||
Console.Error.WriteLine(" -h, --help Show this help");
|
||||
Console.WriteLine(" --plate <index> Plate index to fill (default: 0)");
|
||||
Console.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
|
||||
Console.WriteLine(" --spacing <value> Override part spacing");
|
||||
Console.WriteLine(" --size <WxH> Override plate size (e.g. 120x60)");
|
||||
Console.WriteLine(" --output <path> Output nest file path (default: <input>-result.zip)");
|
||||
Console.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
||||
Console.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
Console.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.WriteLine(" --no-save Skip saving output file");
|
||||
Console.WriteLine(" --no-log Skip writing debug log file");
|
||||
Console.WriteLine(" --collect <dir> Brute-force process all DXFs in directory to SQLite");
|
||||
Console.WriteLine(" --db <path> Path to the SQLite training database (default: nesting_training.db)");
|
||||
Console.WriteLine(" --save-nests <dir> Directory to save individual .zip nests for each winner");
|
||||
Console.WriteLine(" --template <path> Nest template (.nstdot) for plate defaults");
|
||||
Console.WriteLine(" -h, --help Show this help");
|
||||
}
|
||||
|
||||
145
OpenNest.Console/TrainingDatabase.cs
Normal file
145
OpenNest.Console/TrainingDatabase.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using OpenNest.Engine.ML;
|
||||
|
||||
namespace OpenNest.Console
|
||||
{
|
||||
public class TrainingDatabase : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public TrainingDatabase(string dbPath)
|
||||
{
|
||||
var connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = dbPath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate
|
||||
}.ToString();
|
||||
|
||||
_connection = new SqliteConnection(connectionString);
|
||||
_connection.Open();
|
||||
|
||||
InitializeSchema();
|
||||
}
|
||||
|
||||
private void InitializeSchema()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
CREATE TABLE IF NOT EXISTS Parts (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
FileName TEXT,
|
||||
Area REAL,
|
||||
Convexity REAL,
|
||||
AspectRatio REAL,
|
||||
BBFill REAL,
|
||||
Circularity REAL,
|
||||
VertexCount INTEGER,
|
||||
Bitmask BLOB,
|
||||
GeometryData TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Runs (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
PartId INTEGER,
|
||||
SheetWidth REAL,
|
||||
SheetHeight REAL,
|
||||
Spacing REAL,
|
||||
PartCount INTEGER,
|
||||
Utilization REAL,
|
||||
TimeMs INTEGER,
|
||||
LayoutData TEXT,
|
||||
FilePath TEXT,
|
||||
FOREIGN KEY(PartId) REFERENCES Parts(Id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_parts_filename ON Parts(FileName);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_partid ON Runs(PartId);
|
||||
";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public long GetOrAddPart(string fileName, PartFeatures features, string geometryData)
|
||||
{
|
||||
// Check if part already exists
|
||||
using (var checkCmd = _connection.CreateCommand())
|
||||
{
|
||||
checkCmd.CommandText = "SELECT Id FROM Parts WHERE FileName = @name";
|
||||
checkCmd.Parameters.AddWithValue("@name", fileName);
|
||||
var result = checkCmd.ExecuteScalar();
|
||||
if (result != null) return (long)result;
|
||||
}
|
||||
|
||||
// Add new part
|
||||
using (var insertCmd = _connection.CreateCommand())
|
||||
{
|
||||
insertCmd.CommandText = @"
|
||||
INSERT INTO Parts (FileName, Area, Convexity, AspectRatio, BBFill, Circularity, VertexCount, Bitmask, GeometryData)
|
||||
VALUES (@name, @area, @conv, @asp, @fill, @circ, @vert, @mask, @geo);
|
||||
SELECT last_insert_rowid();";
|
||||
|
||||
insertCmd.Parameters.AddWithValue("@name", fileName);
|
||||
insertCmd.Parameters.AddWithValue("@area", features.Area);
|
||||
insertCmd.Parameters.AddWithValue("@conv", features.Convexity);
|
||||
insertCmd.Parameters.AddWithValue("@asp", features.AspectRatio);
|
||||
insertCmd.Parameters.AddWithValue("@fill", features.BoundingBoxFill);
|
||||
insertCmd.Parameters.AddWithValue("@circ", features.Circularity);
|
||||
insertCmd.Parameters.AddWithValue("@vert", features.VertexCount);
|
||||
insertCmd.Parameters.AddWithValue("@mask", features.Bitmask);
|
||||
insertCmd.Parameters.AddWithValue("@geo", geometryData);
|
||||
|
||||
return (long)insertCmd.ExecuteScalar();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasRun(string fileName, double sheetWidth, double sheetHeight, double spacing)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = @"SELECT COUNT(*) FROM Runs r JOIN Parts p ON r.PartId = p.Id
|
||||
WHERE p.FileName = @name AND r.SheetWidth = @w AND r.SheetHeight = @h AND r.Spacing = @s";
|
||||
cmd.Parameters.AddWithValue("@name", fileName);
|
||||
cmd.Parameters.AddWithValue("@w", sheetWidth);
|
||||
cmd.Parameters.AddWithValue("@h", sheetHeight);
|
||||
cmd.Parameters.AddWithValue("@s", spacing);
|
||||
return (long)cmd.ExecuteScalar() > 0;
|
||||
}
|
||||
|
||||
public int RunCount(string fileName)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Runs r JOIN Parts p ON r.PartId = p.Id WHERE p.FileName = @name";
|
||||
cmd.Parameters.AddWithValue("@name", fileName);
|
||||
return (int)(long)cmd.ExecuteScalar();
|
||||
}
|
||||
|
||||
public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO Runs (PartId, SheetWidth, SheetHeight, Spacing, PartCount, Utilization, TimeMs, LayoutData, FilePath)
|
||||
VALUES (@pid, @w, @h, @s, @cnt, @util, @time, @layout, @path)";
|
||||
|
||||
cmd.Parameters.AddWithValue("@pid", partId);
|
||||
cmd.Parameters.AddWithValue("@w", w);
|
||||
cmd.Parameters.AddWithValue("@h", h);
|
||||
cmd.Parameters.AddWithValue("@s", s);
|
||||
cmd.Parameters.AddWithValue("@cnt", result.PartCount);
|
||||
cmd.Parameters.AddWithValue("@util", result.Utilization);
|
||||
cmd.Parameters.AddWithValue("@time", result.TimeMs);
|
||||
cmd.Parameters.AddWithValue("@layout", result.LayoutData ?? "");
|
||||
cmd.Parameters.AddWithValue("@path", filePath ?? "");
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public SqliteTransaction BeginTransaction()
|
||||
{
|
||||
return _connection.BeginTransaction();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
@@ -84,6 +84,23 @@ namespace OpenNest.CNC
|
||||
Rotation = Angle.NormalizeRad(Rotation + angle);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine(mode == Mode.Absolute ? "G90" : "G91");
|
||||
foreach (var code in Codes)
|
||||
{
|
||||
if (code is Motion m)
|
||||
{
|
||||
var cmd = m is RapidMove ? "G00" : (m is ArcMove am ? (am.Rotation == RotationType.CW ? "G02" : "G03") : "G01");
|
||||
sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}");
|
||||
if (m is ArcMove arc) sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public virtual void Rotate(double angle, Vector origin)
|
||||
{
|
||||
var mode = Mode;
|
||||
@@ -99,7 +116,7 @@ namespace OpenNest.CNC
|
||||
var subpgm = (SubProgramCall)code;
|
||||
|
||||
if (subpgm.Program != null)
|
||||
subpgm.Program.Rotate(angle);
|
||||
subpgm.Program.Rotate(angle, origin);
|
||||
}
|
||||
|
||||
if (code is Motion == false)
|
||||
|
||||
@@ -863,81 +863,55 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction)
|
||||
{
|
||||
var p1x = edge.pt1.X;
|
||||
var p1y = edge.pt1.Y;
|
||||
var p2x = edge.pt2.X;
|
||||
var p2y = edge.pt2.Y;
|
||||
return RayEdgeDistance(
|
||||
vertex.X, vertex.Y,
|
||||
edge.pt1.X, edge.pt1.Y, edge.pt2.X, edge.pt2.Y,
|
||||
direction);
|
||||
}
|
||||
|
||||
[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,
|
||||
PushDirection direction)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left:
|
||||
{
|
||||
// Ray goes in -X direction. Need non-horizontal edge.
|
||||
var dy = p2y - p1y;
|
||||
if (dy > -Tolerance.Epsilon && dy < Tolerance.Epsilon)
|
||||
return double.MaxValue; // horizontal edge, parallel to ray
|
||||
|
||||
var t = (vertex.Y - p1y) / dy;
|
||||
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = vertex.X - ix; // positive if edge is to the left
|
||||
if (dist > Tolerance.Epsilon) return dist;
|
||||
if (dist >= -Tolerance.Epsilon) return 0; // touching
|
||||
return double.MaxValue; // edge is behind vertex
|
||||
}
|
||||
|
||||
case PushDirection.Right:
|
||||
{
|
||||
var dy = p2y - p1y;
|
||||
if (dy > -Tolerance.Epsilon && dy < Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var t = (vertex.Y - p1y) / dy;
|
||||
var t = (vy - p1y) / dy;
|
||||
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = ix - vertex.X;
|
||||
var dist = direction == PushDirection.Left ? vx - ix : ix - vx;
|
||||
if (dist > Tolerance.Epsilon) return dist;
|
||||
if (dist >= -Tolerance.Epsilon) return 0; // touching
|
||||
return double.MaxValue; // edge is behind vertex
|
||||
if (dist >= -Tolerance.Epsilon) return 0;
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
case PushDirection.Down:
|
||||
{
|
||||
// Ray goes in -Y direction. Need non-vertical edge.
|
||||
var dx = p2x - p1x;
|
||||
if (dx > -Tolerance.Epsilon && dx < Tolerance.Epsilon)
|
||||
return double.MaxValue; // vertical edge, parallel to ray
|
||||
|
||||
var t = (vertex.X - p1x) / dx;
|
||||
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = vertex.Y - iy;
|
||||
if (dist > Tolerance.Epsilon) return dist;
|
||||
if (dist >= -Tolerance.Epsilon) return 0; // touching
|
||||
return double.MaxValue; // edge is behind vertex
|
||||
}
|
||||
|
||||
case PushDirection.Up:
|
||||
{
|
||||
var dx = p2x - p1x;
|
||||
if (dx > -Tolerance.Epsilon && dx < Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var t = (vertex.X - p1x) / dx;
|
||||
var t = (vx - p1x) / dx;
|
||||
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = iy - vertex.Y;
|
||||
var dist = direction == PushDirection.Down ? vy - iy : iy - vy;
|
||||
if (dist > Tolerance.Epsilon) return dist;
|
||||
if (dist >= -Tolerance.Epsilon) return 0; // touching
|
||||
return double.MaxValue; // edge is behind vertex
|
||||
if (dist >= -Tolerance.Epsilon) return 0;
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -991,6 +965,82 @@ namespace OpenNest
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance with the moving lines translated
|
||||
/// by (movingDx, movingDy) without creating new Line objects.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines, double movingDx, double movingDy,
|
||||
List<Line> stationaryLines, PushDirection direction)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Each moving vertex → each stationary edge
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
var ml = movingLines[i];
|
||||
var mx1 = ml.pt1.X + movingDx;
|
||||
var my1 = ml.pt1.Y + movingDy;
|
||||
var mx2 = ml.pt2.X + movingDx;
|
||||
var my2 = ml.pt2.Y + movingDy;
|
||||
|
||||
for (int j = 0; j < stationaryLines.Count; j++)
|
||||
{
|
||||
var se = stationaryLines[j];
|
||||
var d = RayEdgeDistance(mx1, my1, se.pt1.X, se.pt1.Y, se.pt2.X, se.pt2.Y, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
|
||||
d = RayEdgeDistance(mx2, my2, se.pt1.X, se.pt1.Y, se.pt2.X, se.pt2.Y, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex → each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
var sl = stationaryLines[i];
|
||||
|
||||
for (int j = 0; j < movingLines.Count; j++)
|
||||
{
|
||||
var me = movingLines[j];
|
||||
var d = RayEdgeDistance(
|
||||
sl.pt1.X, sl.pt1.Y,
|
||||
me.pt1.X + movingDx, me.pt1.Y + movingDy,
|
||||
me.pt2.X + movingDx, me.pt2.Y + movingDy,
|
||||
opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
|
||||
d = RayEdgeDistance(
|
||||
sl.pt2.X, sl.pt2.Y,
|
||||
me.pt1.X + movingDx, me.pt1.Y + movingDy,
|
||||
me.pt2.X + movingDx, me.pt2.Y + movingDy,
|
||||
opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs line segments into a flat double array [x1,y1,x2,y2, ...] for GPU transfer.
|
||||
/// </summary>
|
||||
public static double[] FlattenLines(List<Line> lines)
|
||||
{
|
||||
var result = new double[lines.Count * 4];
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
result[i * 4] = line.pt1.X;
|
||||
result[i * 4 + 1] = line.pt1.Y;
|
||||
result[i * 4 + 2] = line.pt2.X;
|
||||
result[i * 4 + 3] = line.pt2.Y;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PushDirection OppositeDirection(PushDirection direction)
|
||||
{
|
||||
switch (direction)
|
||||
|
||||
@@ -149,31 +149,25 @@ namespace OpenNest
|
||||
pts = new List<Vector>();
|
||||
|
||||
var entities1 = ConvertProgram.ToGeometry(Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
var entities2 = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
var shapes1 = Helper.GetShapes(entities1);
|
||||
var shapes2 = Helper.GetShapes(entities2);
|
||||
if (entities1.Count == 0 || entities2.Count == 0)
|
||||
return false;
|
||||
|
||||
shapes1.ForEach(shape => shape.Offset(Location));
|
||||
shapes2.ForEach(shape => shape.Offset(part.Location));
|
||||
var perimeter1 = new ShapeProfile(entities1).Perimeter;
|
||||
var perimeter2 = new ShapeProfile(entities2).Perimeter;
|
||||
|
||||
for (int i = 0; i < shapes1.Count; i++)
|
||||
{
|
||||
var shape1 = shapes1[i];
|
||||
if (perimeter1 == null || perimeter2 == null)
|
||||
return false;
|
||||
|
||||
for (int j = 0; j < shapes2.Count; j++)
|
||||
{
|
||||
var shape2 = shapes2[j];
|
||||
List<Vector> pts2;
|
||||
perimeter1.Offset(Location);
|
||||
perimeter2.Offset(part.Location);
|
||||
|
||||
if (shape1.Intersects(shape2, out pts2))
|
||||
pts.AddRange(pts2);
|
||||
}
|
||||
}
|
||||
|
||||
return pts.Count > 0;
|
||||
return perimeter1.Intersects(perimeter2, out pts);
|
||||
}
|
||||
|
||||
public double Left
|
||||
@@ -216,8 +210,9 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public Part CloneAtOffset(Vector offset)
|
||||
{
|
||||
var clonedProgram = Program.Clone() as Program;
|
||||
var part = new Part(BaseDrawing, clonedProgram,
|
||||
// Share the Program instance — offset-only copies don't modify the program codes.
|
||||
// This is a major performance win for tiling large patterns.
|
||||
var part = new Part(BaseDrawing, Program,
|
||||
location + offset,
|
||||
new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y,
|
||||
BoundingBox.Width, BoundingBox.Length));
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace OpenNest.Engine.BestFit
|
||||
new ConcurrentDictionary<CacheKey, List<BestFitResult>>();
|
||||
|
||||
public static Func<Drawing, double, IPairEvaluator> CreateEvaluator { get; set; }
|
||||
public static Func<ISlideComputer> CreateSlideComputer { get; set; }
|
||||
|
||||
public static List<BestFitResult> GetOrCompute(
|
||||
Drawing drawing, double plateWidth, double plateHeight,
|
||||
@@ -24,6 +25,7 @@ namespace OpenNest.Engine.BestFit
|
||||
return cached;
|
||||
|
||||
IPairEvaluator evaluator = null;
|
||||
ISlideComputer slideComputer = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -33,13 +35,107 @@ namespace OpenNest.Engine.BestFit
|
||||
catch { /* fall back to default evaluator */ }
|
||||
}
|
||||
|
||||
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator);
|
||||
if (CreateSlideComputer != null)
|
||||
{
|
||||
try { slideComputer = CreateSlideComputer(); }
|
||||
catch { /* fall back to CPU slide computation */ }
|
||||
}
|
||||
|
||||
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
||||
var results = finder.FindBestFits(drawing, spacing, StepSize);
|
||||
|
||||
_cache.TryAdd(key, results);
|
||||
return results;
|
||||
}
|
||||
finally
|
||||
{
|
||||
(evaluator as IDisposable)?.Dispose();
|
||||
// Slide computer is managed by the factory as a singleton — don't dispose here
|
||||
}
|
||||
}
|
||||
|
||||
public static void ComputeForSizes(
|
||||
Drawing drawing, double spacing,
|
||||
IEnumerable<(double Width, double Height)> plateSizes)
|
||||
{
|
||||
// Skip sizes that are already cached.
|
||||
var needed = new List<(double Width, double Height)>();
|
||||
foreach (var size in plateSizes)
|
||||
{
|
||||
var key = new CacheKey(drawing, size.Width, size.Height, spacing);
|
||||
if (!_cache.ContainsKey(key))
|
||||
needed.Add(size);
|
||||
}
|
||||
|
||||
if (needed.Count == 0)
|
||||
return;
|
||||
|
||||
// Find the largest plate to use for the initial computation — this
|
||||
// keeps the filter maximally permissive so we don't discard results
|
||||
// that a smaller plate might still use after re-filtering.
|
||||
var maxWidth = 0.0;
|
||||
var maxHeight = 0.0;
|
||||
foreach (var size in needed)
|
||||
{
|
||||
if (size.Width > maxWidth) maxWidth = size.Width;
|
||||
if (size.Height > maxHeight) maxHeight = size.Height;
|
||||
}
|
||||
|
||||
IPairEvaluator evaluator = null;
|
||||
ISlideComputer slideComputer = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (CreateEvaluator != null)
|
||||
{
|
||||
try { evaluator = CreateEvaluator(drawing, spacing); }
|
||||
catch { /* fall back to default evaluator */ }
|
||||
}
|
||||
|
||||
if (CreateSlideComputer != null)
|
||||
{
|
||||
try { slideComputer = CreateSlideComputer(); }
|
||||
catch { /* fall back to CPU slide computation */ }
|
||||
}
|
||||
|
||||
// Compute candidates and evaluate once with the largest plate.
|
||||
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
|
||||
var baseResults = finder.FindBestFits(drawing, spacing, StepSize);
|
||||
|
||||
// Cache a filtered copy for each plate size.
|
||||
foreach (var size in needed)
|
||||
{
|
||||
var filter = new BestFitFilter
|
||||
{
|
||||
MaxPlateWidth = size.Width,
|
||||
MaxPlateHeight = size.Height
|
||||
};
|
||||
|
||||
var copy = new List<BestFitResult>(baseResults.Count);
|
||||
for (var i = 0; i < baseResults.Count; i++)
|
||||
{
|
||||
var r = baseResults[i];
|
||||
copy.Add(new BestFitResult
|
||||
{
|
||||
Candidate = r.Candidate,
|
||||
RotatedArea = r.RotatedArea,
|
||||
BoundingWidth = r.BoundingWidth,
|
||||
BoundingHeight = r.BoundingHeight,
|
||||
OptimalRotation = r.OptimalRotation,
|
||||
TrueArea = r.TrueArea,
|
||||
HullAngles = r.HullAngles,
|
||||
Keep = r.Keep,
|
||||
Reason = r.Reason
|
||||
});
|
||||
}
|
||||
|
||||
filter.Apply(copy);
|
||||
|
||||
var key = new CacheKey(drawing, size.Width, size.Height, spacing);
|
||||
_cache.TryAdd(key, copy);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
(evaluator as IDisposable)?.Dispose();
|
||||
}
|
||||
@@ -54,6 +150,25 @@ namespace OpenNest.Engine.BestFit
|
||||
}
|
||||
}
|
||||
|
||||
public static void Populate(Drawing drawing, double plateWidth, double plateHeight,
|
||||
double spacing, List<BestFitResult> results)
|
||||
{
|
||||
var key = new CacheKey(drawing, plateWidth, plateHeight, spacing);
|
||||
_cache.TryAdd(key, results);
|
||||
}
|
||||
|
||||
public static Dictionary<(double PlateWidth, double PlateHeight, double Spacing), List<BestFitResult>>
|
||||
GetAllForDrawing(Drawing drawing)
|
||||
{
|
||||
var result = new Dictionary<(double, double, double), List<BestFitResult>>();
|
||||
foreach (var kvp in _cache)
|
||||
{
|
||||
if (ReferenceEquals(kvp.Key.Drawing, drawing))
|
||||
result[(kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)] = kvp.Value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
|
||||
@@ -12,11 +12,14 @@ namespace OpenNest.Engine.BestFit
|
||||
public class BestFitFinder
|
||||
{
|
||||
private readonly IPairEvaluator _evaluator;
|
||||
private readonly ISlideComputer _slideComputer;
|
||||
private readonly BestFitFilter _filter;
|
||||
|
||||
public BestFitFinder(double maxPlateWidth, double maxPlateHeight, IPairEvaluator evaluator = null)
|
||||
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
|
||||
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
|
||||
{
|
||||
_evaluator = evaluator ?? new PairEvaluator();
|
||||
_slideComputer = slideComputer;
|
||||
_filter = new BestFitFilter
|
||||
{
|
||||
MaxPlateWidth = maxPlateWidth,
|
||||
@@ -78,7 +81,7 @@ namespace OpenNest.Engine.BestFit
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
|
||||
strategies.Add(new RotationSlideStrategy(angle, type++, desc));
|
||||
strategies.Add(new RotationSlideStrategy(angle, type++, desc, _slideComputer));
|
||||
}
|
||||
|
||||
return strategies;
|
||||
@@ -102,6 +105,7 @@ namespace OpenNest.Engine.BestFit
|
||||
AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI));
|
||||
}
|
||||
|
||||
angles.Sort();
|
||||
return angles;
|
||||
}
|
||||
|
||||
@@ -115,8 +119,24 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var polygon = shape.ToPolygonWithTolerance(0.01);
|
||||
points.AddRange(polygon.Vertices);
|
||||
// Extract key points from original geometry — line endpoints
|
||||
// plus arc endpoints and cardinal extreme points. This avoids
|
||||
// tessellating arcs into many chords that flood the hull with
|
||||
// near-duplicate edge angles.
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
points.Add(line.StartPoint);
|
||||
points.Add(line.EndPoint);
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
points.Add(arc.StartPoint());
|
||||
points.Add(arc.EndPoint());
|
||||
AddArcExtremes(points, arc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (points.Count < 3)
|
||||
@@ -143,13 +163,49 @@ namespace OpenNest.Engine.BestFit
|
||||
return hullAngles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°)
|
||||
/// if they fall within the arc's angular span.
|
||||
/// </summary>
|
||||
private static void AddArcExtremes(List<Vector> points, Arc arc)
|
||||
{
|
||||
var a1 = arc.StartAngle;
|
||||
var a2 = arc.EndAngle;
|
||||
|
||||
if (arc.IsReversed)
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum angular separation (radians) between hull-derived rotation candidates.
|
||||
/// Tessellated arcs produce many hull edges with nearly identical angles;
|
||||
/// a 1° threshold collapses those into a single representative.
|
||||
/// </summary>
|
||||
private const double AngleTolerance = System.Math.PI / 36; // 5 degrees
|
||||
|
||||
private static void AddUniqueAngle(List<double> angles, double angle)
|
||||
{
|
||||
angle = Angle.NormalizeRad(angle);
|
||||
|
||||
foreach (var existing in angles)
|
||||
{
|
||||
if (existing.IsEqualTo(angle))
|
||||
if (existing.IsEqualTo(angle, AngleTolerance))
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace OpenNest.Engine.BestFit
|
||||
public bool Keep { get; set; }
|
||||
public string Reason { get; set; }
|
||||
public double TrueArea { get; set; }
|
||||
public List<double> HullAngles { get; set; }
|
||||
|
||||
public double Utilization
|
||||
{
|
||||
|
||||
38
OpenNest.Engine/BestFit/ISlideComputer.cs
Normal file
38
OpenNest.Engine/BestFit/ISlideComputer.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
/// <summary>
|
||||
/// Batches directional-distance computations for multiple offset positions.
|
||||
/// GPU implementations can process all offsets in a single kernel launch.
|
||||
/// </summary>
|
||||
public interface ISlideComputer : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance for each offset position.
|
||||
/// </summary>
|
||||
/// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param>
|
||||
/// <param name="stationaryCount">Number of line segments in stationarySegments.</param>
|
||||
/// <param name="movingTemplateSegments">Flat array [x1,y1,x2,y2, ...] for moving edges at origin.</param>
|
||||
/// <param name="movingCount">Number of line segments in movingTemplateSegments.</param>
|
||||
/// <param name="offsets">Flat array [dx,dy, dx,dy, ...] of translation offsets.</param>
|
||||
/// <param name="offsetCount">Number of offset positions.</param>
|
||||
/// <param name="direction">Push direction.</param>
|
||||
/// <returns>Array of minimum distances, one per offset position.</returns>
|
||||
double[] ComputeBatch(
|
||||
double[] stationarySegments, int stationaryCount,
|
||||
double[] movingTemplateSegments, int movingCount,
|
||||
double[] offsets, int offsetCount,
|
||||
PushDirection direction);
|
||||
|
||||
/// <summary>
|
||||
/// Computes minimum directional distance for offsets with per-offset directions.
|
||||
/// Uploads segment data once for all offsets, reducing GPU round-trips.
|
||||
/// </summary>
|
||||
double[] ComputeBatchMultiDir(
|
||||
double[] stationarySegments, int stationaryCount,
|
||||
double[] movingTemplateSegments, int movingCount,
|
||||
double[] offsets, int offsetCount,
|
||||
int[] directions);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
// Find optimal bounding rectangle via rotating calipers
|
||||
double bestArea, bestWidth, bestHeight, bestRotation;
|
||||
List<double> hullAngles = null;
|
||||
|
||||
if (allPoints.Count >= 3)
|
||||
{
|
||||
@@ -51,6 +52,7 @@ namespace OpenNest.Engine.BestFit
|
||||
bestWidth = result.Width;
|
||||
bestHeight = result.Height;
|
||||
bestRotation = result.Angle;
|
||||
hullAngles = RotationAnalysis.GetHullEdgeAngles(hull);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -59,6 +61,7 @@ namespace OpenNest.Engine.BestFit
|
||||
bestWidth = combinedBox.Width;
|
||||
bestHeight = combinedBox.Length;
|
||||
bestRotation = 0;
|
||||
hullAngles = new List<double> { 0 };
|
||||
}
|
||||
|
||||
var trueArea = drawing.Area * 2;
|
||||
@@ -71,6 +74,7 @@ namespace OpenNest.Engine.BestFit
|
||||
BoundingHeight = bestHeight,
|
||||
OptimalRotation = bestRotation,
|
||||
TrueArea = trueArea,
|
||||
HullAngles = hullAngles,
|
||||
Keep = !overlaps,
|
||||
Reason = overlaps ? "Overlap detected" : "Valid"
|
||||
};
|
||||
|
||||
@@ -5,11 +5,20 @@ namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class RotationSlideStrategy : IBestFitStrategy
|
||||
{
|
||||
public RotationSlideStrategy(double part2Rotation, int type, string description)
|
||||
private readonly ISlideComputer _slideComputer;
|
||||
|
||||
private static readonly PushDirection[] AllDirections =
|
||||
{
|
||||
PushDirection.Left, PushDirection.Down, PushDirection.Right, PushDirection.Up
|
||||
};
|
||||
|
||||
public RotationSlideStrategy(double part2Rotation, int type, string description,
|
||||
ISlideComputer slideComputer = null)
|
||||
{
|
||||
Part2Rotation = part2Rotation;
|
||||
Type = type;
|
||||
Description = description;
|
||||
_slideComputer = slideComputer;
|
||||
}
|
||||
|
||||
public double Part2Rotation { get; }
|
||||
@@ -23,43 +32,66 @@ namespace OpenNest.Engine.BestFit
|
||||
var part1 = Part.CreateAtOrigin(drawing);
|
||||
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
|
||||
|
||||
var halfSpacing = spacing / 2;
|
||||
var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing);
|
||||
var part2TemplateLines = Helper.GetOffsetPartLines(part2Template, halfSpacing);
|
||||
|
||||
var bbox1 = part1.BoundingBox;
|
||||
var bbox2 = part2Template.BoundingBox;
|
||||
|
||||
// Collect offsets and directions across all 4 axes
|
||||
var allDx = new List<double>();
|
||||
var allDy = new List<double>();
|
||||
var allDirs = new List<PushDirection>();
|
||||
|
||||
foreach (var pushDir in AllDirections)
|
||||
BuildOffsets(bbox1, bbox2, spacing, stepSize, pushDir, allDx, allDy, allDirs);
|
||||
|
||||
if (allDx.Count == 0)
|
||||
return candidates;
|
||||
|
||||
// Compute all distances — single GPU dispatch or CPU loop
|
||||
var distances = ComputeAllDistances(
|
||||
part1Lines, part2TemplateLines, allDx, allDy, allDirs);
|
||||
|
||||
// Create candidates from valid results
|
||||
var testNumber = 0;
|
||||
|
||||
// Try pushing left (horizontal slide)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Left, candidates, ref testNumber);
|
||||
for (var i = 0; i < allDx.Count; i++)
|
||||
{
|
||||
var slideDist = distances[i];
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
// Try pushing down (vertical slide)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Down, candidates, ref testNumber);
|
||||
var dx = allDx[i];
|
||||
var dy = allDy[i];
|
||||
var pushVector = GetPushVector(allDirs[i], slideDist);
|
||||
var finalPosition = new Vector(
|
||||
part2Template.Location.X + dx + pushVector.X,
|
||||
part2Template.Location.Y + dy + pushVector.Y);
|
||||
|
||||
// Try pushing right (approach from left — finds concave interlocking)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Right, candidates, ref testNumber);
|
||||
|
||||
// Try pushing up (approach from below — finds concave interlocking)
|
||||
GenerateCandidatesForAxis(
|
||||
part1, part2Template, drawing, spacing, stepSize,
|
||||
PushDirection.Up, candidates, ref testNumber);
|
||||
candidates.Add(new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = Part2Rotation,
|
||||
Part2Offset = finalPosition,
|
||||
StrategyType = Type,
|
||||
TestNumber = testNumber++,
|
||||
Spacing = spacing
|
||||
});
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private void GenerateCandidatesForAxis(
|
||||
Part part1, Part part2Template, Drawing drawing,
|
||||
double spacing, double stepSize, PushDirection pushDir,
|
||||
List<PairCandidate> candidates, ref int testNumber)
|
||||
private static void BuildOffsets(
|
||||
Box bbox1, Box bbox2, double spacing, double stepSize,
|
||||
PushDirection pushDir, List<double> allDx, List<double> allDy,
|
||||
List<PushDirection> allDirs)
|
||||
{
|
||||
var bbox1 = part1.BoundingBox;
|
||||
var bbox2 = part2Template.BoundingBox;
|
||||
var halfSpacing = spacing / 2;
|
||||
|
||||
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
|
||||
|
||||
// Perpendicular range: part2 slides across the full extent of part1
|
||||
double perpMin, perpMax, pushStartOffset;
|
||||
|
||||
if (isHorizontalPush)
|
||||
@@ -75,54 +107,55 @@ namespace OpenNest.Engine.BestFit
|
||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||
}
|
||||
|
||||
// Pre-compute part1's offset lines (half-spacing outward)
|
||||
var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing);
|
||||
|
||||
// Align sweep start to a multiple of stepSize so that offset=0 is always
|
||||
// included. This ensures perfect grid arrangements (side-by-side, stacked)
|
||||
// are generated for rectangular parts.
|
||||
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
||||
|
||||
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
|
||||
{
|
||||
var part2 = (Part)part2Template.Clone();
|
||||
|
||||
// Place part2 far away along push axis, at perpendicular offset.
|
||||
// Left/Down: start on the positive side; Right/Up: start on the negative side.
|
||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
||||
|
||||
if (isHorizontalPush)
|
||||
part2.Offset(startPos, offset);
|
||||
else
|
||||
part2.Offset(offset, startPos);
|
||||
|
||||
// Get part2's offset lines (half-spacing outward)
|
||||
var part2Lines = Helper.GetOffsetPartLines(part2, halfSpacing);
|
||||
|
||||
// Find contact distance
|
||||
var slideDist = Helper.DirectionalDistance(part2Lines, part1Lines, pushDir);
|
||||
|
||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||
continue;
|
||||
|
||||
// Move part2 to contact position
|
||||
var pushVector = GetPushVector(pushDir, slideDist);
|
||||
var finalPosition = part2.Location + pushVector;
|
||||
|
||||
candidates.Add(new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = Part2Rotation,
|
||||
Part2Offset = finalPosition,
|
||||
StrategyType = Type,
|
||||
TestNumber = testNumber++,
|
||||
Spacing = spacing
|
||||
});
|
||||
allDx.Add(isHorizontalPush ? startPos : offset);
|
||||
allDy.Add(isHorizontalPush ? offset : startPos);
|
||||
allDirs.Add(pushDir);
|
||||
}
|
||||
}
|
||||
|
||||
private double[] ComputeAllDistances(
|
||||
List<Line> part1Lines, List<Line> part2TemplateLines,
|
||||
List<double> allDx, List<double> allDy, List<PushDirection> allDirs)
|
||||
{
|
||||
var count = allDx.Count;
|
||||
|
||||
if (_slideComputer != null)
|
||||
{
|
||||
var stationarySegments = Helper.FlattenLines(part1Lines);
|
||||
var movingSegments = Helper.FlattenLines(part2TemplateLines);
|
||||
var offsets = new double[count * 2];
|
||||
var directions = new int[count];
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
offsets[i * 2] = allDx[i];
|
||||
offsets[i * 2 + 1] = allDy[i];
|
||||
directions[i] = (int)allDirs[i];
|
||||
}
|
||||
|
||||
return _slideComputer.ComputeBatchMultiDir(
|
||||
stationarySegments, part1Lines.Count,
|
||||
movingSegments, part2TemplateLines.Count,
|
||||
offsets, count, directions);
|
||||
}
|
||||
|
||||
var results = new double[count];
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
results[i] = Helper.DirectionalDistance(
|
||||
part2TemplateLines, allDx[i], allDy[i], part1Lines, allDirs[i]);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Vector GetPushVector(PushDirection direction, double distance)
|
||||
{
|
||||
switch (direction)
|
||||
|
||||
@@ -41,39 +41,32 @@ namespace OpenNest
|
||||
return default;
|
||||
|
||||
var totalPartArea = 0.0;
|
||||
var minX = double.MaxValue;
|
||||
var minY = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
var bboxArea = bbox.Area();
|
||||
if (bb.Left < minX) minX = bb.Left;
|
||||
if (bb.Bottom < minY) minY = bb.Bottom;
|
||||
if (bb.Right > maxX) maxX = bb.Right;
|
||||
if (bb.Top > maxY) maxY = bb.Top;
|
||||
}
|
||||
|
||||
var bboxArea = (maxX - minX) * (maxY - minY);
|
||||
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
|
||||
|
||||
var usableRemnantArea = ComputeUsableRemnantArea(parts, workArea);
|
||||
var usableRemnantArea = ComputeUsableRemnantArea(maxX, maxY, workArea);
|
||||
|
||||
return new FillScore(parts.Count, usableRemnantArea, density);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the largest usable remnant (short side >= MinRemnantDimension)
|
||||
/// by checking right and top edge strips between placed parts and the work area boundary.
|
||||
/// </summary>
|
||||
private static double ComputeUsableRemnantArea(List<Part> parts, Box workArea)
|
||||
private static double ComputeUsableRemnantArea(double maxRight, double maxTop, Box workArea)
|
||||
{
|
||||
var maxRight = double.MinValue;
|
||||
var maxTop = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
if (bb.Right > maxRight)
|
||||
maxRight = bb.Right;
|
||||
|
||||
if (bb.Top > maxTop)
|
||||
maxTop = bb.Top;
|
||||
}
|
||||
|
||||
var largest = 0.0;
|
||||
|
||||
// Right strip
|
||||
|
||||
53
OpenNest.Engine/ML/BruteForceRunner.cs
Normal file
53
OpenNest.Engine/ML/BruteForceRunner.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.ML
|
||||
{
|
||||
public class BruteForceResult
|
||||
{
|
||||
public int PartCount { get; set; }
|
||||
public double Utilization { get; set; }
|
||||
public long TimeMs { get; set; }
|
||||
public string LayoutData { get; set; }
|
||||
public List<Part> PlacedParts { get; set; }
|
||||
}
|
||||
|
||||
public static class BruteForceRunner
|
||||
{
|
||||
public static BruteForceResult Run(Drawing drawing, Plate plate)
|
||||
{
|
||||
var engine = new NestEngine(plate);
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
sw.Stop();
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
return null;
|
||||
|
||||
return new BruteForceResult
|
||||
{
|
||||
PartCount = parts.Count,
|
||||
Utilization = CalculateUtilization(parts, plate.Area()),
|
||||
TimeMs = sw.ElapsedMilliseconds,
|
||||
LayoutData = SerializeLayout(parts),
|
||||
PlacedParts = parts
|
||||
};
|
||||
}
|
||||
|
||||
private static string SerializeLayout(List<Part> parts)
|
||||
{
|
||||
var data = parts.Select(p => new { X = p.Location.X, Y = p.Location.Y, R = p.Rotation }).ToList();
|
||||
return System.Text.Json.JsonSerializer.Serialize(data);
|
||||
}
|
||||
|
||||
private static double CalculateUtilization(List<Part> parts, double plateArea)
|
||||
{
|
||||
if (plateArea <= 0) return 0;
|
||||
return parts.Sum(p => p.BaseDrawing.Area) / plateArea;
|
||||
}
|
||||
}
|
||||
}
|
||||
88
OpenNest.Engine/ML/FeatureExtractor.cs
Normal file
88
OpenNest.Engine/ML/FeatureExtractor.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.ML
|
||||
{
|
||||
public class PartFeatures
|
||||
{
|
||||
// --- Geometric Features ---
|
||||
public double Area { get; set; }
|
||||
public double Convexity { get; set; } // Area / Convex Hull Area
|
||||
public double AspectRatio { get; set; } // Width / Length
|
||||
public double BoundingBoxFill { get; set; } // Area / (Width * Length)
|
||||
public double Circularity { get; set; } // 4 * PI * Area / Perimeter^2
|
||||
public int VertexCount { get; set; }
|
||||
|
||||
// --- Normalized Bitmask (32x32 = 1024 features) ---
|
||||
public byte[] Bitmask { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Area:F2},{Convexity:F4},{AspectRatio:F4},{BoundingBoxFill:F4},{Circularity:F4},{VertexCount}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class FeatureExtractor
|
||||
{
|
||||
public static PartFeatures Extract(Drawing drawing)
|
||||
{
|
||||
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
var perimeter = profile.Perimeter;
|
||||
|
||||
if (perimeter == null) return null;
|
||||
|
||||
var polygon = perimeter.ToPolygonWithTolerance(0.01);
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
|
||||
var hull = ConvexHull.Compute(polygon.Vertices);
|
||||
var hullArea = hull.Area();
|
||||
|
||||
var features = new PartFeatures
|
||||
{
|
||||
Area = drawing.Area,
|
||||
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0),
|
||||
AspectRatio = bb.Width / (bb.Length > 0 ? bb.Length : 1.0),
|
||||
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
|
||||
VertexCount = polygon.Vertices.Count,
|
||||
Bitmask = GenerateBitmask(polygon, 32)
|
||||
};
|
||||
|
||||
// Circularity = 4 * PI * Area / Perimeter^2
|
||||
var perimeterLen = polygon.Perimeter();
|
||||
features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen);
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
private static byte[] GenerateBitmask(Polygon polygon, int size)
|
||||
{
|
||||
var mask = new byte[size * size];
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
|
||||
for (int y = 0; y < size; y++)
|
||||
{
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
// Map grid coordinate (0..size) to bounding box coordinate
|
||||
var px = bb.Left + (x + 0.5) * (bb.Width / size);
|
||||
var py = bb.Bottom + (y + 0.5) * (bb.Length / size);
|
||||
|
||||
if (polygon.ContainsPoint(new Vector(px, py)))
|
||||
{
|
||||
mask[y * size + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,14 @@ namespace OpenNest
|
||||
}
|
||||
}
|
||||
|
||||
// Try pair-based approach first.
|
||||
var pairResult = FillWithPairs(item, workArea);
|
||||
var best = pairResult;
|
||||
var bestScore = FillScore.Compute(best, workArea);
|
||||
|
||||
Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts");
|
||||
|
||||
// Try linear phase.
|
||||
var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List<Part> parts)>();
|
||||
|
||||
System.Threading.Tasks.Parallel.ForEach(angles, angle =>
|
||||
@@ -120,37 +128,26 @@ namespace OpenNest
|
||||
linearBag.Add((FillScore.Compute(v, workArea), v));
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var (score, parts) in linearBag)
|
||||
{
|
||||
if (best == null || score > bestScore)
|
||||
if (score > bestScore)
|
||||
{
|
||||
best = parts;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default;
|
||||
Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}");
|
||||
Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}");
|
||||
|
||||
// Try rectangle best-fit (mixes orientations to fill remnant strips).
|
||||
var rectResult = FillRectangleBestFit(item, workArea);
|
||||
var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default;
|
||||
|
||||
Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts");
|
||||
Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts");
|
||||
|
||||
if (IsBetterFill(rectResult, best, workArea))
|
||||
if (rectScore > bestScore)
|
||||
best = rectResult;
|
||||
|
||||
// Try pair-based approach.
|
||||
var pairResult = FillWithPairs(item, workArea);
|
||||
|
||||
Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}");
|
||||
|
||||
if (IsBetterFill(pairResult, best, workArea))
|
||||
best = pairResult;
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
@@ -183,6 +180,15 @@ namespace OpenNest
|
||||
}
|
||||
}
|
||||
|
||||
// Pairs phase first
|
||||
var pairResult = FillWithPairs(item, workArea, token);
|
||||
best = pairResult;
|
||||
var bestScore = FillScore.Compute(best, workArea);
|
||||
|
||||
Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts");
|
||||
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Linear phase
|
||||
var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List<Part> parts)>();
|
||||
|
||||
@@ -200,44 +206,30 @@ namespace OpenNest
|
||||
linearBag.Add((FillScore.Compute(v, workArea), v));
|
||||
});
|
||||
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var (score, parts) in linearBag)
|
||||
{
|
||||
if (best == null || score > bestScore)
|
||||
if (score > bestScore)
|
||||
{
|
||||
best = parts;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default;
|
||||
Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}");
|
||||
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);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// RectBestFit phase
|
||||
var rectResult = FillRectangleBestFit(item, workArea);
|
||||
Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts");
|
||||
var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default;
|
||||
Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts");
|
||||
|
||||
if (IsBetterFill(rectResult, best, workArea))
|
||||
if (rectScore > bestScore)
|
||||
{
|
||||
best = rectResult;
|
||||
ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea);
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// Pairs phase
|
||||
var pairResult = FillWithPairs(item, workArea, token);
|
||||
Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}");
|
||||
|
||||
if (IsBetterFill(pairResult, best, workArea))
|
||||
{
|
||||
best = pairResult;
|
||||
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -366,7 +358,7 @@ namespace OpenNest
|
||||
{
|
||||
var result = candidates[i];
|
||||
var pairParts = result.BuildParts(item.Drawing);
|
||||
var angles = RotationAnalysis.FindHullEdgeAngles(pairParts);
|
||||
var angles = result.HullAngles;
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var filled = FillPattern(engine, pairParts, angles, workArea);
|
||||
|
||||
@@ -409,7 +401,7 @@ namespace OpenNest
|
||||
{
|
||||
var result = candidates[i];
|
||||
var pairParts = result.BuildParts(item.Drawing);
|
||||
var angles = RotationAnalysis.FindHullEdgeAngles(pairParts);
|
||||
var angles = result.HullAngles;
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var filled = FillPattern(engine, pairParts, angles, workArea);
|
||||
|
||||
@@ -687,35 +679,32 @@ namespace OpenNest
|
||||
|
||||
private List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||
{
|
||||
var bag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List<Part> parts)>();
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
System.Threading.Tasks.Parallel.ForEach(angles, angle =>
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var pattern = BuildRotatedPattern(groupParts, angle);
|
||||
|
||||
if (pattern.Parts.Count == 0)
|
||||
return;
|
||||
continue;
|
||||
|
||||
var localEngine = new FillLinear(workArea, engine.PartSpacing);
|
||||
var h = localEngine.Fill(pattern, NestDirection.Horizontal);
|
||||
var v = localEngine.Fill(pattern, NestDirection.Vertical);
|
||||
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||
var scoreH = h != null && h.Count > 0 ? FillScore.Compute(h, workArea) : default;
|
||||
|
||||
if (h != null && h.Count > 0 && !HasOverlaps(h, engine.PartSpacing))
|
||||
bag.Add((FillScore.Compute(h, workArea), h));
|
||||
|
||||
if (v != null && v.Count > 0 && !HasOverlaps(v, engine.PartSpacing))
|
||||
bag.Add((FillScore.Compute(v, workArea), v));
|
||||
});
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
foreach (var (score, parts) in bag)
|
||||
{
|
||||
if (best == null || score > bestScore)
|
||||
if (scoreH.Count > 0 && (best == null || scoreH > bestScore))
|
||||
{
|
||||
best = parts;
|
||||
bestScore = score;
|
||||
best = h;
|
||||
bestScore = scoreH;
|
||||
}
|
||||
|
||||
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||
var scoreV = v != null && v.Count > 0 ? FillScore.Compute(v, workArea) : default;
|
||||
|
||||
if (scoreV.Count > 0 && (best == null || scoreV > bestScore))
|
||||
{
|
||||
best = v;
|
||||
bestScore = scoreV;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,9 +723,13 @@ namespace OpenNest
|
||||
|
||||
var score = FillScore.Compute(best, workArea);
|
||||
var clonedParts = new List<Part>(best.Count);
|
||||
var totalPartArea = 0.0;
|
||||
|
||||
foreach (var part in best)
|
||||
{
|
||||
clonedParts.Add((Part)part.Clone());
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
}
|
||||
|
||||
progress.Report(new NestProgress
|
||||
{
|
||||
@@ -744,7 +737,7 @@ namespace OpenNest
|
||||
PlateNumber = plateNumber,
|
||||
BestPartCount = score.Count,
|
||||
BestDensity = score.Density,
|
||||
UsableRemnantArea = score.UsableRemnantArea,
|
||||
UsableRemnantArea = workArea.Area() - totalPartArea,
|
||||
BestParts = clonedParts
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,22 +23,26 @@ namespace OpenNest
|
||||
|
||||
public PartBoundary(Part part, double spacing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = Helper.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
var definedShape = new ShapeProfile(entities);
|
||||
var perimeter = definedShape.Perimeter;
|
||||
_polygons = new List<Polygon>();
|
||||
|
||||
foreach (var shape in shapes)
|
||||
if (perimeter != null)
|
||||
{
|
||||
var offsetEntity = shape.OffsetEntity(spacing, OffsetSide.Left) as Shape;
|
||||
var offsetEntity = perimeter.OffsetEntity(spacing, OffsetSide.Left) as Shape;
|
||||
|
||||
if (offsetEntity == null)
|
||||
continue;
|
||||
|
||||
// Circumscribe arcs so polygon vertices are always outside
|
||||
// the true arc — guarantees the boundary never under-estimates.
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true);
|
||||
polygon.RemoveSelfIntersections();
|
||||
_polygons.Add(polygon);
|
||||
if (offsetEntity != null)
|
||||
{
|
||||
// Circumscribe arcs so polygon vertices are always outside
|
||||
// the true arc — guarantees the boundary never under-estimates.
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true);
|
||||
polygon.RemoveSelfIntersections();
|
||||
_polygons.Add(polygon);
|
||||
}
|
||||
}
|
||||
|
||||
PrecomputeDirectionalEdges(
|
||||
|
||||
@@ -80,6 +80,11 @@ namespace OpenNest
|
||||
return new List<double> { 0 };
|
||||
|
||||
var hull = ConvexHull.Compute(points);
|
||||
return GetHullEdgeAngles(hull);
|
||||
}
|
||||
|
||||
public static List<double> GetHullEdgeAngles(Polygon hull)
|
||||
{
|
||||
var vertices = hull.Vertices;
|
||||
var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace OpenNest.Gpu
|
||||
private static bool _probed;
|
||||
private static bool _gpuAvailable;
|
||||
private static string _deviceName;
|
||||
private static GpuSlideComputer _slideComputer;
|
||||
private static readonly object _slideLock = new object();
|
||||
|
||||
public static bool GpuAvailable
|
||||
{
|
||||
@@ -46,6 +48,29 @@ namespace OpenNest.Gpu
|
||||
}
|
||||
}
|
||||
|
||||
public static ISlideComputer CreateSlideComputer()
|
||||
{
|
||||
if (!GpuAvailable)
|
||||
return null;
|
||||
|
||||
lock (_slideLock)
|
||||
{
|
||||
if (_slideComputer != null)
|
||||
return _slideComputer;
|
||||
|
||||
try
|
||||
{
|
||||
_slideComputer = new GpuSlideComputer();
|
||||
return _slideComputer;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[GpuEvaluatorFactory] GPU slide computer failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Probe()
|
||||
{
|
||||
_probed = true;
|
||||
|
||||
460
OpenNest.Gpu/GpuSlideComputer.cs
Normal file
460
OpenNest.Gpu/GpuSlideComputer.cs
Normal file
@@ -0,0 +1,460 @@
|
||||
using System;
|
||||
using ILGPU;
|
||||
using ILGPU.Runtime;
|
||||
using ILGPU.Algorithms;
|
||||
using OpenNest.Engine.BestFit;
|
||||
|
||||
namespace OpenNest.Gpu
|
||||
{
|
||||
public class GpuSlideComputer : ISlideComputer
|
||||
{
|
||||
private readonly Context _context;
|
||||
private readonly Accelerator _accelerator;
|
||||
private readonly object _lock = new object();
|
||||
|
||||
// ── Kernels ──────────────────────────────────────────────────
|
||||
|
||||
private readonly Action<Index1D,
|
||||
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // movingPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // offsets
|
||||
ArrayView1D<double, Stride1D.Dense>, // results
|
||||
int, int, int> _kernel;
|
||||
|
||||
private readonly Action<Index1D,
|
||||
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // movingPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // offsets
|
||||
ArrayView1D<double, Stride1D.Dense>, // results
|
||||
ArrayView1D<int, Stride1D.Dense>, // directions
|
||||
int, int> _kernelMultiDir;
|
||||
|
||||
private readonly Action<Index1D,
|
||||
ArrayView1D<double, Stride1D.Dense>, // raw
|
||||
ArrayView1D<double, Stride1D.Dense>, // prepared
|
||||
int> _prepareKernel;
|
||||
|
||||
// ── Buffers ──────────────────────────────────────────────────
|
||||
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryRaw;
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryPrep;
|
||||
private double[]? _lastStationaryData; // Keep CPU copy/ref for content check
|
||||
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingRaw;
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingPrep;
|
||||
private double[]? _lastMovingData; // Keep CPU copy/ref for content check
|
||||
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuOffsets;
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuResults;
|
||||
private MemoryBuffer1D<int, Stride1D.Dense>? _gpuDirs;
|
||||
private int _offsetCapacity;
|
||||
|
||||
public GpuSlideComputer()
|
||||
{
|
||||
_context = Context.CreateDefault();
|
||||
_accelerator = _context.GetPreferredDevice(preferCPU: false)
|
||||
.CreateAccelerator(_context);
|
||||
|
||||
_kernel = _accelerator.LoadAutoGroupedStreamKernel<
|
||||
Index1D,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
int, int, int>(SlideKernel);
|
||||
|
||||
_kernelMultiDir = _accelerator.LoadAutoGroupedStreamKernel<
|
||||
Index1D,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<int, Stride1D.Dense>,
|
||||
int, int>(SlideKernelMultiDir);
|
||||
|
||||
_prepareKernel = _accelerator.LoadAutoGroupedStreamKernel<
|
||||
Index1D,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
int>(PrepareKernel);
|
||||
}
|
||||
|
||||
public double[] ComputeBatch(
|
||||
double[] stationarySegments, int stationaryCount,
|
||||
double[] movingTemplateSegments, int movingCount,
|
||||
double[] offsets, int offsetCount,
|
||||
PushDirection direction)
|
||||
{
|
||||
var results = new double[offsetCount];
|
||||
if (offsetCount == 0 || stationaryCount == 0 || movingCount == 0)
|
||||
{
|
||||
Array.Fill(results, double.MaxValue);
|
||||
return results;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureStationary(stationarySegments, stationaryCount);
|
||||
EnsureMoving(movingTemplateSegments, movingCount);
|
||||
EnsureOffsetBuffers(offsetCount);
|
||||
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets);
|
||||
|
||||
_kernel(offsetCount,
|
||||
_gpuStationaryPrep!.View, _gpuMovingPrep!.View,
|
||||
_gpuOffsets.View, _gpuResults!.View,
|
||||
stationaryCount, movingCount, (int)direction);
|
||||
|
||||
_accelerator.Synchronize();
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public double[] ComputeBatchMultiDir(
|
||||
double[] stationarySegments, int stationaryCount,
|
||||
double[] movingTemplateSegments, int movingCount,
|
||||
double[] offsets, int offsetCount,
|
||||
int[] directions)
|
||||
{
|
||||
var results = new double[offsetCount];
|
||||
if (offsetCount == 0 || stationaryCount == 0 || movingCount == 0)
|
||||
{
|
||||
Array.Fill(results, double.MaxValue);
|
||||
return results;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureStationary(stationarySegments, stationaryCount);
|
||||
EnsureMoving(movingTemplateSegments, movingCount);
|
||||
EnsureOffsetBuffers(offsetCount);
|
||||
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets);
|
||||
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(directions);
|
||||
|
||||
_kernelMultiDir(offsetCount,
|
||||
_gpuStationaryPrep!.View, _gpuMovingPrep!.View,
|
||||
_gpuOffsets.View, _gpuResults!.View, _gpuDirs.View,
|
||||
stationaryCount, movingCount);
|
||||
|
||||
_accelerator.Synchronize();
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public void InvalidateStationary() => _lastStationaryData = null;
|
||||
public void InvalidateMoving() => _lastMovingData = null;
|
||||
|
||||
private void EnsureStationary(double[] data, int count)
|
||||
{
|
||||
// Fast check: if same object or content is identical, skip upload
|
||||
if (_gpuStationaryPrep != null &&
|
||||
_lastStationaryData != null &&
|
||||
_lastStationaryData.Length == data.Length)
|
||||
{
|
||||
// Reference equality or content equality
|
||||
if (_lastStationaryData == data ||
|
||||
new ReadOnlySpan<double>(_lastStationaryData).SequenceEqual(new ReadOnlySpan<double>(data)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_gpuStationaryRaw?.Dispose();
|
||||
_gpuStationaryPrep?.Dispose();
|
||||
|
||||
_gpuStationaryRaw = _accelerator.Allocate1D(data);
|
||||
_gpuStationaryPrep = _accelerator.Allocate1D<double>(count * 10);
|
||||
|
||||
_prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count);
|
||||
_accelerator.Synchronize();
|
||||
|
||||
_lastStationaryData = data; // store reference for next comparison
|
||||
}
|
||||
|
||||
private void EnsureMoving(double[] data, int count)
|
||||
{
|
||||
if (_gpuMovingPrep != null &&
|
||||
_lastMovingData != null &&
|
||||
_lastMovingData.Length == data.Length)
|
||||
{
|
||||
if (_lastMovingData == data ||
|
||||
new ReadOnlySpan<double>(_lastMovingData).SequenceEqual(new ReadOnlySpan<double>(data)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_gpuMovingRaw?.Dispose();
|
||||
_gpuMovingPrep?.Dispose();
|
||||
|
||||
_gpuMovingRaw = _accelerator.Allocate1D(data);
|
||||
_gpuMovingPrep = _accelerator.Allocate1D<double>(count * 10);
|
||||
|
||||
_prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count);
|
||||
_accelerator.Synchronize();
|
||||
|
||||
_lastMovingData = data;
|
||||
}
|
||||
|
||||
private void EnsureOffsetBuffers(int offsetCount)
|
||||
{
|
||||
if (_offsetCapacity >= offsetCount)
|
||||
return;
|
||||
|
||||
var newCapacity = System.Math.Max(offsetCount, _offsetCapacity * 3 / 2);
|
||||
|
||||
_gpuOffsets?.Dispose();
|
||||
_gpuResults?.Dispose();
|
||||
_gpuDirs?.Dispose();
|
||||
|
||||
_gpuOffsets = _accelerator.Allocate1D<double>(newCapacity * 2);
|
||||
_gpuResults = _accelerator.Allocate1D<double>(newCapacity);
|
||||
_gpuDirs = _accelerator.Allocate1D<int>(newCapacity);
|
||||
|
||||
_offsetCapacity = newCapacity;
|
||||
}
|
||||
|
||||
// ── Preparation Kernel ───────────────────────────────────────
|
||||
|
||||
private static void PrepareKernel(
|
||||
Index1D index,
|
||||
ArrayView1D<double, Stride1D.Dense> raw,
|
||||
ArrayView1D<double, Stride1D.Dense> prepared,
|
||||
int count)
|
||||
{
|
||||
if (index >= count) return;
|
||||
var x1 = raw[index * 4 + 0];
|
||||
var y1 = raw[index * 4 + 1];
|
||||
var x2 = raw[index * 4 + 2];
|
||||
var y2 = raw[index * 4 + 3];
|
||||
|
||||
prepared[index * 10 + 0] = x1;
|
||||
prepared[index * 10 + 1] = y1;
|
||||
prepared[index * 10 + 2] = x2;
|
||||
prepared[index * 10 + 3] = y2;
|
||||
|
||||
var dx = x2 - x1;
|
||||
var dy = y2 - y1;
|
||||
|
||||
// invD is used for parameter 't'. We use a small epsilon for stability.
|
||||
prepared[index * 10 + 4] = (XMath.Abs(dx) < 1e-9) ? 0 : 1.0 / dx;
|
||||
prepared[index * 10 + 5] = (XMath.Abs(dy) < 1e-9) ? 0 : 1.0 / dy;
|
||||
|
||||
prepared[index * 10 + 6] = XMath.Min(x1, x2);
|
||||
prepared[index * 10 + 7] = XMath.Max(x1, x2);
|
||||
prepared[index * 10 + 8] = XMath.Min(y1, y2);
|
||||
prepared[index * 10 + 9] = XMath.Max(y1, y2);
|
||||
}
|
||||
|
||||
// ── Main Slide Kernels ───────────────────────────────────────
|
||||
|
||||
private static void SlideKernel(
|
||||
Index1D index,
|
||||
ArrayView1D<double, Stride1D.Dense> stationaryPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> movingPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> offsets,
|
||||
ArrayView1D<double, Stride1D.Dense> results,
|
||||
int sCount, int mCount, int direction)
|
||||
{
|
||||
if (index >= results.Length) return;
|
||||
|
||||
var dx = offsets[index * 2];
|
||||
var dy = offsets[index * 2 + 1];
|
||||
|
||||
results[index] = ComputeSlideLean(
|
||||
stationaryPrep, movingPrep, dx, dy, sCount, mCount, direction);
|
||||
}
|
||||
|
||||
private static void SlideKernelMultiDir(
|
||||
Index1D index,
|
||||
ArrayView1D<double, Stride1D.Dense> stationaryPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> movingPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> offsets,
|
||||
ArrayView1D<double, Stride1D.Dense> results,
|
||||
ArrayView1D<int, Stride1D.Dense> directions,
|
||||
int sCount, int mCount)
|
||||
{
|
||||
if (index >= results.Length) return;
|
||||
|
||||
var dx = offsets[index * 2];
|
||||
var dy = offsets[index * 2 + 1];
|
||||
var dir = directions[index];
|
||||
|
||||
results[index] = ComputeSlideLean(
|
||||
stationaryPrep, movingPrep, dx, dy, sCount, mCount, dir);
|
||||
}
|
||||
|
||||
private static double ComputeSlideLean(
|
||||
ArrayView1D<double, Stride1D.Dense> sPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> mPrep,
|
||||
double dx, double dy, int sCount, int mCount, int direction)
|
||||
{
|
||||
const double eps = 0.00001;
|
||||
var minDist = double.MaxValue;
|
||||
var horizontal = direction >= 2;
|
||||
var oppDir = direction ^ 1;
|
||||
|
||||
// ── Forward Pass: moving vertices vs stationary edges ─────
|
||||
for (int i = 0; i < mCount; i++)
|
||||
{
|
||||
var m1x = mPrep[i * 10 + 0] + dx;
|
||||
var m1y = mPrep[i * 10 + 1] + dy;
|
||||
var m2x = mPrep[i * 10 + 2] + dx;
|
||||
var m2y = mPrep[i * 10 + 3] + dy;
|
||||
|
||||
for (int j = 0; j < sCount; j++)
|
||||
{
|
||||
var sMin = horizontal ? sPrep[j * 10 + 8] : sPrep[j * 10 + 6];
|
||||
var sMax = horizontal ? sPrep[j * 10 + 9] : sPrep[j * 10 + 7];
|
||||
|
||||
// Test moving vertex 1 against stationary edge j
|
||||
var mv1 = horizontal ? m1y : m1x;
|
||||
if (mv1 >= sMin - eps && mv1 <= sMax + eps)
|
||||
{
|
||||
var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
// Test moving vertex 2 against stationary edge j
|
||||
var mv2 = horizontal ? m2y : m2x;
|
||||
if (mv2 >= sMin - eps && mv2 <= sMax + eps)
|
||||
{
|
||||
var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reverse Pass: stationary vertices vs moving edges ─────
|
||||
for (int i = 0; i < sCount; i++)
|
||||
{
|
||||
var s1x = sPrep[i * 10 + 0];
|
||||
var s1y = sPrep[i * 10 + 1];
|
||||
var s2x = sPrep[i * 10 + 2];
|
||||
var s2y = sPrep[i * 10 + 3];
|
||||
|
||||
for (int j = 0; j < mCount; j++)
|
||||
{
|
||||
var mMin = horizontal ? (mPrep[j * 10 + 8] + dy) : (mPrep[j * 10 + 6] + dx);
|
||||
var mMax = horizontal ? (mPrep[j * 10 + 9] + dy) : (mPrep[j * 10 + 7] + dx);
|
||||
|
||||
// Test stationary vertex 1 against moving edge j
|
||||
var sv1 = horizontal ? s1y : s1x;
|
||||
if (sv1 >= mMin - eps && sv1 <= mMax + eps)
|
||||
{
|
||||
var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
// Test stationary vertex 2 against moving edge j
|
||||
var sv2 = horizontal ? s2y : s2x;
|
||||
if (sv2 >= mMin - eps && sv2 <= mMax + eps)
|
||||
{
|
||||
var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private static double RayEdgeLean(
|
||||
double vx, double vy,
|
||||
ArrayView1D<double, Stride1D.Dense> sPrep, int j,
|
||||
int direction, double eps)
|
||||
{
|
||||
var p1x = sPrep[j * 10 + 0];
|
||||
var p1y = sPrep[j * 10 + 1];
|
||||
var p2x = sPrep[j * 10 + 2];
|
||||
var p2y = sPrep[j * 10 + 3];
|
||||
|
||||
if (direction >= 2) // Horizontal (Left=2, Right=3)
|
||||
{
|
||||
var invDy = sPrep[j * 10 + 5];
|
||||
if (invDy == 0) return double.MaxValue;
|
||||
|
||||
var t = (vy - p1y) * invDy;
|
||||
if (t < -eps || t > 1.0 + eps) return double.MaxValue;
|
||||
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = (direction == 2) ? (vx - ix) : (ix - vx);
|
||||
|
||||
if (dist > eps) return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
}
|
||||
else // Vertical (Up=0, Down=1)
|
||||
{
|
||||
var invDx = sPrep[j * 10 + 4];
|
||||
if (invDx == 0) return double.MaxValue;
|
||||
|
||||
var t = (vx - p1x) * invDx;
|
||||
if (t < -eps || t > 1.0 + eps) return double.MaxValue;
|
||||
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = (direction == 1) ? (vy - iy) : (iy - vy);
|
||||
|
||||
if (dist > eps) return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static double RayEdgeLeanMoving(
|
||||
double vx, double vy,
|
||||
ArrayView1D<double, Stride1D.Dense> mPrep, int j,
|
||||
double dx, double dy, int direction, double eps)
|
||||
{
|
||||
var p1x = mPrep[j * 10 + 0] + dx;
|
||||
var p1y = mPrep[j * 10 + 1] + dy;
|
||||
var p2x = mPrep[j * 10 + 2] + dx;
|
||||
var p2y = mPrep[j * 10 + 3] + dy;
|
||||
|
||||
if (direction >= 2) // Horizontal
|
||||
{
|
||||
var invDy = mPrep[j * 10 + 5];
|
||||
if (invDy == 0) return double.MaxValue;
|
||||
|
||||
var t = (vy - p1y) * invDy;
|
||||
if (t < -eps || t > 1.0 + eps) return double.MaxValue;
|
||||
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = (direction == 2) ? (vx - ix) : (ix - vx);
|
||||
|
||||
if (dist > eps) return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
}
|
||||
else // Vertical
|
||||
{
|
||||
var invDx = mPrep[j * 10 + 4];
|
||||
if (invDx == 0) return double.MaxValue;
|
||||
|
||||
var t = (vx - p1x) * invDx;
|
||||
if (t < -eps || t > 1.0 + eps) return double.MaxValue;
|
||||
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = (direction == 1) ? (vy - iy) : (iy - vy);
|
||||
|
||||
if (dist > eps) return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_gpuStationaryRaw?.Dispose();
|
||||
_gpuStationaryPrep?.Dispose();
|
||||
_gpuMovingRaw?.Dispose();
|
||||
_gpuMovingPrep?.Dispose();
|
||||
_gpuOffsets?.Dispose();
|
||||
_gpuResults?.Dispose();
|
||||
_gpuDirs?.Dispose();
|
||||
_accelerator?.Dispose();
|
||||
_context?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,5 +122,32 @@ namespace OpenNest.IO
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
}
|
||||
|
||||
public record BestFitSetDto
|
||||
{
|
||||
public double PlateWidth { get; init; }
|
||||
public double PlateHeight { get; init; }
|
||||
public double Spacing { get; init; }
|
||||
public List<BestFitResultDto> Results { get; init; } = new();
|
||||
}
|
||||
|
||||
public record BestFitResultDto
|
||||
{
|
||||
public double Part1Rotation { get; init; }
|
||||
public double Part2Rotation { get; init; }
|
||||
public double Part2OffsetX { get; init; }
|
||||
public double Part2OffsetY { get; init; }
|
||||
public int StrategyType { get; init; }
|
||||
public int TestNumber { get; init; }
|
||||
public double CandidateSpacing { get; init; }
|
||||
public double RotatedArea { get; init; }
|
||||
public double BoundingWidth { get; init; }
|
||||
public double BoundingHeight { get; init; }
|
||||
public double OptimalRotation { get; init; }
|
||||
public bool Keep { get; init; }
|
||||
public string Reason { get; init; } = "";
|
||||
public double TrueArea { get; init; }
|
||||
public List<double> HullAngles { get; init; } = new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.IO.NestFormat;
|
||||
|
||||
@@ -35,6 +36,7 @@ namespace OpenNest.IO
|
||||
|
||||
var programs = ReadPrograms(dto.Drawings.Count);
|
||||
var drawingMap = BuildDrawings(dto, programs);
|
||||
ReadBestFits(drawingMap);
|
||||
var nest = BuildNest(dto, drawingMap);
|
||||
|
||||
zipArchive.Dispose();
|
||||
@@ -97,6 +99,54 @@ namespace OpenNest.IO
|
||||
return map;
|
||||
}
|
||||
|
||||
private void ReadBestFits(Dictionary<int, Drawing> drawingMap)
|
||||
{
|
||||
foreach (var kvp in drawingMap)
|
||||
{
|
||||
var entry = zipArchive.GetEntry($"bestfits/bestfit-{kvp.Key}");
|
||||
if (entry == null) continue;
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var reader = new StreamReader(entryStream);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
var sets = JsonSerializer.Deserialize<List<BestFitSetDto>>(json, JsonOptions);
|
||||
if (sets == null) continue;
|
||||
|
||||
PopulateBestFitSets(kvp.Value, sets);
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateBestFitSets(Drawing drawing, List<BestFitSetDto> sets)
|
||||
{
|
||||
foreach (var set in sets)
|
||||
{
|
||||
var results = set.Results.Select(r => new BestFitResult
|
||||
{
|
||||
Candidate = new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = r.Part1Rotation,
|
||||
Part2Rotation = r.Part2Rotation,
|
||||
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
|
||||
StrategyType = r.StrategyType,
|
||||
TestNumber = r.TestNumber,
|
||||
Spacing = r.CandidateSpacing
|
||||
},
|
||||
RotatedArea = r.RotatedArea,
|
||||
BoundingWidth = r.BoundingWidth,
|
||||
BoundingHeight = r.BoundingHeight,
|
||||
OptimalRotation = r.OptimalRotation,
|
||||
Keep = r.Keep,
|
||||
Reason = r.Reason,
|
||||
TrueArea = r.TrueArea,
|
||||
HullAngles = r.HullAngles
|
||||
}).ToList();
|
||||
|
||||
BestFitCache.Populate(drawing, set.PlateWidth, set.PlateHeight, set.Spacing, results);
|
||||
}
|
||||
}
|
||||
|
||||
private Nest BuildNest(NestDto dto, Dictionary<int, Drawing> drawingMap)
|
||||
{
|
||||
var nest = new Nest();
|
||||
|
||||
@@ -6,6 +6,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using static OpenNest.IO.NestFormat;
|
||||
|
||||
@@ -35,6 +37,7 @@ namespace OpenNest.IO
|
||||
|
||||
WriteNestJson(zipArchive);
|
||||
WritePrograms(zipArchive);
|
||||
WriteBestFits(zipArchive);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -185,6 +188,70 @@ namespace OpenNest.IO
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<BestFitSetDto> BuildBestFitDtos(Drawing drawing)
|
||||
{
|
||||
var allBestFits = BestFitCache.GetAllForDrawing(drawing);
|
||||
var sets = new List<BestFitSetDto>();
|
||||
|
||||
// Only save best-fit sets for plate sizes actually used in this nest.
|
||||
var plateSizes = new HashSet<(double, double, double)>();
|
||||
foreach (var plate in nest.Plates)
|
||||
plateSizes.Add((plate.Size.Width, plate.Size.Length, plate.PartSpacing));
|
||||
|
||||
foreach (var kvp in allBestFits)
|
||||
{
|
||||
if (!plateSizes.Contains((kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)))
|
||||
continue;
|
||||
|
||||
var results = kvp.Value
|
||||
.Where(r => r.Keep)
|
||||
.Select(r => new BestFitResultDto
|
||||
{
|
||||
Part1Rotation = r.Candidate.Part1Rotation,
|
||||
Part2Rotation = r.Candidate.Part2Rotation,
|
||||
Part2OffsetX = r.Candidate.Part2Offset.X,
|
||||
Part2OffsetY = r.Candidate.Part2Offset.Y,
|
||||
StrategyType = r.Candidate.StrategyType,
|
||||
TestNumber = r.Candidate.TestNumber,
|
||||
CandidateSpacing = r.Candidate.Spacing,
|
||||
RotatedArea = r.RotatedArea,
|
||||
BoundingWidth = r.BoundingWidth,
|
||||
BoundingHeight = r.BoundingHeight,
|
||||
OptimalRotation = r.OptimalRotation,
|
||||
Keep = r.Keep,
|
||||
Reason = r.Reason ?? "",
|
||||
TrueArea = r.TrueArea,
|
||||
HullAngles = r.HullAngles ?? new List<double>()
|
||||
}).ToList();
|
||||
|
||||
sets.Add(new BestFitSetDto
|
||||
{
|
||||
PlateWidth = kvp.Key.PlateWidth,
|
||||
PlateHeight = kvp.Key.PlateHeight,
|
||||
Spacing = kvp.Key.Spacing,
|
||||
Results = results
|
||||
});
|
||||
}
|
||||
|
||||
return sets;
|
||||
}
|
||||
|
||||
private void WriteBestFits(ZipArchive zipArchive)
|
||||
{
|
||||
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
||||
{
|
||||
var sets = BuildBestFitDtos(kvp.Value);
|
||||
if (sets.Count == 0)
|
||||
continue;
|
||||
|
||||
var json = JsonSerializer.Serialize(sets, JsonOptions);
|
||||
var entry = zipArchive.CreateEntry($"bestfits/bestfit-{kvp.Key}");
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||
writer.Write(json);
|
||||
}
|
||||
}
|
||||
|
||||
private void WritePrograms(ZipArchive zipArchive)
|
||||
{
|
||||
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<PackageReference Include="ACadSharp" Version="3.1.32" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
60
OpenNest/Forms/BestFitViewerForm.Designer.cs
generated
60
OpenNest/Forms/BestFitViewerForm.Designer.cs
generated
@@ -14,11 +14,15 @@ namespace OpenNest.Forms
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.gridPanel = new System.Windows.Forms.TableLayoutPanel();
|
||||
this.navPanel = new System.Windows.Forms.Panel();
|
||||
this.btnPrev = new System.Windows.Forms.Button();
|
||||
this.btnNext = new System.Windows.Forms.Button();
|
||||
this.lblPage = new System.Windows.Forms.Label();
|
||||
this.navPanel.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// gridPanel
|
||||
//
|
||||
this.gridPanel.AutoScroll = true;
|
||||
this.gridPanel.ColumnCount = 5;
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
@@ -28,24 +32,72 @@ namespace OpenNest.Forms
|
||||
this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.gridPanel.Location = new System.Drawing.Point(0, 0);
|
||||
this.gridPanel.Name = "gridPanel";
|
||||
this.gridPanel.RowCount = 1;
|
||||
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle());
|
||||
this.gridPanel.Size = new System.Drawing.Size(1200, 800);
|
||||
this.gridPanel.RowCount = 2;
|
||||
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F));
|
||||
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F));
|
||||
this.gridPanel.Size = new System.Drawing.Size(1200, 764);
|
||||
this.gridPanel.TabIndex = 0;
|
||||
//
|
||||
// navPanel
|
||||
//
|
||||
this.navPanel.Controls.Add(this.btnPrev);
|
||||
this.navPanel.Controls.Add(this.lblPage);
|
||||
this.navPanel.Controls.Add(this.btnNext);
|
||||
this.navPanel.Dock = System.Windows.Forms.DockStyle.Bottom;
|
||||
this.navPanel.Location = new System.Drawing.Point(0, 764);
|
||||
this.navPanel.Name = "navPanel";
|
||||
this.navPanel.Size = new System.Drawing.Size(1200, 36);
|
||||
this.navPanel.TabIndex = 1;
|
||||
//
|
||||
// btnPrev
|
||||
//
|
||||
this.btnPrev.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
this.btnPrev.Location = new System.Drawing.Point(4, 4);
|
||||
this.btnPrev.Name = "btnPrev";
|
||||
this.btnPrev.Size = new System.Drawing.Size(80, 28);
|
||||
this.btnPrev.TabIndex = 0;
|
||||
this.btnPrev.Text = "< Prev";
|
||||
this.btnPrev.Click += new System.EventHandler(this.btnPrev_Click);
|
||||
//
|
||||
// lblPage
|
||||
//
|
||||
this.lblPage.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.lblPage.Name = "lblPage";
|
||||
this.lblPage.Size = new System.Drawing.Size(1200, 36);
|
||||
this.lblPage.TabIndex = 1;
|
||||
this.lblPage.Text = "Page 1 / 1";
|
||||
this.lblPage.TextAlign = System.Drawing.ContentAlignment.MiddleCenter;
|
||||
//
|
||||
// btnNext
|
||||
//
|
||||
this.btnNext.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
|
||||
this.btnNext.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
this.btnNext.Location = new System.Drawing.Point(1116, 4);
|
||||
this.btnNext.Name = "btnNext";
|
||||
this.btnNext.Size = new System.Drawing.Size(80, 28);
|
||||
this.btnNext.TabIndex = 2;
|
||||
this.btnNext.Text = "Next >";
|
||||
this.btnNext.Click += new System.EventHandler(this.btnNext_Click);
|
||||
//
|
||||
// BestFitViewerForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(1200, 800);
|
||||
this.Controls.Add(this.gridPanel);
|
||||
this.Controls.Add(this.navPanel);
|
||||
this.KeyPreview = true;
|
||||
this.Name = "BestFitViewerForm";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "Best-Fit Viewer";
|
||||
this.navPanel.ResumeLayout(false);
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
|
||||
private System.Windows.Forms.TableLayoutPanel gridPanel;
|
||||
private System.Windows.Forms.Panel navPanel;
|
||||
private System.Windows.Forms.Button btnPrev;
|
||||
private System.Windows.Forms.Button btnNext;
|
||||
private System.Windows.Forms.Label lblPage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
@@ -9,7 +10,8 @@ namespace OpenNest.Forms
|
||||
public partial class BestFitViewerForm : Form
|
||||
{
|
||||
private const int Columns = 5;
|
||||
private const int RowHeight = 300;
|
||||
private const int Rows = 2;
|
||||
private const int ItemsPerPage = Columns * Rows;
|
||||
private const int MaxResults = 50;
|
||||
|
||||
private static readonly Color KeptColor = Color.FromArgb(0, 0, 100);
|
||||
@@ -18,6 +20,14 @@ namespace OpenNest.Forms
|
||||
private readonly Drawing drawing;
|
||||
private readonly Plate plate;
|
||||
|
||||
private List<BestFitResult> results;
|
||||
private int totalResults;
|
||||
private int keptCount;
|
||||
private double computeSeconds;
|
||||
private double totalSeconds;
|
||||
private int currentPage;
|
||||
private int pageCount;
|
||||
|
||||
public BestFitResult SelectedResult { get; private set; }
|
||||
|
||||
public BestFitViewerForm(Drawing drawing, Plate plate)
|
||||
@@ -33,7 +43,8 @@ namespace OpenNest.Forms
|
||||
Cursor = Cursors.WaitCursor;
|
||||
try
|
||||
{
|
||||
PopulateGrid(drawing, plate);
|
||||
ComputeResults();
|
||||
ShowPage(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -48,51 +59,84 @@ namespace OpenNest.Forms
|
||||
Close();
|
||||
return true;
|
||||
}
|
||||
if (keyData == Keys.Left || keyData == Keys.PageUp)
|
||||
{
|
||||
NavigatePage(-1);
|
||||
return true;
|
||||
}
|
||||
if (keyData == Keys.Right || keyData == Keys.PageDown)
|
||||
{
|
||||
NavigatePage(1);
|
||||
return true;
|
||||
}
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
|
||||
private void PopulateGrid(Drawing drawing, Plate plate)
|
||||
private void ComputeResults()
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var results = BestFitCache.GetOrCompute(
|
||||
var all = BestFitCache.GetOrCompute(
|
||||
drawing, plate.Size.Width, plate.Size.Length, plate.PartSpacing);
|
||||
|
||||
var findMs = sw.ElapsedMilliseconds;
|
||||
var total = results.Count;
|
||||
var kept = 0;
|
||||
computeSeconds = sw.ElapsedMilliseconds / 1000.0;
|
||||
totalResults = all.Count;
|
||||
keptCount = 0;
|
||||
|
||||
foreach (var r in results)
|
||||
foreach (var r in all)
|
||||
{
|
||||
if (r.Keep) kept++;
|
||||
if (r.Keep) keptCount++;
|
||||
}
|
||||
|
||||
var count = System.Math.Min(total, MaxResults);
|
||||
var rows = (int)System.Math.Ceiling(count / (double)Columns);
|
||||
gridPanel.RowCount = rows;
|
||||
gridPanel.RowStyles.Clear();
|
||||
|
||||
for (var i = 0; i < rows; i++)
|
||||
gridPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, RowHeight));
|
||||
|
||||
gridPanel.SuspendLayout();
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var result = results[i];
|
||||
var cell = CreateCell(result, drawing, i + 1);
|
||||
gridPanel.Controls.Add(cell, i % Columns, i / Columns);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
gridPanel.ResumeLayout(true);
|
||||
}
|
||||
var count = System.Math.Min(totalResults, MaxResults);
|
||||
results = all.GetRange(0, count);
|
||||
pageCount = System.Math.Max(1, (int)System.Math.Ceiling(count / (double)ItemsPerPage));
|
||||
|
||||
sw.Stop();
|
||||
Text = string.Format("Best-Fit Viewer — {0} candidates ({1} kept) | Compute: {2:F1}s | Total: {3:F1}s | Showing {4}",
|
||||
total, kept, findMs / 1000.0, sw.Elapsed.TotalSeconds, count);
|
||||
totalSeconds = sw.Elapsed.TotalSeconds;
|
||||
}
|
||||
|
||||
private void ShowPage(int page)
|
||||
{
|
||||
currentPage = page;
|
||||
var start = page * ItemsPerPage;
|
||||
var count = System.Math.Min(ItemsPerPage, results.Count - start);
|
||||
|
||||
gridPanel.SuspendLayout();
|
||||
gridPanel.Controls.Clear();
|
||||
|
||||
gridPanel.RowCount = Rows;
|
||||
gridPanel.RowStyles.Clear();
|
||||
for (var i = 0; i < Rows; i++)
|
||||
gridPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / Rows));
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var result = results[start + i];
|
||||
var cell = CreateCell(result, drawing, start + i + 1);
|
||||
gridPanel.Controls.Add(cell, i % Columns, i / Columns);
|
||||
}
|
||||
|
||||
gridPanel.ResumeLayout(true);
|
||||
|
||||
btnPrev.Enabled = currentPage > 0;
|
||||
btnNext.Enabled = currentPage < pageCount - 1;
|
||||
lblPage.Text = string.Format("Page {0} / {1}", currentPage + 1, pageCount);
|
||||
|
||||
Text = string.Format("Best-Fit Viewer — {0} candidates ({1} kept) | Compute: {2:F1}s | Total: {3:F1}s | Showing {4}-{5} of {6}",
|
||||
totalResults, keptCount, computeSeconds, totalSeconds,
|
||||
start + 1, start + count, results.Count);
|
||||
}
|
||||
|
||||
private void btnPrev_Click(object sender, System.EventArgs e) => NavigatePage(-1);
|
||||
|
||||
private void btnNext_Click(object sender, System.EventArgs e) => NavigatePage(1);
|
||||
|
||||
private void NavigatePage(int delta)
|
||||
{
|
||||
var newPage = currentPage + delta;
|
||||
if (newPage >= 0 && newPage < pageCount)
|
||||
ShowPage(newPage);
|
||||
}
|
||||
|
||||
private BestFitCell CreateCell(BestFitResult result, Drawing drawing, int rank)
|
||||
|
||||
@@ -50,6 +50,9 @@ namespace OpenNest.Forms
|
||||
|
||||
//if (GpuEvaluatorFactory.GpuAvailable)
|
||||
// BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);
|
||||
|
||||
if (GpuEvaluatorFactory.GpuAvailable)
|
||||
BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer();
|
||||
}
|
||||
|
||||
private Nest CreateDefaultNest()
|
||||
|
||||
30
OpenNest/Forms/NestProgressForm.Designer.cs
generated
30
OpenNest/Forms/NestProgressForm.Designer.cs
generated
@@ -39,6 +39,8 @@ namespace OpenNest.Forms
|
||||
this.densityValue = new System.Windows.Forms.Label();
|
||||
this.remnantLabel = new System.Windows.Forms.Label();
|
||||
this.remnantValue = new System.Windows.Forms.Label();
|
||||
this.elapsedLabel = new System.Windows.Forms.Label();
|
||||
this.elapsedValue = new System.Windows.Forms.Label();
|
||||
this.stopButton = new System.Windows.Forms.Button();
|
||||
this.buttonPanel = new System.Windows.Forms.FlowLayoutPanel();
|
||||
this.table.SuspendLayout();
|
||||
@@ -60,18 +62,21 @@ namespace OpenNest.Forms
|
||||
this.table.Controls.Add(this.densityValue, 1, 3);
|
||||
this.table.Controls.Add(this.remnantLabel, 0, 4);
|
||||
this.table.Controls.Add(this.remnantValue, 1, 4);
|
||||
this.table.Controls.Add(this.elapsedLabel, 0, 5);
|
||||
this.table.Controls.Add(this.elapsedValue, 1, 5);
|
||||
this.table.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
this.table.Location = new System.Drawing.Point(0, 0);
|
||||
this.table.Name = "table";
|
||||
this.table.Padding = new System.Windows.Forms.Padding(8);
|
||||
this.table.RowCount = 5;
|
||||
this.table.RowCount = 6;
|
||||
this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));
|
||||
this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));
|
||||
this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));
|
||||
this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));
|
||||
this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));
|
||||
this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));
|
||||
this.table.AutoSize = true;
|
||||
this.table.Size = new System.Drawing.Size(264, 130);
|
||||
this.table.Size = new System.Drawing.Size(264, 156);
|
||||
this.table.TabIndex = 0;
|
||||
//
|
||||
// phaseLabel
|
||||
@@ -140,7 +145,7 @@ namespace OpenNest.Forms
|
||||
this.remnantLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold);
|
||||
this.remnantLabel.Margin = new System.Windows.Forms.Padding(4);
|
||||
this.remnantLabel.Name = "remnantLabel";
|
||||
this.remnantLabel.Text = "Remnant:";
|
||||
this.remnantLabel.Text = "Unused:";
|
||||
//
|
||||
// remnantValue
|
||||
//
|
||||
@@ -149,6 +154,21 @@ namespace OpenNest.Forms
|
||||
this.remnantValue.Name = "remnantValue";
|
||||
this.remnantValue.Text = "\u2014";
|
||||
//
|
||||
// elapsedLabel
|
||||
//
|
||||
this.elapsedLabel.AutoSize = true;
|
||||
this.elapsedLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold);
|
||||
this.elapsedLabel.Margin = new System.Windows.Forms.Padding(4);
|
||||
this.elapsedLabel.Name = "elapsedLabel";
|
||||
this.elapsedLabel.Text = "Elapsed:";
|
||||
//
|
||||
// elapsedValue
|
||||
//
|
||||
this.elapsedValue.AutoSize = true;
|
||||
this.elapsedValue.Margin = new System.Windows.Forms.Padding(4);
|
||||
this.elapsedValue.Name = "elapsedValue";
|
||||
this.elapsedValue.Text = "0:00";
|
||||
//
|
||||
// stopButton
|
||||
//
|
||||
this.stopButton.Anchor = System.Windows.Forms.AnchorStyles.None;
|
||||
@@ -174,7 +194,7 @@ namespace OpenNest.Forms
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(264, 181);
|
||||
this.ClientSize = new System.Drawing.Size(264, 207);
|
||||
this.Controls.Add(this.buttonPanel);
|
||||
this.Controls.Add(this.table);
|
||||
this.Controls.SetChildIndex(this.table, 0);
|
||||
@@ -206,6 +226,8 @@ namespace OpenNest.Forms
|
||||
private System.Windows.Forms.Label densityValue;
|
||||
private System.Windows.Forms.Label remnantLabel;
|
||||
private System.Windows.Forms.Label remnantValue;
|
||||
private System.Windows.Forms.Label elapsedLabel;
|
||||
private System.Windows.Forms.Label elapsedValue;
|
||||
private System.Windows.Forms.Button stopButton;
|
||||
private System.Windows.Forms.FlowLayoutPanel buttonPanel;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
|
||||
@@ -7,6 +8,8 @@ namespace OpenNest.Forms
|
||||
public partial class NestProgressForm : Form
|
||||
{
|
||||
private readonly CancellationTokenSource cts;
|
||||
private readonly Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
private readonly System.Windows.Forms.Timer elapsedTimer;
|
||||
|
||||
public NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true)
|
||||
{
|
||||
@@ -18,6 +21,10 @@ namespace OpenNest.Forms
|
||||
plateLabel.Visible = false;
|
||||
plateValue.Visible = false;
|
||||
}
|
||||
|
||||
elapsedTimer = new System.Windows.Forms.Timer { Interval = 1000 };
|
||||
elapsedTimer.Tick += (s, e) => UpdateElapsed();
|
||||
elapsedTimer.Start();
|
||||
}
|
||||
|
||||
public void UpdateProgress(NestProgress progress)
|
||||
@@ -37,6 +44,10 @@ namespace OpenNest.Forms
|
||||
if (IsDisposed || !IsHandleCreated)
|
||||
return;
|
||||
|
||||
stopwatch.Stop();
|
||||
elapsedTimer.Stop();
|
||||
UpdateElapsed();
|
||||
|
||||
phaseValue.Text = "Done";
|
||||
stopButton.Text = "Close";
|
||||
stopButton.Enabled = true;
|
||||
@@ -44,6 +55,17 @@ namespace OpenNest.Forms
|
||||
stopButton.Click += (s, e) => Close();
|
||||
}
|
||||
|
||||
private void UpdateElapsed()
|
||||
{
|
||||
if (IsDisposed || !IsHandleCreated)
|
||||
return;
|
||||
|
||||
var elapsed = stopwatch.Elapsed;
|
||||
elapsedValue.Text = elapsed.TotalHours >= 1
|
||||
? elapsed.ToString(@"h\:mm\:ss")
|
||||
: elapsed.ToString(@"m\:ss");
|
||||
}
|
||||
|
||||
private void StopButton_Click(object sender, EventArgs e)
|
||||
{
|
||||
cts.Cancel();
|
||||
@@ -53,6 +75,10 @@ namespace OpenNest.Forms
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
elapsedTimer.Stop();
|
||||
elapsedTimer.Dispose();
|
||||
stopwatch.Stop();
|
||||
|
||||
if (!cts.IsCancellationRequested)
|
||||
cts.Cancel();
|
||||
|
||||
|
||||
10
collect-training-data.ps1
Normal file
10
collect-training-data.ps1
Normal file
@@ -0,0 +1,10 @@
|
||||
param(
|
||||
[Parameter(Mandatory, Position = 0)]
|
||||
[string]$DxfDir
|
||||
)
|
||||
|
||||
$DbPath = Join-Path $PSScriptRoot 'test-training.db'
|
||||
$SaveDir = 'X:\'
|
||||
$Template = 'X:\Template.nstdot'
|
||||
|
||||
dotnet run --project (Join-Path $PSScriptRoot 'OpenNest.Console') -- --collect $DxfDir --db $DbPath --save-nests $SaveDir --template $Template
|
||||
110
docs/plans/2026-03-10-gpu-overlap-debug.md
Normal file
110
docs/plans/2026-03-10-gpu-overlap-debug.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# GPU Pair Evaluator — Overlap Detection Bug
|
||||
|
||||
**Date**: 2026-03-10
|
||||
**Status**: RESOLVED — commit b55aa7a
|
||||
|
||||
## Problem
|
||||
|
||||
The `GpuPairEvaluator` reports "Overlap detected" for ALL best-fit candidates, even though the parts are clearly not overlapping. The CPU `PairEvaluator` works correctly (screenshot comparison: GPU = all red/overlap, CPU = blue with valid results like 93.9% utilization).
|
||||
|
||||
## Root Cause (identified but not yet fully fixed)
|
||||
|
||||
The bitmap coordinate system doesn't match the `Part2Offset` coordinate system.
|
||||
|
||||
### How Part2Offset is computed
|
||||
`RotationSlideStrategy` creates parts using `Part.CreateAtOrigin(drawing, rotation)` which:
|
||||
1. Clones the drawing's program
|
||||
2. Rotates it
|
||||
3. Calls `Program.BoundingBox()` to get the bbox
|
||||
4. Offsets by `-bbox.Location` to normalize to origin
|
||||
|
||||
`Part2Offset` is the final position of Part2 in this **normalized** coordinate space.
|
||||
|
||||
### How bitmaps are rasterized
|
||||
`PartBitmap.FromDrawing` / `FromDrawingRotated`:
|
||||
1. Extracts closed polygons from the drawing (filters out rapids, open shapes)
|
||||
2. Rotates them (for B)
|
||||
3. Rasterizes with `OriginX/Y = polygon min`
|
||||
|
||||
### The mismatch
|
||||
`Program.BoundingBox()` initializes `minX=0, minY=0, maxX=0, maxY=0` (line 289-292 in Program.cs), so (0,0) is **always** included in the bbox. This means:
|
||||
- For geometry at (5,3)-(10,8): bbox.Location = (0,0), CreateAtOrigin shifts by (0,0) = no change
|
||||
- But polygon min = (5,3), so bitmap OriginX=5, OriginY=3
|
||||
- Part2Offset is in the (0,0)-based normalized space, bitmap is in the (5,3)-based polygon space
|
||||
|
||||
For rotated geometry, the discrepancy is even worse because rotation changes the polygon min dramatically while the bbox may or may not include (0,0).
|
||||
|
||||
## What we tried
|
||||
|
||||
### Attempt 1: BlitPair approach (correct but too slow)
|
||||
- Added `PartBitmap.BlitPair()` that places both bitmaps into a shared world-space grid
|
||||
- Eliminated all offset math from the kernel (trivial element-wise AND)
|
||||
- **Problem**: Per-candidate grid allocation. 21K candidates × large grids = massive memory + GPU transfer. Took minutes instead of seconds.
|
||||
|
||||
### Attempt 2: Integer offsets with gap correction
|
||||
- Kept shared-bitmap approach (one A + one B per rotation group)
|
||||
- Changed offsets from `float` to `int` with `Math.Round()` on CPU
|
||||
- Added gap correction: `offset = (Part2Offset - gapA + gapB) / cellSize` where `gapA = bitmapOriginA - bboxA.Location`, `gapB = bitmapOriginB - bboxB.Location`
|
||||
- **Problem**: Still false positives. The formula is mathematically correct in derivation but something is wrong in practice.
|
||||
|
||||
### Attempt 3: Normalize bitmaps to match CreateAtOrigin (current state)
|
||||
- Added `PartBitmap.FromDrawingAtOrigin()` and `FromDrawingAtOriginRotated()`
|
||||
- These shift polygons by `-bbox.Location` before rasterizing, exactly like `CreateAtOrigin`
|
||||
- Offset formula: `(Part2Offset.X - bitmapA.OriginX + bitmapB.OriginX) / cellSize`
|
||||
- **Problem**: STILL showing false overlaps for all candidates (see gpu.png). 33.8s compute, 3942 kept but all marked overlap.
|
||||
|
||||
## Current state of code
|
||||
|
||||
### Files modified
|
||||
|
||||
**`OpenNest.Gpu/PartBitmap.cs`**:
|
||||
- Added `BlitPair()` static method (from attempt 1, still present but unused)
|
||||
- Added `FromDrawingAtOrigin()` — normalizes polygons by `-bbox.Location` before rasterize
|
||||
- Added `FromDrawingAtOriginRotated()` — rotates polygons, clones+rotates program for bbox, normalizes, rasterizes
|
||||
|
||||
**`OpenNest.Gpu/GpuPairEvaluator.cs`**:
|
||||
- Uses `FromDrawingAtOrigin` / `FromDrawingAtOriginRotated` instead of raw `FromDrawing` / `FromDrawingRotated`
|
||||
- Offsets are `int[]` (not `float[]`) computed with `Math.Round()` on CPU
|
||||
- Kernel is `OverlapKernel` — uses integer offsets, early-exit on `cellA != 1`
|
||||
- `PadBitmap` helper restored
|
||||
- Removed the old `NestingKernel` with float offsets
|
||||
|
||||
**`OpenNest/Forms/MainForm.cs`**:
|
||||
- Added `using OpenNest.Engine.BestFit;`
|
||||
- Wired up GPU evaluator: `BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);`
|
||||
|
||||
## Next steps to debug
|
||||
|
||||
1. **Add diagnostic logging** to compare GPU vs CPU for a single candidate:
|
||||
- Print bitmapA: OriginX, OriginY, Width, Height
|
||||
- Print bitmapB: OriginX, OriginY, Width, Height
|
||||
- Print the computed integer offset
|
||||
- Print the overlap count from the kernel
|
||||
- Compare with CPU `PairEvaluator.CheckOverlap()` result for the same candidate
|
||||
|
||||
2. **Verify Program.Clone() + Rotate() produces same geometry as Polygon.Rotate()**:
|
||||
- `FromDrawingAtOriginRotated` rotates polygons with `poly.Rotate(rotation)` then normalizes using `prog.Clone().Rotate(rotation).BoundingBox()`
|
||||
- If `Program.Rotate` and `Polygon.Rotate` use different rotation centers or conventions, the normalization would be wrong
|
||||
- Check: does `Program.Rotate` rotate around (0,0)? Does `Polygon.Rotate` rotate around (0,0)?
|
||||
|
||||
3. **Try rasterizing from the Part directly**: Instead of extracting polygons from the raw drawing and manually rotating/normalizing, create `Part.CreateAtOrigin(drawing, rotation)` and extract polygons from the Part's already-normalized program. This guarantees exact coordinate system match.
|
||||
|
||||
4. **Consider that the kernel grid might be too small**: `gridWidth = max(A.Width, B.Width)` only works if offset is small. If Part2Offset places B far from A, the B cells at `bx = x - offset` could all be out of bounds (negative), leading the kernel to find zero overlaps (false negative). But we're seeing false POSITIVES, so this isn't the issue unless the offset sign is wrong.
|
||||
|
||||
5. **Check offset sign**: Verify that when offset is positive, `bx = x - offset` correctly maps A cells to B cells. A positive offset should mean B is shifted right relative to A.
|
||||
|
||||
## Performance notes
|
||||
- CPU evaluator: 25.0s compute, 5954 kept, correct results
|
||||
- GPU evaluator (current): 33.8s compute, 3942 kept, all false overlaps
|
||||
- GPU is actually SLOWER because `FromDrawingAtOriginRotated` clones+rotates the full program per rotation group
|
||||
- Once overlap detection is fixed, performance optimization should focus on avoiding the Program.Clone().Rotate() per rotation group
|
||||
|
||||
## Key files to reference
|
||||
- `OpenNest.Gpu/GpuPairEvaluator.cs` — the GPU evaluator
|
||||
- `OpenNest.Gpu/PartBitmap.cs` — bitmap rasterization
|
||||
- `OpenNest.Engine/BestFit/PairEvaluator.cs` — CPU evaluator (working reference)
|
||||
- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — generates Part2Offset values
|
||||
- `OpenNest.Core/Part.cs:109` — `Part.CreateAtOrigin()`
|
||||
- `OpenNest.Core/CNC/Program.cs:281-342` — `Program.BoundingBox()` (note min init at 0,0)
|
||||
- `OpenNest.Engine/BestFit/BestFitCache.cs` — where evaluator is plugged in
|
||||
- `OpenNest/Forms/MainForm.cs` — where GPU evaluator is wired up
|
||||
367
docs/superpowers/plans/2026-03-11-test-harness.md
Normal file
367
docs/superpowers/plans/2026-03-11-test-harness.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# OpenNest Test Harness 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:** Create a console app + MCP tool that builds and runs OpenNest.Engine against a nest file, writing debug output to a file for grepping and saving the resulting nest.
|
||||
|
||||
**Architecture:** A new `OpenNest.TestHarness` console app references Core, Engine, and IO. It loads a nest file, clears a plate, runs `NestEngine.Fill()`, writes `Debug.WriteLine` output to a timestamped log file via `TextWriterTraceListener`, prints a summary to stdout, and saves the nest. An MCP tool `test_engine` in OpenNest.Mcp shells out to `dotnet run --project OpenNest.TestHarness` and returns the summary + log file path.
|
||||
|
||||
**Tech Stack:** .NET 8, System.Diagnostics tracing, OpenNest.Core/Engine/IO
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `OpenNest.TestHarness/OpenNest.TestHarness.csproj` | Console app project, references Core + Engine + IO. Forces `DEBUG` constant. |
|
||||
| Create | `OpenNest.TestHarness/Program.cs` | Entry point: parse args, load nest, run fill, write debug to file, save nest |
|
||||
| Modify | `OpenNest.sln` | Add new project to solution |
|
||||
| Create | `OpenNest.Mcp/Tools/TestTools.cs` | MCP `test_engine` tool that shells out to the harness |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Console App + MCP Tool
|
||||
|
||||
### Task 1: Create the OpenNest.TestHarness project
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.TestHarness/OpenNest.TestHarness.csproj`
|
||||
|
||||
- [ ] **Step 1: Create the project file**
|
||||
|
||||
Note: `DEBUG` is defined for all configurations so `Debug.WriteLine` output is always captured — that's the whole point of this tool.
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.TestHarness</RootNamespace>
|
||||
<AssemblyName>OpenNest.TestHarness</AssemblyName>
|
||||
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add project to solution**
|
||||
|
||||
```bash
|
||||
dotnet sln OpenNest.sln add OpenNest.TestHarness/OpenNest.TestHarness.csproj
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify it builds**
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj
|
||||
```
|
||||
|
||||
Expected: Build succeeded (with warning about empty Program.cs — that's fine, we create it next).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Write the TestHarness Program.cs
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.TestHarness/Program.cs`
|
||||
|
||||
The console app does:
|
||||
1. Parse command-line args for nest file path, optional drawing name, plate index, output path
|
||||
2. Create a timestamped log file and attach a `TextWriterTraceListener` so `Debug.WriteLine` goes to the file
|
||||
3. Load the nest file via `NestReader`
|
||||
4. Find the drawing and plate
|
||||
5. Clear existing parts from the plate
|
||||
6. Run `NestEngine.Fill()`
|
||||
7. Print summary (part count, utilization, log file path) to stdout
|
||||
8. Save the nest via `NestWriter`
|
||||
|
||||
- [ ] **Step 1: Write Program.cs**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest;
|
||||
using OpenNest.IO;
|
||||
|
||||
// Parse arguments.
|
||||
var nestFile = args.Length > 0 ? args[0] : null;
|
||||
var drawingName = (string)null;
|
||||
var plateIndex = 0;
|
||||
var outputFile = (string)null;
|
||||
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--drawing" when i + 1 < args.Length:
|
||||
drawingName = args[++i];
|
||||
break;
|
||||
case "--plate" when i + 1 < args.Length:
|
||||
plateIndex = int.Parse(args[++i]);
|
||||
break;
|
||||
case "--output" when i + 1 < args.Length:
|
||||
outputFile = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile))
|
||||
{
|
||||
Console.Error.WriteLine("Usage: OpenNest.TestHarness <nest-file> [--drawing <name>] [--plate <index>] [--output <path>]");
|
||||
Console.Error.WriteLine(" nest-file Path to a .zip nest file");
|
||||
Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)");
|
||||
Console.Error.WriteLine(" --plate Plate index to fill (default: 0)");
|
||||
Console.Error.WriteLine(" --output Output nest file path (default: <input>-result.zip)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Set up debug log file.
|
||||
var logDir = Path.Combine(Path.GetDirectoryName(nestFile), "test-harness-logs");
|
||||
Directory.CreateDirectory(logDir);
|
||||
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
|
||||
var logWriter = new StreamWriter(logFile) { AutoFlush = true };
|
||||
Trace.Listeners.Add(new TextWriterTraceListener(logWriter));
|
||||
|
||||
// Load nest.
|
||||
var reader = new NestReader(nestFile);
|
||||
var nest = reader.Read();
|
||||
|
||||
if (nest.Plates.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: nest file contains no plates");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (plateIndex >= nest.Plates.Count)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: plate index {plateIndex} out of range (0-{nest.Plates.Count - 1})");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var plate = nest.Plates[plateIndex];
|
||||
|
||||
// Find drawing.
|
||||
var drawing = drawingName != null
|
||||
? nest.Drawings.FirstOrDefault(d => d.Name == drawingName)
|
||||
: nest.Drawings.FirstOrDefault();
|
||||
|
||||
if (drawing == null)
|
||||
{
|
||||
Console.Error.WriteLine(drawingName != null
|
||||
? $"Error: drawing '{drawingName}' not found"
|
||||
: "Error: nest file contains no drawings");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Clear existing parts.
|
||||
var existingCount = plate.Parts.Count;
|
||||
plate.Parts.Clear();
|
||||
|
||||
Console.WriteLine($"Nest: {nest.Name}");
|
||||
Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Height:F1}), spacing={plate.PartSpacing:F2}");
|
||||
Console.WriteLine($"Drawing: {drawing.Name}");
|
||||
Console.WriteLine($"Cleared {existingCount} existing parts");
|
||||
Console.WriteLine("---");
|
||||
|
||||
// Run fill.
|
||||
var sw = Stopwatch.StartNew();
|
||||
var engine = new NestEngine(plate);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = 0 };
|
||||
var success = engine.Fill(item);
|
||||
sw.Stop();
|
||||
|
||||
// Flush and close the log.
|
||||
Trace.Flush();
|
||||
logWriter.Dispose();
|
||||
|
||||
// Print results.
|
||||
Console.WriteLine($"Result: {(success ? "success" : "failed")}");
|
||||
Console.WriteLine($"Parts placed: {plate.Parts.Count}");
|
||||
Console.WriteLine($"Utilization: {plate.Utilization():P1}");
|
||||
Console.WriteLine($"Time: {sw.ElapsedMilliseconds}ms");
|
||||
Console.WriteLine($"Debug log: {logFile}");
|
||||
|
||||
// Save output.
|
||||
if (outputFile == null)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(nestFile);
|
||||
var name = Path.GetFileNameWithoutExtension(nestFile);
|
||||
outputFile = Path.Combine(dir, $"{name}-result.zip");
|
||||
}
|
||||
|
||||
var writer = new NestWriter(nest);
|
||||
writer.Write(outputFile);
|
||||
Console.WriteLine($"Saved: {outputFile}");
|
||||
|
||||
return 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the project**
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj
|
||||
```
|
||||
|
||||
Expected: Build succeeded with 0 errors.
|
||||
|
||||
- [ ] **Step 3: Run a smoke test with the real nest file**
|
||||
|
||||
```bash
|
||||
dotnet run --project OpenNest.TestHarness -- "C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip"
|
||||
```
|
||||
|
||||
Expected: Prints nest info and results to stdout, writes debug log file, saves a `-result.zip` file.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.TestHarness/ OpenNest.sln
|
||||
git commit -m "feat: add OpenNest.TestHarness console app for engine testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add the MCP test_engine tool
|
||||
|
||||
**Files:**
|
||||
- Create: `OpenNest.Mcp/Tools/TestTools.cs`
|
||||
|
||||
The MCP tool:
|
||||
1. Accepts optional `nestFile`, `drawingName`, `plateIndex` parameters
|
||||
2. Runs `dotnet run --project <path> -- <args>` capturing stdout (results) and stderr (errors only)
|
||||
3. Returns the summary + debug log file path (Claude can then Grep the log file)
|
||||
|
||||
Note: The solution root is hard-coded because the MCP server is published to `~/.claude/mcp/OpenNest.Mcp/`, far from the source tree.
|
||||
|
||||
- [ ] **Step 1: Create TestTools.cs**
|
||||
|
||||
```csharp
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace OpenNest.Mcp.Tools
|
||||
{
|
||||
[McpServerToolType]
|
||||
public class TestTools
|
||||
{
|
||||
private const string SolutionRoot = @"C:\Users\AJ\Desktop\Projects\OpenNest";
|
||||
|
||||
private static readonly string HarnessProject = Path.Combine(
|
||||
SolutionRoot, "OpenNest.TestHarness", "OpenNest.TestHarness.csproj");
|
||||
|
||||
[McpServerTool(Name = "test_engine")]
|
||||
[Description("Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server.")]
|
||||
public string TestEngine(
|
||||
[Description("Path to the nest .zip file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip",
|
||||
[Description("Drawing name to fill with (default: first drawing)")] string drawingName = null,
|
||||
[Description("Plate index to fill (default: 0)")] int plateIndex = 0,
|
||||
[Description("Output nest file path (default: <input>-result.zip)")] string outputFile = null)
|
||||
{
|
||||
if (!File.Exists(nestFile))
|
||||
return $"Error: nest file not found: {nestFile}";
|
||||
|
||||
var processArgs = new StringBuilder();
|
||||
processArgs.Append($"\"{nestFile}\"");
|
||||
|
||||
if (!string.IsNullOrEmpty(drawingName))
|
||||
processArgs.Append($" --drawing \"{drawingName}\"");
|
||||
|
||||
processArgs.Append($" --plate {plateIndex}");
|
||||
|
||||
if (!string.IsNullOrEmpty(outputFile))
|
||||
processArgs.Append($" --output \"{outputFile}\"");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run --project \"{HarnessProject}\" -- {processArgs}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = SolutionRoot
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(psi);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
var stdout = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(120_000);
|
||||
var stderr = stderrTask.Result;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stdout))
|
||||
sb.Append(stdout.TrimEnd());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("=== Errors ===");
|
||||
sb.Append(stderr.TrimEnd());
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Process exited with code {process.ExitCode}");
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
sb.AppendLine($"Error running test harness: {ex.Message}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the MCP project**
|
||||
|
||||
```bash
|
||||
dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj
|
||||
```
|
||||
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 3: Republish the MCP server**
|
||||
|
||||
```bash
|
||||
dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"
|
||||
```
|
||||
|
||||
Expected: Publish succeeded. The MCP server now has the `test_engine` tool.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Mcp/Tools/TestTools.cs
|
||||
git commit -m "feat: add test_engine MCP tool for iterative engine testing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
After implementation, the workflow for iterating on FillLinear becomes:
|
||||
|
||||
1. **Other session** makes changes to `FillLinear.cs` or `NestEngine.cs`
|
||||
2. **This session** calls `test_engine` (no args needed — defaults to the test nest file)
|
||||
3. The tool builds the latest code and runs it in a fresh process
|
||||
4. Returns: part count, utilization, timing, and **debug log file path**
|
||||
5. Grep the log file for specific patterns (e.g., `[FillLinear]`, `[FindBestFill]`)
|
||||
6. Repeat
|
||||
281
docs/superpowers/plans/2026-03-12-contour-reindexing.md
Normal file
281
docs/superpowers/plans/2026-03-12-contour-reindexing.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Contour Re-Indexing 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:** Add entity-splitting primitives (`Line.SplitAt`, `Arc.SplitAt`), a `Shape.ReindexAt` method, and wire them into `ContourCuttingStrategy.Apply()` to replace the `NotImplementedException` stubs.
|
||||
|
||||
**Architecture:** Bottom-up — build splitting primitives first, then the reindexing algorithm on top, then wire into the strategy. Each layer depends only on the one below it.
|
||||
|
||||
**Tech Stack:** C# / .NET 8, OpenNest.Core (Geometry + CNC namespaces)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-12-contour-reindexing-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Change | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `OpenNest.Core/Geometry/Line.cs` | Add method | `SplitAt(Vector)` — split a line at a point into two halves |
|
||||
| `OpenNest.Core/Geometry/Arc.cs` | Add method | `SplitAt(Vector)` — split an arc at a point into two halves |
|
||||
| `OpenNest.Core/Geometry/Shape.cs` | Add method | `ReindexAt(Vector, Entity)` — reorder a closed contour to start at a given point |
|
||||
| `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Add method + modify | `ConvertShapeToMoves` + replace two `NotImplementedException` blocks |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Splitting Primitives
|
||||
|
||||
### Task 1: Add `Line.SplitAt(Vector)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Line.cs`
|
||||
|
||||
- [ ] **Step 1: Add `SplitAt` method to `Line`**
|
||||
|
||||
Add the following method to the `Line` class (after the existing `ClosestPointTo` method):
|
||||
|
||||
```csharp
|
||||
public (Line first, Line second) SplitAt(Vector point)
|
||||
{
|
||||
var first = point.DistanceTo(StartPoint) < Tolerance.Epsilon
|
||||
? null
|
||||
: new Line(StartPoint, point);
|
||||
|
||||
var second = point.DistanceTo(EndPoint) < Tolerance.Epsilon
|
||||
? null
|
||||
: new Line(point, EndPoint);
|
||||
|
||||
return (first, second);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Line.cs
|
||||
git commit -m "feat: add Line.SplitAt(Vector) splitting primitive"
|
||||
```
|
||||
|
||||
### Task 2: Add `Arc.SplitAt(Vector)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Arc.cs`
|
||||
|
||||
- [ ] **Step 1: Add `SplitAt` method to `Arc`**
|
||||
|
||||
Add the following method to the `Arc` class (after the existing `EndPoint` method):
|
||||
|
||||
```csharp
|
||||
public (Arc first, Arc second) SplitAt(Vector point)
|
||||
{
|
||||
if (point.DistanceTo(StartPoint()) < Tolerance.Epsilon)
|
||||
return (null, new Arc(Center, Radius, StartAngle, EndAngle, IsReversed));
|
||||
|
||||
if (point.DistanceTo(EndPoint()) < Tolerance.Epsilon)
|
||||
return (new Arc(Center, Radius, StartAngle, EndAngle, IsReversed), null);
|
||||
|
||||
var splitAngle = Angle.NormalizeRad(Center.AngleTo(point));
|
||||
|
||||
var firstArc = new Arc(Center, Radius, StartAngle, splitAngle, IsReversed);
|
||||
var secondArc = new Arc(Center, Radius, splitAngle, EndAngle, IsReversed);
|
||||
|
||||
return (firstArc, secondArc);
|
||||
}
|
||||
```
|
||||
|
||||
Key details from spec:
|
||||
- Compare distances to `StartPoint()`/`EndPoint()` rather than comparing angles (avoids 0/2π wrap-around issues).
|
||||
- `splitAngle` is computed from `Center.AngleTo(point)`, normalized.
|
||||
- Both halves preserve center, radius, and `IsReversed` direction.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Arc.cs
|
||||
git commit -m "feat: add Arc.SplitAt(Vector) splitting primitive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Shape.ReindexAt
|
||||
|
||||
### Task 3: Add `Shape.ReindexAt(Vector, Entity)`
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/Geometry/Shape.cs`
|
||||
|
||||
- [ ] **Step 1: Add `ReindexAt` method to `Shape`**
|
||||
|
||||
Add the following method to the `Shape` class (after the existing `ClosestPointTo(Vector, out Entity)` method around line 201):
|
||||
|
||||
```csharp
|
||||
public Shape ReindexAt(Vector point, Entity entity)
|
||||
{
|
||||
// Circle case: return a new shape with just the circle
|
||||
if (entity is Circle)
|
||||
{
|
||||
var result = new Shape();
|
||||
result.Entities.Add(entity);
|
||||
return result;
|
||||
}
|
||||
|
||||
var i = Entities.IndexOf(entity);
|
||||
if (i < 0)
|
||||
throw new ArgumentException("Entity not found in shape", nameof(entity));
|
||||
|
||||
// Split the entity at the point
|
||||
Entity firstHalf = null;
|
||||
Entity secondHalf = null;
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
var (f, s) = line.SplitAt(point);
|
||||
firstHalf = f;
|
||||
secondHalf = s;
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
var (f, s) = arc.SplitAt(point);
|
||||
firstHalf = f;
|
||||
secondHalf = s;
|
||||
}
|
||||
|
||||
// Build reindexed entity list
|
||||
var entities = new List<Entity>();
|
||||
|
||||
// secondHalf of split entity (if not null)
|
||||
if (secondHalf != null)
|
||||
entities.Add(secondHalf);
|
||||
|
||||
// Entities after the split index (wrapping)
|
||||
for (var j = i + 1; j < Entities.Count; j++)
|
||||
entities.Add(Entities[j]);
|
||||
|
||||
// Entities before the split index (wrapping)
|
||||
for (var j = 0; j < i; j++)
|
||||
entities.Add(Entities[j]);
|
||||
|
||||
// firstHalf of split entity (if not null)
|
||||
if (firstHalf != null)
|
||||
entities.Add(firstHalf);
|
||||
|
||||
var reindexed = new Shape();
|
||||
reindexed.Entities.AddRange(entities);
|
||||
return reindexed;
|
||||
}
|
||||
```
|
||||
|
||||
The `Shape` class already imports `System` and `System.Collections.Generic`, so no new usings needed.
|
||||
|
||||
- [ ] **Step 2: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/Geometry/Shape.cs
|
||||
git commit -m "feat: add Shape.ReindexAt(Vector, Entity) for contour reordering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Wire into ContourCuttingStrategy
|
||||
|
||||
### Task 4: Add `ConvertShapeToMoves` and replace stubs
|
||||
|
||||
**Files:**
|
||||
- Modify: `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs`
|
||||
|
||||
- [ ] **Step 1: Add `ConvertShapeToMoves` private method**
|
||||
|
||||
Add the following private method to `ContourCuttingStrategy` (after the existing `SelectLeadOut` method, before the closing brace of the class):
|
||||
|
||||
```csharp
|
||||
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint)
|
||||
{
|
||||
var moves = new List<ICode>();
|
||||
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
moves.Add(new LinearMove(line.EndPoint));
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW));
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}");
|
||||
}
|
||||
}
|
||||
|
||||
return moves;
|
||||
}
|
||||
```
|
||||
|
||||
This matches the `ConvertGeometry.AddArc`/`AddCircle`/`AddLine` patterns but without `RapidMove` between entities (they are contiguous in a reindexed shape).
|
||||
|
||||
- [ ] **Step 2: Replace cutout `NotImplementedException` (line 41)**
|
||||
|
||||
In the `Apply` method, replace:
|
||||
```csharp
|
||||
// Contour re-indexing: split shape entities at closestPt so cutting
|
||||
// starts there, convert to ICode, and add to result.Codes
|
||||
throw new System.NotImplementedException("Contour re-indexing not yet implemented");
|
||||
```
|
||||
|
||||
With:
|
||||
```csharp
|
||||
var reindexed = cutout.ReindexAt(closestPt, entity);
|
||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
|
||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace perimeter `NotImplementedException` (line 57)**
|
||||
|
||||
In the `Apply` method, replace:
|
||||
```csharp
|
||||
throw new System.NotImplementedException("Contour re-indexing not yet implemented");
|
||||
```
|
||||
|
||||
With:
|
||||
```csharp
|
||||
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
|
||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
|
||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build to verify**
|
||||
|
||||
Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 5: Build full solution**
|
||||
|
||||
Run: `dotnet build OpenNest.sln`
|
||||
Expected: Build succeeded, 0 errors
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
|
||||
git commit -m "feat: wire contour re-indexing into ContourCuttingStrategy.Apply()"
|
||||
```
|
||||
Reference in New Issue
Block a user