diff --git a/README.md b/README.md index 276d64d..0fcd062 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tools/NestDxfJob/NestDxfJob.csproj b/tools/NestDxfJob/NestDxfJob.csproj new file mode 100644 index 0000000..9418a95 --- /dev/null +++ b/tools/NestDxfJob/NestDxfJob.csproj @@ -0,0 +1,10 @@ + + + Exe + net8.0 + enable + + + + + diff --git a/tools/NestDxfJob/Program.cs b/tools/NestDxfJob/Program.cs new file mode 100644 index 0000000..93160aa --- /dev/null +++ b/tools/NestDxfJob/Program.cs @@ -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 " + ); + 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 { 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(); + 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 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 Validate(Nest nest, IReadOnlyDictionary 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 +{ + public void Report(NestJobProgress value) + { + if (value.Stage == NestJobStage.PlateCommitted) + Console.WriteLine( + $"Committed {value.CommittedPlates} sheets / {value.CommittedParts} parts" + ); + } +}