chore: remove tools directory

One-off helper utilities don't belong in the repo. PepNestExport moved to
/home/aj/src/PepNestExport as a standalone companion project; NestDxfJob and
StreamGravographJob are removed (recoverable from history).
This commit is contained in:
aj
2026-09-23 15:15:45 -04:00
parent c45909e397
commit d6d819bfed
7 changed files with 0 additions and 1530 deletions
-29
View File
@@ -120,35 +120,6 @@ var domainResult = NestResultMaterializer.Materialize(job, result);
**Single-plate placement:** interactive fills use `PlateFillService`, which explicitly selects one of `Default`, `Strip`, `Vertical Remnant`, or `Horizontal Remnant` and returns proposed parts without mutating the caller's plate. The caller remains responsible for preview accept/cancel and attachment. Whole-job work in the desktop app, console, MCP server, and public API resolves a named `INestingEngine`, solves one `NestJob`, and materializes committed results. `NestRunner.RunAsync` reports status, stop reason, part fulfillment, stock usage, and plate-to-stock mapping; `.nestquote` archives carry a schema version and round-trip incomplete jobs. **Single-plate placement:** interactive fills use `PlateFillService`, which explicitly selects one of `Default`, `Strip`, `Vertical Remnant`, or `Horizontal Remnant` and returns proposed parts without mutating the caller's plate. The caller remains responsible for preview accept/cancel and attachment. Whole-job work in the desktop app, console, MCP server, and public API resolves a named `INestingEngine`, solves one `NestJob`, and materializes committed results. `NestRunner.RunAsync` reports status, stop reason, part fulfillment, stock usage, and plate-to-stock mapping; `.nestquote` archives carry a schema version and round-trip incomplete jobs.
### Fresh DXF job verification (headless)
`tools/NestDxfJob` imports a complete quantity workbook and runs a registered whole-job engine. It never reuses saved drawing geometry or placements. The workbook must have a `Parts` worksheet with exactly one `Part Name` and `Qty Required` column; names match DXF filename stems exactly. Invalid/fractional/negative quantities, duplicate names, and missing required DXFs fail explicitly. Zero-demand rows are not imported; additional DXFs with no positive workbook demand are listed and not assigned an invented quantity.
```bash
dotnet run --project tools/NestDxfJob -- \
/path/to/dxfs /path/to/parts.xlsx /path/to/settings.nest \
/path/to/new-results-directory Strip
```
The settings nest supplies units, material metadata, per-part rotation constraints/priority where names match, and distinct plate dimensions/clearances/quadrants. Stock is unlimited copies of those settings with a 40-sheet cap, not a claim about physical inventory. DXFs with explicit conflicting units reject; unitless DXFs use the template units without rescaling. `CadImporter` is called with `DetectBends = false`: default DXF filtering removes case-insensitive `ETCH`/`SCRIBE` layers before optimization, and bend detection cannot regenerate marks.
The output directory must not exist. The tool writes `imported-cut-only.nest` and `import-report.json` (including input hashes, excluded marks, and unmatched DXFs), then runs the selected engine with a ten-minute cancellation budget. A complete result must pass quantity, bounds, overlap/spacing and cut-only checks, then save/reload and pass them again before success. `validation-report.json` records per-part fulfillment and placements. A partial or invalid result exits nonzero and is not published as a successful nest. Use `import-only` instead of an engine name to verify and save only the imported job. This verifies nesting geometry, not machine-ready CNC lead-ins or post-processing.
### PEP nest export (benchmark against PEP)
`tools/PepNestExport` converts a year of PEP nests into `.nest` files for `OpenNest.Benchmark`. It lists nests from PepApi (`/nests/{year}`), downloads each `.pep` file (`/nests/{year}/{name}/download`), and reads it with `PepLib.Core` from the sibling `PepApi.Core` repo. Override the path with `-p:PepLibProject=<path>` if that repo is cloned elsewhere.
```bash
dotnet run --project tools/PepNestExport -c Release -- "/path/to/PEP 2026 nests" --year 2026
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll "/path/to/PEP 2026 nests" --engines Opus55NestingEngine --csv results.csv
```
Each `.nest` keeps PEP's own layout: plate sizes and duplicate counts, part spacing, edge spacing, quadrant and every placement. The benchmark therefore scores PEP as its `Baseline` row and offers engines only the sheet sizes PEP used, unless you pass `--sheet-sizes`. Drawing geometry comes from PEP's loops, flattened with sub-loop (hole) calls continuing the incremental position. Lead-ins, lead-outs, scribe, display and `DESTRUCT CUT` moves are dropped, and uncut micro-joint tabs of 0.25 or less are closed: open cut runs are chained end to start across the tab and bridged with a cut line, but only where they form a closed loop, so separate contours that happen to lie close together are never merged. The tabs themselves are not kept. Skeleton and display-only parts are excluded.
By default `--quantity nested` sets demand to what PEP actually nested; `--quantity required` uses PEP's required counts instead. Other options: `--nests`, `--status` (default: every status except `Deleted`), `--parallel` and `--force`.
The tool writes `pep-baseline.csv` (sheets, sheet area, part area and utilization per nest) and a `<nest>.violations.txt` when PEP's layout fails validation. PEP places parts at exactly the nominal spacing and rounds coordinates to about 4 decimals, so the strict benchmark validator usually rejects the PEP baseline. Its sheet count and area still show in the report. `RelaxedValid` repeats the check allowing 0.025 on spacing and 0.001 on edges; a failure there means a real overlap or a genuinely tight manual placement. Validation runs on the saved file and is capped at 60 seconds per nest: `NestValidator` can take minutes on parts with hundreds of outline segments and many holes, and those rows report `timeout`. Programs are stored incremental, like CAD-imported drawings, because the desktop renderer only applies part locations to incremental programs.
### Run ### Run
```bash ```bash
-10
View File
@@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
</ItemGroup>
</Project>
-296
View File
@@ -1,296 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using OpenNest;
using OpenNest.Benchmark;
using OpenNest.CNC;
using OpenNest.IO;
using OpenNest.IO.Bom;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
if (args.Length != 5)
{
Console.Error.WriteLine(
"Usage: NestDxfJob <dxf-directory> <quantities.xlsx> <settings.nest> <new-output-directory> <engine|import-only>"
);
return 1;
}
try
{
var directory = Path.GetFullPath(args[0]);
var workbookPath = Path.GetFullPath(args[1]);
var templatePath = Path.GetFullPath(args[2]);
var outputDirectory = Path.GetFullPath(args[3]);
if (Directory.Exists(outputDirectory) || File.Exists(outputDirectory))
throw new IOException(
"Output directory must be new; existing inputs/results are never overwritten."
);
var engineInfo =
args[4] == "import-only"
? null
: NestingEngineRegistry.AvailableEngines.SingleOrDefault(e => e.Name == args[4])
?? throw new ArgumentException($"Unknown engine: {args[4]}");
var quantities = PartQuantityReader.Read(workbookPath);
var paths = Directory
.GetFiles(directory)
.Where(p => Path.GetExtension(p).Equals(".dxf", StringComparison.OrdinalIgnoreCase))
.Order(StringComparer.Ordinal)
.ToArray();
var files = paths.ToDictionary(Path.GetFileNameWithoutExtension, StringComparer.Ordinal);
var demand = quantities
.Where(p => p.Value > 0)
.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
var missing = demand.Keys.Except(files.Keys, StringComparer.Ordinal).ToArray();
if (missing.Length > 0)
throw new InvalidDataException($"Missing exact-name DXFs: {string.Join(", ", missing)}");
var unrequested = files.Keys.Except(demand.Keys, StringComparer.Ordinal).ToArray();
foreach (var name in unrequested)
Console.WriteLine($"Not requested by workbook (not imported): {files[name]}");
var hashes = paths
.Append(workbookPath)
.Append(templatePath)
.Distinct()
.ToDictionary(p => p, Hash);
var template = new NestReader(templatePath).Read();
// Saved placements and drawing geometry are NOT imported from the template.
var input = new Nest(template.Name)
{
Units = template.Units,
Material = template.Material,
Thickness = template.Thickness,
Customer = template.Customer,
DateCreated = DateTime.Now,
Notes =
"Fresh DXF import; ETCH/SCRIBE excluded and bend generation disabled. Quantities from workbook. Template supplies stock settings only.",
};
var stocks =
template.Plates.Count > 0
? template.Plates.ToList()
: new List<Plate> { template.PlateDefaults.CreateNew() };
var distinctStocks = stocks
.GroupBy(p => new
{
p.Size,
p.Quadrant,
p.PartSpacing,
p.EdgeSpacing.Left,
p.EdgeSpacing.Right,
p.EdgeSpacing.Top,
p.EdgeSpacing.Bottom,
})
.Select(g => g.First())
.ToList();
foreach (var stock in distinctStocks)
{
input.PlateDefaults.SetFromExisting(stock);
input.Plates.Add(input.PlateDefaults.CreateNew());
}
input.PlateDefaults.SetFromExisting(distinctStocks[0]);
var imports = new List<object>();
foreach (var (name, quantity) in demand)
{
var import = CadImporter.Import(files[name], new CadImportOptions { DetectBends = false });
var sourceUnits = (int)import.Document.Header.InsUnits;
var expectedUnits = template.Units == Units.Inches ? 1 : 4;
if (sourceUnits != 0 && sourceUnits != expectedUnits)
throw new InvalidDataException($"DXF units conflict with template: {name}");
var marks = import.Document.Entities.Count(e => IsMark(e.Layer?.Name));
if (import.Entities.Any(e => IsMark(e.Layer?.Name)) || import.Bends.Count != 0)
throw new InvalidDataException($"Cut-only import retained markings: {name}");
var drawing = CadImporter.BuildDrawing(
import,
import.Entities,
import.Bends,
quantity,
template.Customer,
null
);
drawing.Material = template.Material;
var previous = template.Drawings.SingleOrDefault(d => d.Name == name);
if (previous != null)
{
drawing.Constraints = previous.Constraints;
drawing.Priority = previous.Priority;
}
EnsureCutOnly(drawing);
input.Drawings.Add(drawing);
imports.Add(
new
{
Name = name,
Required = quantity,
RemovedMarks = marks,
SourceUnits = sourceUnits,
CutEntities = import.Entities.Count,
drawing.Area,
}
);
}
var job = new NestJob(
input.Drawings.Select(d => DrawingJobMapper.FromDrawing(d.Name, d, d.Quantity.Required)),
distinctStocks.Select((p, i) => DrawingJobMapper.FromPlate($"stock-{i + 1}", p, null)),
new NestJobOptions(maxPlates: 40)
);
NestJobValidator.Validate(job);
VerifySources(hashes);
Directory.CreateDirectory(outputDirectory);
var inputPath = Path.Combine(outputDirectory, "imported-cut-only.nest");
new NestWriter(input).Write(inputPath);
var jsonOptions = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(
Path.Combine(outputDirectory, "import-report.json"),
JsonSerializer.Serialize(
new
{
Workbook = workbookPath,
Template = templatePath,
Units = template.Units.ToString(),
UnitPolicy = "Explicit DXF units must agree; unitless DXFs use template units without scaling.",
StockPolicy = "Unlimited copies of the template's distinct physical sheet settings; maximum 40 sheets. Not an inventory assertion.",
SourceHashes = hashes,
UnrequestedDxfs = unrequested,
Imports = imports,
Requested = demand.Values.Sum(),
Drawings = demand.Count,
},
jsonOptions
)
);
Console.WriteLine(
$"Imported {demand.Count} drawings / {demand.Values.Sum()} pieces. Input: {inputPath}"
);
if (engineInfo == null)
return 0;
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(10));
var timer = Stopwatch.StartNew();
var result = engineInfo.Factory().Solve(job, new JobProgress(), cancellation.Token);
Console.WriteLine(
$"{result.Status}: {result.Fulfillment.Sum(f => f.Placed)}/{demand.Values.Sum()}, {result.Plates.Count} sheets, {timer.Elapsed.TotalSeconds:F2}s"
);
var materialized = NestResultMaterializer.Materialize(job, result);
var output = materialized.Nest;
output.Name = input.Name;
output.Units = input.Units;
output.Material = input.Material;
output.Thickness = input.Thickness;
output.Customer = input.Customer;
output.Notes = input.Notes;
output.DateCreated = input.DateCreated;
output.PlateDefaults = input.PlateDefaults;
foreach (var drawing in input.Drawings)
{
var placed = materialized.DrawingsByPartId[drawing.Name];
placed.Name = drawing.Name;
placed.Color = drawing.Color;
placed.Source = drawing.Source;
placed.SourceEntities = drawing.SourceEntities;
placed.SuppressedEntityIds = drawing.SuppressedEntityIds;
placed.Material = drawing.Material;
placed.Customer = drawing.Customer;
}
var violations = Validate(output, demand);
if (result.Status != NestJobStatus.Complete)
violations.Add($"Incomplete job: {result.StopReason}");
VerifySources(hashes);
var resultPath = Path.Combine(outputDirectory, $"{input.Name}-{engineInfo.Name}.nest");
if (violations.Count == 0)
{
new NestWriter(output).Write(resultPath);
var reloaded = new NestReader(resultPath).Read();
violations.AddRange(Validate(reloaded, demand));
NestJobValidator.Validate(
new NestJob(
reloaded.Drawings.Select(d =>
DrawingJobMapper.FromDrawing(d.Name, d, d.Quantity.Required)
),
job.Plates
)
);
if (violations.Count != 0)
File.Move(resultPath, resultPath + ".invalid");
}
File.WriteAllText(
Path.Combine(outputDirectory, "validation-report.json"),
JsonSerializer.Serialize(
new
{
Engine = engineInfo.Name,
Status = result.Status.ToString(),
StopReason = result.StopReason.ToString(),
ElapsedSeconds = timer.Elapsed.TotalSeconds,
Requested = demand.Values.Sum(),
Placed = result.Fulfillment.Sum(f => f.Placed),
result.Fulfillment,
result.StockUsage,
result.Plates,
Violations = violations,
SavedNestReloadPassed = violations.Count == 0,
SourceHashesUnchanged = true,
},
jsonOptions
)
);
if (violations.Count != 0)
throw new InvalidDataException(string.Join(Environment.NewLine, violations));
Console.WriteLine($"Verified complete saved nest: {resultPath}");
return 0;
}
catch (Exception exception)
{
Console.Error.WriteLine(exception.ToString());
return 1;
}
static bool IsMark(string layer) =>
string.Equals(layer, "ETCH", StringComparison.OrdinalIgnoreCase)
|| string.Equals(layer, "SCRIBE", StringComparison.OrdinalIgnoreCase);
static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
static void VerifySources(Dictionary<string, string> hashes)
{
foreach (var (path, hash) in hashes)
if (Hash(path) != hash)
throw new IOException($"Source changed during run: {path}");
}
static void EnsureCutOnly(Drawing drawing)
{
if (
drawing.Program.Codes.Any(c =>
c is LinearMove l && l.Layer != LayerType.Cut
|| c is ArcMove a && a.Layer != LayerType.Cut
)
)
throw new InvalidDataException($"Non-cut motion in {drawing.Name}");
}
static List<string> Validate(Nest nest, IReadOnlyDictionary<string, int> demand)
{
var requirements = nest.Drawings.ToDictionary(d => d, d => (d.Name, demand[d.Name]));
var validation = NestValidator.Validate(
nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(),
requirements
);
var counts = nest
.Plates.SelectMany(p => p.Parts)
.GroupBy(p => p.BaseDrawing.Name)
.ToDictionary(g => g.Key, g => g.Count());
foreach (var (name, required) in demand)
if (counts.GetValueOrDefault(name) != required)
validation.Violations.Add(
$"{name}: {counts.GetValueOrDefault(name)} placed, {required} required"
);
foreach (var drawing in nest.Drawings)
EnsureCutOnly(drawing);
return validation.Violations;
}
sealed class JobProgress : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value)
{
if (value.Stage == NestJobStage.PlateCommitted)
Console.WriteLine(
$"Committed {value.CommittedPlates} sheets / {value.CommittedParts} parts"
);
}
}
-13
View File
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<!-- PepLib.Core lives in the PepApi.Core repo; override with -p:PepLibProject=<path> if it is cloned elsewhere. -->
<PepLibProject Condition="'$(PepLibProject)' == ''">$(MSBuildThisFileDirectory)..\..\..\PepApi.Core\PepLib.Core\PepLib.Core.csproj</PepLibProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
<ProjectReference Include="$(PepLibProject)" />
</ItemGroup>
</Project>
-825
View File
@@ -1,825 +0,0 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using OpenNest;
using OpenNest.Benchmark;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
using PepCodes = PepLib.Codes;
using PepModels = PepLib.Models;
using PepVector = PepLib.Geometry.Vector;
// Converts PEP nests (downloaded through PepApi, parsed with PepLib) into OpenNest .nest files
// for OpenNest.Benchmark. Each .nest keeps PEP's own layout - sheet sizes, duplicates, part
// spacing, edge spacing, quadrant and every placement - so the benchmark scores PEP as its
// "Baseline" row and offers engines exactly the sheet sizes PEP used.
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
var options = Options.Parse(args);
if (options == null)
{
Console.Error.WriteLine(
"""
Usage: PepNestExport <output-directory> [options]
--year <yyyy> PEP year to export (default 2026)
--api <url> PepApi base URL (default http://10.10.100.134:8085)
--nests N1,N2,... Only these nest names (default: every nest in the year)
--status S1,S2,... Only these PEP statuses, e.g. "Has been cut,To be cut"
(default: every status except Deleted)
--quantity nested|required Part demand written to the .nest (default nested):
nested = what PEP actually nested, so the PEP layout is a
valid, fully placed baseline
required = PEP's required qty; where PEP over-nested, the
baseline is flagged over-quantity
--parallel <n> Nests converted at once (default 4)
--force Re-download and re-convert nests that already exist
Writes <output>/<nest>.nest, caches the raw files in <output>/pep/, and writes a
per-nest summary to <output>/pep-baseline.csv. Benchmark the output folder with:
OpenNest.Benchmark <output-directory> --engines Opus55 --csv results.csv
"""
);
return 1;
}
Directory.CreateDirectory(options.OutputDirectory);
var pepDirectory = Path.Combine(options.OutputDirectory, "pep");
Directory.CreateDirectory(pepDirectory);
using var http = new HttpClient
{
BaseAddress = new Uri(options.ApiBaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromMinutes(2),
};
var json = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
List<NestSummary> summaries;
try
{
summaries =
await http.GetFromJsonAsync<List<NestSummary>>($"nests/{options.Year}", json)
?? new List<NestSummary>();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Could not list {options.Year} nests from {http.BaseAddress}: {ex.Message}");
return 1;
}
var selected = summaries
.Where(s => options.Nests.Count == 0 || options.Nests.Contains(s.Name))
.Where(s =>
options.Statuses.Count > 0
? options.Statuses.Contains(s.Status)
: !string.Equals(s.Status, "Deleted", StringComparison.OrdinalIgnoreCase)
)
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var missingNests = options.Nests.Except(summaries.Select(s => s.Name), StringComparer.OrdinalIgnoreCase);
foreach (var name in missingNests)
Console.Error.WriteLine($"Warning: {name} is not a {options.Year} nest in PepApi.");
Console.WriteLine(
$"{summaries.Count} nests in {options.Year}; converting {selected.Count} (quantity = {options.Quantity})."
);
var rows = new ConcurrentBag<ReportRow>();
var completed = 0;
await Parallel.ForEachAsync(
selected,
new ParallelOptions { MaxDegreeOfParallelism = options.Parallel },
async (summary, cancellation) =>
{
var row = new ReportRow { Nest = summary.Name, PepStatus = summary.Status };
try
{
var nestPath = Path.Combine(options.OutputDirectory, summary.Name + ".nest");
if (File.Exists(nestPath) && !options.Force)
{
row.Result = "skipped (exists; --force to redo)";
}
else
{
var pepPath = Path.Combine(pepDirectory, summary.Name + ".pep");
if (!File.Exists(pepPath) || options.Force)
{
var url = $"nests/{options.Year}/{Uri.EscapeDataString(summary.Name)}/download";
var bytes = await http.GetByteArrayAsync(url, cancellation);
await File.WriteAllBytesAsync(pepPath, bytes, cancellation);
}
PepModels.Nest pep;
using (var stream = File.OpenRead(pepPath))
pep = PepModels.Nest.Load(stream);
PepConverter.Convert(summary, pep, options.Quantity, row, nestPath);
}
}
catch (Exception ex)
{
row.Result = "error: " + ex.Message.ReplaceLineEndings(" ");
}
rows.Add(row);
var done = Interlocked.Increment(ref completed);
Console.WriteLine($"[{done}/{selected.Count}] {row.Nest}: {row.Result}");
}
);
var ordered = rows.OrderBy(r => r.Nest, StringComparer.OrdinalIgnoreCase).ToList();
var csvPath = Path.Combine(options.OutputDirectory, "pep-baseline.csv");
ReportRow.WriteCsv(csvPath, ordered);
var converted = ordered.Where(r => r.Result == "ok").ToList();
Console.WriteLine();
Console.WriteLine(
$"Converted {converted.Count} (with geometry warnings: {converted.Count(r => r.GeometryWarnings.Count > 0)}; "
+ $"PEP layout fails relaxed check: {converted.Count(r => !r.ValidationTimedOut && !r.RelaxedValid)}; "
+ $"fails strict benchmark check: {converted.Count(r => !r.ValidationTimedOut && !r.BaselineValid)}; "
+ $"validation timed out: {converted.Count(r => r.ValidationTimedOut)}), "
+ $"skipped {ordered.Count(r => r.Result.StartsWith("skipped"))}, "
+ $"errors {ordered.Count(r => r.Result.StartsWith("error"))}."
);
if (converted.Count > 0)
{
var sheet = converted.Sum(r => r.SheetArea);
var part = converted.Sum(r => r.PartArea);
Console.WriteLine(
$"PEP across converted nests: {converted.Sum(r => r.Sheets)} sheets, {sheet:F0} sq in of sheet, "
+ $"{part:F0} sq in of parts, {100 * part / sheet:F1}% utilization."
);
}
Console.WriteLine($"Summary: {csvPath}");
return 0;
static class PepConverter
{
public static void Convert(
NestSummary summary,
PepModels.Nest pep,
QuantityMode quantityMode,
ReportRow row,
string nestPath
)
{
var required = pep
.Drawings.GroupBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Sum(d => d.QtyRequired), StringComparer.OrdinalIgnoreCase);
var pepPlates = pep
.Plates.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.Select(p => (Plate: p, Parts: p.Parts.Where(IsRealPart).ToList()))
.Where(p => p.Parts.Count > 0)
.ToList();
if (pepPlates.Count == 0)
{
row.Result = "skipped (no nested parts)";
return;
}
var first = pepPlates[0].Plate;
var nest = new Nest(summary.Name)
{
Units = Units.Inches,
Customer = summary.Customer,
Thickness = first.Thickness,
Material = new Material(summary.MaterialNumber.ToString(), summary.MaterialGrade),
DateCreated = summary.DateCreated,
DateLastModified = DateTime.Now,
};
var drawings = new Dictionary<string, Drawing>(StringComparer.OrdinalIgnoreCase);
var drawingBoxes = new Dictionary<string, Box>(StringComparer.OrdinalIgnoreCase);
var loopShapes = new Dictionary<string, (OpenNest.CNC.Program Program, Box CutBox)>();
var warnings = new List<string>();
foreach (var (pepPlate, pepParts) in pepPlates)
{
// PEP "PLATE SCALING = 60X120" is Y x X; OpenNest Size is (Width = Y, Length = X).
var plate = new Plate(pepPlate.Size.Height, pepPlate.Size.Width)
{
Quantity = System.Math.Max(1, pepPlate.Duplicates),
Quadrant = pepPlate.Quadrant is >= 1 and <= 4 ? pepPlate.Quadrant : 1,
PartSpacing = pepPlate.PartSpacing,
EdgeSpacing = new Spacing(
pepPlate.EdgeSpacing.Left,
pepPlate.EdgeSpacing.Bottom,
pepPlate.EdgeSpacing.Right,
pepPlate.EdgeSpacing.Top
),
};
foreach (var pepPart in pepParts)
{
if (!loopShapes.TryGetValue(pepPart.Name, out var shape))
{
var loop =
pep.Loops.FirstOrDefault(l => l.Name == pepPart.Name)
?? throw new InvalidDataException($"Loop {pepPart.Name} not found");
var program = ToProgram(loop);
shape = (program, CutBox(program));
loopShapes.Add(pepPart.Name, shape);
}
if (!drawings.TryGetValue(pepPart.DrawingName, out var drawing))
{
drawing = new Drawing(pepPart.DrawingName, shape.Program)
{
Customer = summary.Customer,
Material = nest.Material,
Color = Drawing.GetNextColor(),
};
drawings.Add(pepPart.DrawingName, drawing);
drawingBoxes.Add(pepPart.DrawingName, shape.CutBox);
nest.Drawings.Add(drawing);
if (!HasClosedPerimeter(shape.Program))
warnings.Add($"{pepPart.DrawingName}: no closed outer contour; engines cannot place it");
}
// PEP can place one drawing through several loops, each starting at its own
// pierce point, so each loop's frame is a translation of the drawing's.
var drawingBox = drawingBoxes[pepPart.DrawingName];
var delta = new Vector(
shape.CutBox.Left - drawingBox.Left,
shape.CutBox.Bottom - drawingBox.Bottom
);
if (
System.Math.Abs(shape.CutBox.Width - drawingBox.Width) > 0.01
|| System.Math.Abs(shape.CutBox.Length - drawingBox.Length) > 0.01
)
{
warnings.Add($"{pepPart.DrawingName}: loop {pepPart.Name} differs in size from the drawing's first loop");
}
// Same convention in both systems: rotate the program about its origin, then
// place that origin at the part location.
var part = new Part(drawing);
if (!OpenNest.Math.Tolerance.IsEqualTo(pepPart.Rotation, 0))
part.Rotate(pepPart.Rotation);
part.Location = new Vector(pepPart.Location.X, pepPart.Location.Y) + delta.Rotate(pepPart.Rotation);
plate.Parts.Add(part);
}
nest.Plates.Add(plate);
}
nest.PlateDefaults.SetFromExisting(nest.Plates[0]);
nest.UpdateDrawingQuantities();
foreach (var drawing in nest.Drawings)
{
var pepRequired = required.GetValueOrDefault(drawing.Name);
var nested = drawing.Quantity.Nested;
drawing.Quantity.Required =
quantityMode == QuantityMode.Required && pepRequired > 0 ? pepRequired : nested;
if (pepRequired != nested)
row.QuantityMismatches.Add($"{drawing.Name} req {pepRequired} nested {nested}");
}
var unnested = required
.Where(r => r.Value > 0 && !drawings.ContainsKey(r.Key) && !IsSkeleton(r.Key))
.Select(r => r.Key)
.ToList();
foreach (var name in unnested)
row.QuantityMismatches.Add($"{name} req {required[name]} nested 0 (no geometry; not exported)");
nest.Notes =
$"Converted from PEP {summary.Name} ({summary.Status}; {summary.Comments}). "
+ $"Plates are PEP's own layout. Quantities = PEP {quantityMode.ToString().ToLowerInvariant()} counts.";
new NestWriter(nest).Write(nestPath);
// Report on the saved file (the writer rounds coordinates), which is what the benchmark reads.
FillReport(new NestReader(nestPath).Read(), row, warnings);
var violationsPath = Path.ChangeExtension(nestPath, ".violations.txt");
if (row.Violations.Count > 0)
File.WriteAllLines(violationsPath, row.Violations);
else
File.Delete(violationsPath);
row.Result = "ok";
}
private static void FillReport(Nest nest, ReportRow row, List<string> warnings)
{
var plateRuns = new List<(Plate Plate, List<Part> Parts)>();
foreach (var plate in nest.Plates)
for (var copy = 0; copy < plate.Quantity; copy++)
plateRuns.Add((plate, plate.Parts.ToList()));
var requirements = nest.Drawings.ToDictionary(
d => d,
d => (d.Name, d.Quantity.Required)
);
// PEP stores placements to ~4 decimals and spaces parts at exactly the nominal gap, which
// the validator's circumscribed arc polygons read as slightly short. A relaxed pass
// separates that from real conversion problems (overlaps, parts off the sheet).
var relaxedRuns = plateRuns
.Select(run =>
{
var edge = run.Plate.EdgeSpacing;
var relaxed = new Plate(run.Plate.Size)
{
Quadrant = run.Plate.Quadrant,
PartSpacing = System.Math.Max(0, run.Plate.PartSpacing - RelaxedSpacingTolerance),
EdgeSpacing = new Spacing(
System.Math.Max(0, edge.Left - RelaxedEdgeTolerance),
System.Math.Max(0, edge.Bottom - RelaxedEdgeTolerance),
System.Math.Max(0, edge.Right - RelaxedEdgeTolerance),
System.Math.Max(0, edge.Top - RelaxedEdgeTolerance)
),
};
return (relaxed, run.Parts);
})
.ToList();
row.Material = $"{nest.Material.Name} {nest.Material.Grade} {nest.Thickness:0.###}";
row.Drawings = nest.Drawings.Count;
row.PartsRequested = nest.Drawings.Sum(d => d.Quantity.Required);
row.PartsNested = nest.Drawings.Sum(d => d.Quantity.Nested);
row.Sheets = nest.Plates.Sum(p => p.Quantity);
row.SheetSizes = string.Join(
" ",
nest.Plates.GroupBy(p => (p.Size.Width, p.Size.Length))
.Select(g => $"{g.Key.Width:0.###}x{g.Key.Length:0.###}*{g.Sum(p => p.Quantity)}")
);
row.PartSpacing = string.Join(" ", nest.Plates.Select(p => p.PartSpacing.ToString("0.###")).Distinct());
row.EdgeSpacing = string.Join(
" ",
nest.Plates.Select(p =>
$"{p.EdgeSpacing.Left:0.###}/{p.EdgeSpacing.Bottom:0.###}/{p.EdgeSpacing.Right:0.###}/{p.EdgeSpacing.Top:0.###}"
)
.Distinct()
);
row.SheetArea = nest.Plates.Sum(p => p.Size.Width * p.Size.Length * p.Quantity);
row.PartArea = nest.Drawings.Sum(d => d.Area * d.Quantity.Nested);
// NestValidator can take minutes on parts with hundreds of outline segments and many
// holes; don't let one nest stall the batch. An abandoned check keeps running on a
// pool thread until the process exits.
var check = Task.Run(() =>
(
Strict: NestValidator.Validate(plateRuns, requirements),
Relaxed: NestValidator.Validate(relaxedRuns, requirements)
)
);
row.GeometryWarnings = warnings;
if (!check.Wait(ValidationTimeout))
{
row.ValidationTimedOut = true;
row.Violations = warnings
.Prepend($"validation timed out after {ValidationTimeout.TotalSeconds:0}s (layout not checked)")
.ToList();
return;
}
var (validation, relaxedValidation) = check.Result;
row.BaselineValid = validation.Valid;
row.StrictViolations = validation.Violations.Count;
row.RelaxedValid = relaxedValidation.Valid;
row.Violations = relaxedValidation
.Violations.Select(v => "relaxed: " + v)
.Concat(warnings)
.Concat(validation.Violations.Select(v => "strict: " + v))
.ToList();
}
private const double RelaxedSpacingTolerance = 0.025;
private const double RelaxedEdgeTolerance = 0.001;
private static readonly TimeSpan ValidationTimeout = TimeSpan.FromSeconds(60);
private static Box CutBox(OpenNest.CNC.Program program) =>
OpenNest.Converters.ConvertProgram.ToGeometry(program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.Cast<IBoundable>()
.GetBoundingBox();
private static bool HasClosedPerimeter(OpenNest.CNC.Program program)
{
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
return entities.Count > 0 && new ShapeProfile(entities).Perimeter?.Area() > 1e-9;
}
private static bool IsRealPart(PepModels.Part part) =>
!part.IsDisplayOnly && !string.IsNullOrWhiteSpace(part.DrawingName) && !IsSkeleton(part.DrawingName);
private static bool IsSkeleton(string name) =>
name.StartsWith("Skeleton", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Flattens a PEP loop (incremental, with sub-loop calls for holes) into an absolute OpenNest
/// program in the loop's own frame. Only contour cuts are kept as cut motion; display, scribe,
/// lead-in/out and destruct (slug-chopping) moves become rapids so they never shape the part
/// for nesting. Contours PEP leaves open by a micro-joint are closed.
/// </summary>
private static OpenNest.CNC.Program ToProgram(PepModels.Loop loop)
{
var codes = new List<ICode>();
Emit(loop, new PepVector(0, 0), codes);
var program = new OpenNest.CNC.Program(Mode.Absolute);
program.Codes.AddRange(CollapseRapids(CloseMicroJoints(codes)));
// Built absolute, stored incremental like CAD-imported drawings: the desktop renderer
// only applies a part's location to incremental programs.
program.Mode = Mode.Incremental;
return program;
}
/// <summary>
/// Replaces each run of non-cut moves with one rapid onto the next contour's start and drops
/// trailing ones. Lead-in/out endpoints lie outside the part and a program's bounding box
/// counts rapid endpoints, so leaving them in would inflate the part for edge checks.
/// </summary>
private static IEnumerable<ICode> CollapseRapids(IEnumerable<ICode> codes)
{
var pos = new Vector(0, 0);
var pendingRapid = false;
foreach (var code in codes)
{
if (code is LinearMove or ArcMove)
{
if (pendingRapid)
yield return new RapidMove(pos);
pendingRapid = false;
yield return code;
}
else if (code is Motion)
{
pendingRapid = true;
}
if (code is Motion motion)
pos = motion.EndPoint;
}
}
/// <summary>Largest uncut tab (micro-joint) gap that is bridged to close a contour.</summary>
private const double MaxMicroJointGap = 0.25;
/// <summary>
/// PEP leaves tabs uncut to hold parts and cutouts in place: the cut stops, jumps the tab
/// with a rapid, and carries on (e.g. a cutout cut as two halves 0.02 apart, or an outer
/// contour stopping 0.03 short of its start). The part still occupies that material, so
/// open cut runs are chained end to start across gaps up to <see cref="MaxMicroJointGap"/>
/// and each chain that closes into a loop gets a cut line across every tab. Links are
/// matched shortest gap first, and only closed loops are kept, so separate contours that
/// happen to lie close together (closed ones never take part) are not merged.
/// </summary>
private static IEnumerable<ICode> CloseMicroJoints(List<ICode> codes)
{
var runs = SplitCutRuns(codes);
var open = Enumerable
.Range(0, runs.Count)
.Where(i => runs[i].Start.DistanceTo(runs[i].End) > OpenNest.Math.Tolerance.Epsilon)
.ToList();
var next = new Dictionary<int, int>();
var previous = new Dictionary<int, int>();
var links =
from i in open
from j in open
let gap = runs[i].End.DistanceTo(runs[j].Start)
where gap <= MaxMicroJointGap
orderby gap
select (From: i, To: j);
foreach (var (from, to) in links)
{
if (next.ContainsKey(from) || previous.ContainsKey(to))
continue;
next[from] = to;
previous[to] = from;
}
// Keep only links that close a loop; a chain that dead-ends is left as it was.
var cycleOf = new Dictionary<int, List<int>>();
foreach (var first in open.Where(next.ContainsKey))
{
if (cycleOf.ContainsKey(first))
continue;
var cycle = new List<int> { first };
var current = next[first];
while (current != first && next.TryGetValue(current, out var following) && !cycle.Contains(current))
{
cycle.Add(current);
current = following;
}
if (current != first)
continue;
foreach (var index in cycle)
cycleOf[index] = cycle;
}
var emitted = new HashSet<int>();
for (var i = 0; i < runs.Count; i++)
{
if (emitted.Contains(i))
continue;
if (!cycleOf.TryGetValue(i, out var cycle))
{
emitted.Add(i);
yield return new RapidMove(runs[i].Start);
foreach (var code in runs[i].Codes)
yield return code;
continue;
}
// Start the loop at the run that comes first in the program.
var offset = cycle.IndexOf(i);
yield return new RapidMove(runs[i].Start);
for (var k = 0; k < cycle.Count; k++)
{
var run = runs[cycle[(offset + k) % cycle.Count]];
var following = runs[cycle[(offset + k + 1) % cycle.Count]];
emitted.Add(cycle[(offset + k) % cycle.Count]);
foreach (var code in run.Codes)
yield return code;
if (run.End.DistanceTo(following.Start) > OpenNest.Math.Tolerance.Epsilon)
yield return new LinearMove(following.Start) { Layer = LayerType.Cut };
}
}
}
private sealed record CutRun(Vector Start, Vector End, List<ICode> Codes);
/// <summary>
/// Splits an absolute program into runs of consecutive cut moves. Everything else only
/// positions the head, and <see cref="CollapseRapids"/> rebuilds it afterwards.
/// </summary>
private static List<CutRun> SplitCutRuns(List<ICode> codes)
{
var runs = new List<CutRun>();
var pos = new Vector(0, 0);
List<ICode> current = null;
var start = pos;
foreach (var code in codes)
{
if (code is LinearMove or ArcMove)
{
if (current == null)
{
current = new List<ICode>();
start = pos;
}
current.Add(code);
}
else if (current != null)
{
runs.Add(new CutRun(start, pos, current));
current = null;
}
if (code is Motion motion)
pos = motion.EndPoint;
}
if (current != null)
runs.Add(new CutRun(start, pos, current));
return runs;
}
private static PepVector Emit(PepModels.Program source, PepVector start, List<ICode> codes)
{
var pos = start;
var inDestructCut = false;
foreach (var code in source)
{
switch (code)
{
case PepCodes.Comment comment:
if (comment.Value.StartsWith("DESTRUCT CUT START", StringComparison.OrdinalIgnoreCase))
inDestructCut = true;
else if (comment.Value.StartsWith("DESTRUCT CUT END", StringComparison.OrdinalIgnoreCase))
inDestructCut = false;
break;
case PepCodes.RapidMove rapid:
pos = Advance(pos, rapid.EndPoint, source.Mode);
codes.Add(new RapidMove(ToVector(pos)));
break;
case PepCodes.LinearMove line:
pos = Advance(pos, line.EndPoint, source.Mode);
codes.Add(
line.Type == PepCodes.EntityType.Cut && !inDestructCut
? new LinearMove(ToVector(pos)) { Layer = LayerType.Cut }
: new RapidMove(ToVector(pos))
);
break;
case PepCodes.CircularMove arc:
var arcStart = pos;
pos = Advance(pos, arc.EndPoint, source.Mode);
var center = EquidistantCenter(arcStart, pos, Advance(arcStart, arc.CenterPoint, source.Mode));
codes.Add(
arc.Type == PepCodes.EntityType.Cut && !inDestructCut
? new ArcMove(
ToVector(pos),
ToVector(center),
arc.Rotation == PepLib.Enums.RotationType.CW
? RotationType.CW
: RotationType.CCW
)
{
Layer = LayerType.Cut,
}
: new RapidMove(ToVector(pos))
);
break;
case PepCodes.SubProgramCall call when call.Loop != null:
// Incremental position carries through the sub-loop: the caller resumes
// where the sub-loop ended (e.g. identical holes called 13.8125 apart after
// a 0.1875 lead-in sit on a 14.000 pitch).
pos = Emit(call.Loop, pos, codes);
break;
}
}
return pos;
}
/// <summary>
/// PEP stores some small arcs with a center that is not equidistant from both ends (e.g. a
/// 0.06 notch with radii 0.0300 and 0.0298). OpenNest rebuilds the arc from its end point, so
/// its start would miss the previous move and break the contour. Projecting the center onto
/// the chord's perpendicular bisector keeps both endpoints exact. Full circles are unchanged.
/// </summary>
private static PepVector EquidistantCenter(PepVector start, PepVector end, PepVector center)
{
var chordX = end.X - start.X;
var chordY = end.Y - start.Y;
var chord = System.Math.Sqrt(chordX * chordX + chordY * chordY);
if (chord < 1e-9)
return center;
var midX = (start.X + end.X) / 2;
var midY = (start.Y + end.Y) / 2;
var normalX = -chordY / chord;
var normalY = chordX / chord;
var along = (center.X - midX) * normalX + (center.Y - midY) * normalY;
return new PepVector(midX + along * normalX, midY + along * normalY);
}
private static PepVector Advance(PepVector current, PepVector offset, PepLib.Enums.ProgrammingMode mode) =>
mode == PepLib.Enums.ProgrammingMode.Incremental ? current + offset : offset;
private static Vector ToVector(PepVector v) => new(v.X, v.Y);
}
enum QuantityMode
{
Nested,
Required,
}
sealed record NestSummary(
string Name,
DateTime DateCreated,
string Status,
string Comments,
string Customer,
int MaterialNumber,
string MaterialGrade,
string Application
);
sealed class Options
{
public string OutputDirectory;
public int Year = 2026;
public string ApiBaseUrl = "http://10.10.100.134:8085";
public HashSet<string> Nests = new(StringComparer.OrdinalIgnoreCase);
public HashSet<string> Statuses = new(StringComparer.OrdinalIgnoreCase);
public QuantityMode Quantity = QuantityMode.Nested;
public int Parallel = 4;
public bool Force;
public static Options Parse(string[] args)
{
var o = new Options();
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--year" when i + 1 < args.Length:
o.Year = int.Parse(args[++i], CultureInfo.InvariantCulture);
break;
case "--api" when i + 1 < args.Length:
o.ApiBaseUrl = args[++i];
break;
case "--nests" when i + 1 < args.Length:
o.Nests.UnionWith(SplitList(args[++i]));
break;
case "--status" when i + 1 < args.Length:
o.Statuses.UnionWith(SplitList(args[++i]));
break;
case "--quantity" when i + 1 < args.Length:
if (!Enum.TryParse(args[++i], ignoreCase: true, out o.Quantity))
return null;
break;
case "--parallel" when i + 1 < args.Length:
o.Parallel = System.Math.Max(1, int.Parse(args[++i], CultureInfo.InvariantCulture));
break;
case "--force":
o.Force = true;
break;
default:
if (args[i].StartsWith("--") || o.OutputDirectory != null)
return null;
o.OutputDirectory = Path.GetFullPath(args[i]);
break;
}
}
return o.OutputDirectory == null ? null : o;
}
private static IEnumerable<string> SplitList(string value) =>
value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
sealed class ReportRow
{
public string Nest;
public string PepStatus;
public string Result = "";
public string Material = "";
public int Drawings;
public int PartsRequested;
public int PartsNested;
public int Sheets;
public string SheetSizes = "";
public string PartSpacing = "";
public string EdgeSpacing = "";
public double SheetArea;
public double PartArea;
public bool BaselineValid;
public int StrictViolations;
public bool RelaxedValid;
public bool ValidationTimedOut;
public List<string> Violations = new();
public List<string> GeometryWarnings = new();
public List<string> QuantityMismatches = new();
public static void WriteCsv(string path, IEnumerable<ReportRow> rows)
{
var sb = new StringBuilder();
sb.AppendLine(
"Nest,PepStatus,Result,Material,Drawings,PartsRequested,PartsNested,Sheets,SheetSizes,"
+ "PartSpacing,EdgeSpacing(L/B/R/T),SheetArea,PartArea,Utilization%,RelaxedValid,"
+ "StrictValid,StrictViolations,GeometryWarnings,Violations,QuantityMismatches"
);
foreach (var r in rows)
{
var utilization = r.SheetArea > 0 ? 100 * r.PartArea / r.SheetArea : 0;
sb.AppendLine(
string.Join(
",",
Csv(r.Nest),
Csv(r.PepStatus),
Csv(r.Result),
Csv(r.Material),
r.Drawings,
r.PartsRequested,
r.PartsNested,
r.Sheets,
Csv(r.SheetSizes),
Csv(r.PartSpacing),
Csv(r.EdgeSpacing),
r.SheetArea.ToString("F2"),
r.PartArea.ToString("F2"),
utilization.ToString("F2"),
r.Result != "ok" ? "" : r.ValidationTimedOut ? "timeout" : r.RelaxedValid.ToString(),
r.Result != "ok" ? "" : r.ValidationTimedOut ? "timeout" : r.BaselineValid.ToString(),
r.Result != "ok" || r.ValidationTimedOut ? "" : r.StrictViolations.ToString(),
Csv(string.Join(" | ", r.GeometryWarnings)),
Csv(string.Join(" | ", r.Violations.Take(5)) + (r.Violations.Count > 5 ? $" | +{r.Violations.Count - 5} more" : "")),
Csv(string.Join(" | ", r.QuantityMismatches))
)
);
}
File.WriteAllText(path, sb.ToString());
}
private static string Csv(string value) =>
value.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0
? "\"" + value.Replace("\"", "\"\"") + "\""
: value;
}
-343
View File
@@ -1,343 +0,0 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.IO.Ports;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
if (args.Length < 2)
{
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(
" StreamGravographJob <file.prn> <COMx> [chunk=256] [flow=rtscts|xonxoff|none]"
);
Console.Error.WriteLine(
" StreamGravographJob --gen <name> <outfile.prn> # name: testA | testB | miniB | miniSquare"
);
Console.Error.WriteLine(" StreamGravographJob --inspect-nest <file.nest>");
Console.Error.WriteLine(" StreamGravographJob --from-nest <file.nest> <outfile.prn>");
return 2;
}
// Inspect a .nest file: extract polylines via the post-processor pipeline and
// report dimensions / bounding box / pen-up travel — no bytes written.
if (args[0] == "--inspect-nest")
{
if (args.Length < 2)
{
Console.Error.WriteLine("--inspect-nest requires <file.nest>");
return 2;
}
var nestPath = args[1];
if (!File.Exists(nestPath))
{
Console.Error.WriteLine($"Not found: {nestPath}");
return 3;
}
using var fs = new FileStream(nestPath, FileMode.Open, FileAccess.Read);
var reader = new OpenNest.IO.NestReader(fs);
var nest = reader.Read();
Console.WriteLine($"Nest: {nest.Name}");
Console.WriteLine($"Units: {nest.Units}");
Console.WriteLine($"Plates: {nest.Plates.Count}");
var plateIdx = 0;
foreach (var plate in nest.Plates)
{
plateIdx++;
Console.WriteLine(
$" Plate {plateIdx}: size={plate.Size.Length} x {plate.Size.Width}, quadrant={plate.Quadrant}, parts={plate.Parts.Count}"
);
}
var polylines = new OpenNest.Posts.GravographIS.NestPolylineExtractor().Extract(nest);
if (polylines.Count == 0)
{
Console.WriteLine("No polylines extracted.");
return 0;
}
double minX = double.PositiveInfinity,
minY = double.PositiveInfinity;
double maxX = double.NegativeInfinity,
maxY = double.NegativeInfinity;
int totalPts = 0;
foreach (var p in polylines)
{
foreach (var v in p)
{
if (v.X < minX)
minX = v.X;
if (v.X > maxX)
maxX = v.X;
if (v.Y < minY)
minY = v.Y;
if (v.Y > maxY)
maxY = v.Y;
}
totalPts += p.Count;
}
Console.WriteLine($"Polylines: {polylines.Count}, total points: {totalPts}");
Console.WriteLine(
$"Bounding box (inches): X ∈ [{minX:F3}, {maxX:F3}] Y ∈ [{minY:F3}, {maxY:F3}]"
);
Console.WriteLine($"Extents: {maxX - minX:F3}\" × {maxY - minY:F3}\"");
// After running the pre-pass (stitch + reorder from origin) — what the writer will actually consume.
var prepared = OpenNest.Posts.GravographIS.PolylinePrePass.Prepare(polylines);
Console.WriteLine($"After stitch+reorder: {prepared.Count} polylines");
Console.WriteLine();
Console.WriteLine("--- Vertex dump (prepared, upper-left origin, with segment deltas) ---");
var pi = 0;
foreach (var poly in prepared)
{
pi++;
Console.WriteLine($"Polyline {pi}: {poly.Count} points");
var cumX = 0.0;
var cumY = 0.0;
for (var i = 0; i < poly.Count; i++)
{
var v = poly[i];
if (i == 0)
{
Console.WriteLine(
$" [{i}] ({v.X, 7:F3}, {v.Y, 7:F3}) first DR travel from upper-left origin=({v.X, +7:F3}, {v.Y, +7:F3})"
);
}
else
{
var dx = v.X - poly[i - 1].X;
var dy = v.Y - poly[i - 1].Y;
cumX += dx;
cumY += dy;
Console.WriteLine(
$" [{i}] ({v.X, 7:F3}, {v.Y, 7:F3}) Δ=({dx, +7:F3}, {dy, +7:F3}) cum from origin=({cumX, +7:F3}, {cumY, +7:F3})"
);
}
}
}
return 0;
}
// Convert a .nest file to a .prn job via the full post-processor pipeline.
if (args[0] == "--from-nest")
{
if (args.Length < 3)
{
Console.Error.WriteLine("--from-nest requires <file.nest> <outfile.prn>");
return 2;
}
var nestPath = args[1];
var outFile = args[2];
if (!File.Exists(nestPath))
{
Console.Error.WriteLine($"Not found: {nestPath}");
return 3;
}
using var fs = new FileStream(nestPath, FileMode.Open, FileAccess.Read);
var nest = new OpenNest.IO.NestReader(fs).Read();
var post = new OpenNest.Posts.GravographIS.GravographISPostProcessor();
post.Post(nest, outFile);
var size = new FileInfo(outFile).Length;
Console.WriteLine($"Wrote {size} bytes → {outFile}");
return 0;
}
// Generator mode: run the live writer to produce a captured-test file on disk.
if (args[0] == "--gen")
{
if (args.Length < 3)
{
Console.Error.WriteLine("--gen requires <name> <outfile>");
return 2;
}
var preset = args[1];
var outFile = args[2];
var polylines = preset.ToLowerInvariant() switch
{
"testa" =>
new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
new[] { new OpenNest.Geometry.Vector(1, 1), new OpenNest.Geometry.Vector(1, 3) },
},
"testb" =>
new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
new[] { new OpenNest.Geometry.Vector(1, 1), new OpenNest.Geometry.Vector(1, 3) },
new[] { new OpenNest.Geometry.Vector(4, 1), new OpenNest.Geometry.Vector(4, 3) },
new[] { new OpenNest.Geometry.Vector(4, 5), new OpenNest.Geometry.Vector(4, 7) },
new[] { new OpenNest.Geometry.Vector(1, 5), new OpenNest.Geometry.Vector(1, 7) },
},
// Same 4-polyline topology as testB (vertical lines + diagonal PU travels between them),
// shrunk to a 0.5" × 1.5" footprint so it stays right near the operator-set work origin.
"minib" =>
new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
new[] { new OpenNest.Geometry.Vector(0, 0), new OpenNest.Geometry.Vector(0, 0.5) },
new[]
{
new OpenNest.Geometry.Vector(0.5, 0),
new OpenNest.Geometry.Vector(0.5, 0.5),
},
new[]
{
new OpenNest.Geometry.Vector(0.5, 1),
new OpenNest.Geometry.Vector(0.5, 1.5),
},
new[] { new OpenNest.Geometry.Vector(0, 1), new OpenNest.Geometry.Vector(0, 1.5) },
},
// Closed 0.5" square as a SINGLE polyline of 5 points → 4-segment PD packet.
// Exercises multi-segment PD (one FF FD 50 44 00 00 followed by 4 records,
// no intermediate lifts) and bi-directional motion (X+, Y+, X−, Y−).
// Returns the head to its starting point so no manual jog needed after.
"minisquare" =>
new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
new[]
{
new OpenNest.Geometry.Vector(0, 0),
new OpenNest.Geometry.Vector(0.5, 0),
new OpenNest.Geometry.Vector(0.5, 0.5),
new OpenNest.Geometry.Vector(0, 0.5),
new OpenNest.Geometry.Vector(0, 0),
},
},
_ => throw new ArgumentException(
$"Unknown preset '{preset}' (try testA, testB, miniB, or miniSquare)."
),
};
using var outFs = new FileStream(outFile, FileMode.Create, FileAccess.Write);
new OpenNest.Posts.GravographIS.GravographISWriter().Write(polylines, outFs);
Console.WriteLine($"Wrote {new FileInfo(outFile).Length} bytes via live writer → {outFile}");
return 0;
}
var file = args[0];
var portName = args[1];
var chunk = args.Length > 2 ? int.Parse(args[2]) : 256;
var flowArg = args.Length > 3 ? args[3] : "rtscts";
var handshake = flowArg.ToLowerInvariant() switch
{
"rtscts" or "rts" or "cts" => Handshake.RequestToSend,
"xonxoff" or "xon" or "xoff" => Handshake.XOnXOff,
"none" => Handshake.None,
_ => throw new ArgumentException($"Unknown flow control '{flowArg}'."),
};
if (!File.Exists(file))
{
Console.Error.WriteLine($"File not found: {file}");
return 3;
}
var bytes = File.ReadAllBytes(file);
Console.WriteLine($"File: {file}");
Console.WriteLine($"Size: {bytes.Length} bytes");
Console.WriteLine(
$"Header: {BitConverter.ToString(bytes, 0, Math.Min(7, bytes.Length)).Replace('-', ' ')}"
);
var ports = SerialPort.GetPortNames();
Array.Sort(ports);
Console.WriteLine($"Available COM ports: {string.Join(", ", ports)}");
if (Array.IndexOf(ports, portName) < 0)
{
Console.Error.WriteLine($"{portName} not in available ports.");
return 4;
}
using var port = new SerialPort(portName, 9600, Parity.None, 8, StopBits.One)
{
Handshake = handshake,
WriteTimeout = 30000,
ReadTimeout = 30000,
WriteBufferSize = 4096,
DtrEnable = true,
};
// Probe with the same CreateFile flags SerialStream uses, in this same process,
// so we can tell SerialStream-specific failures apart from process-level access denials.
{
const uint GENERIC_RW = 0x80000000u | 0x40000000u;
const uint OPEN_EXISTING = 3;
const uint FILE_FLAG_OVERLAPPED = 0x40000000u;
var devName = @"\\.\" + portName;
var handle = NativeMethods.CreateFileW(
devName,
GENERIC_RW,
0,
IntPtr.Zero,
OPEN_EXISTING,
FILE_FLAG_OVERLAPPED,
IntPtr.Zero
);
var err = Marshal.GetLastWin32Error();
if (handle.IsInvalid)
{
Console.WriteLine(
$"CreateFile(\"{devName}\", overlapped, exclusive) FAILED: win32={err} ({new Win32Exception(err).Message})"
);
}
else
{
Console.WriteLine($"CreateFile(\"{devName}\", overlapped, exclusive) OK — closing.");
handle.Close();
}
}
Console.WriteLine($"Opening {portName} 9600 8N1 handshake={handshake}...");
port.Open();
Console.WriteLine("Opened.");
var sw = Stopwatch.StartNew();
try
{
for (var i = 0; i < bytes.Length; i += chunk)
{
var n = Math.Min(chunk, bytes.Length - i);
port.Write(bytes, i, n);
}
try
{
port.BaseStream.Flush();
}
catch
{ /* advisory */
}
Thread.Sleep(500);
}
finally
{
sw.Stop();
port.Close();
}
Console.WriteLine($"Sent {bytes.Length} bytes in {sw.ElapsedMilliseconds} ms. Port closed.");
return 0;
internal static class NativeMethods
{
[DllImport(
"kernel32.dll",
SetLastError = true,
CharSet = CharSet.Unicode,
EntryPoint = "CreateFileW"
)]
internal static extern SafeFileHandle CreateFileW(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile
);
}
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>OpenNest.Tools.StreamGravographJob</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
<ProjectReference Include="..\..\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
<ProjectReference Include="..\..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>