feat(tools): headless DXF+workbook job verification tool
NestDxfJob imports a DXF folder plus a quantity workbook, runs a registered whole-job engine, and only publishes a nest that passes quantity, bounds, overlap/spacing, and cut-only checks both before and after save/reload. Writes import and validation reports; partial or invalid results exit nonzero. Standalone project (not in the solution); documented in the README.
This commit is contained in:
@@ -120,6 +120,20 @@ var domainResult = NestResultMaterializer.Materialize(job, result);
|
||||
|
||||
**Legacy caller boundaries (not yet migrated):** the desktop UI (`MainForm.RunAutoNestAsync` / `NestSinglePlateAsync`), the CLI (`OpenNest.Console`), and MCP (`NestingTools`) still call the old single-plate `engine.Nest(...)` entry points unchanged. UI adoption needs a separate adapter preserving populated-plate editing, preview routing, and Accept-versus-Cancel semantics. The public API (`OpenNest.Api`, `NestRunner.RunAsync`) already delegates to one `NestJobRunner.Solve` call and 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.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
@@ -0,0 +1,296 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user