diff --git a/CLAUDE.md b/CLAUDE.md index 7565efa..17098d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,7 @@ Compares registered `INestingEngine` implementations against each other on real - `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring. - Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`). - `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv ` writes a flat per-job CSV alongside the console report. +- `tools/PepNestExport` (outside the solution; references `PepLib.Core` from the sibling `PepApi.Core` repo) converts a PepApi year of PEP nests into `.nest` files that keep PEP's placements as the benchmark `Baseline`. PEP loop quirks: sub-loop calls continue the incremental position; lead-in/out, `DESTRUCT CUT` and non-cut moves must not reach the program as rapids (a program's bounding box counts rapid endpoints); contours may be broken by uncut micro-joint tabs (a rapid of up to 0.25 across the tab, at the seam or mid-contour, e.g. a cutout cut as two halves), which the export bridges only where the pieces chain into a closed loop; and one drawing can be placed through several loops with different origins. ### OpenNest.Mcp (console app, depends on Core + Engine + IO) MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`. diff --git a/README.md b/README.md index 35c627e..03489aa 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,21 @@ The settings nest supplies units, material metadata, per-part rotation constrain 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=` 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 `.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 ```bash diff --git a/tools/PepNestExport/PepNestExport.csproj b/tools/PepNestExport/PepNestExport.csproj new file mode 100644 index 0000000..e6efc19 --- /dev/null +++ b/tools/PepNestExport/PepNestExport.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + enable + + $(MSBuildThisFileDirectory)..\..\..\PepApi.Core\PepLib.Core\PepLib.Core.csproj + + + + + + diff --git a/tools/PepNestExport/Program.cs b/tools/PepNestExport/Program.cs new file mode 100644 index 0000000..fb02af8 --- /dev/null +++ b/tools/PepNestExport/Program.cs @@ -0,0 +1,825 @@ +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 [options] + + --year PEP year to export (default 2026) + --api 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 Nests converted at once (default 4) + --force Re-download and re-convert nests that already exist + + Writes /.nest, caches the raw files in /pep/, and writes a + per-nest summary to /pep-baseline.csv. Benchmark the output folder with: + OpenNest.Benchmark --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 summaries; +try +{ + summaries = + await http.GetFromJsonAsync>($"nests/{options.Year}", json) + ?? new List(); +} +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(); +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(StringComparer.OrdinalIgnoreCase); + var drawingBoxes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var loopShapes = new Dictionary(); + var warnings = new List(); + + 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 warnings) + { + var plateRuns = new List<(Plate Plate, List 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() + .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); + + /// + /// 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. + /// + private static OpenNest.CNC.Program ToProgram(PepModels.Loop loop) + { + var codes = new List(); + 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; + } + + /// + /// 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. + /// + private static IEnumerable CollapseRapids(IEnumerable 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; + } + } + + /// Largest uncut tab (micro-joint) gap that is bridged to close a contour. + private const double MaxMicroJointGap = 0.25; + + /// + /// 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 + /// 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. + /// + private static IEnumerable CloseMicroJoints(List 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(); + var previous = new Dictionary(); + 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>(); + foreach (var first in open.Where(next.ContainsKey)) + { + if (cycleOf.ContainsKey(first)) + continue; + + var cycle = new List { 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(); + 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 Codes); + + /// + /// Splits an absolute program into runs of consecutive cut moves. Everything else only + /// positions the head, and rebuilds it afterwards. + /// + private static List SplitCutRuns(List codes) + { + var runs = new List(); + var pos = new Vector(0, 0); + List current = null; + var start = pos; + + foreach (var code in codes) + { + if (code is LinearMove or ArcMove) + { + if (current == null) + { + current = new List(); + 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 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; + } + + /// + /// 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. + /// + 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 Nests = new(StringComparer.OrdinalIgnoreCase); + public HashSet 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 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 Violations = new(); + public List GeometryWarnings = new(); + public List QuantityMismatches = new(); + + public static void WriteCsv(string path, IEnumerable 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; +}