From b026bd19d24062bac245fb6bf4f3cacffded8f3d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 5 Nov 2026 19:38:00 -0500 Subject: [PATCH] feat(common-line): place parts on common lines automatically at a chosen level Slice 3 of common-line cutting. On costly plate (N26-295: 3/16 RA310S at about $2,500 a sheet) pairing parts one kerf apart fits the part the spacing cannot: the last plate's 38 parts fit on one 48x120 with Auto Nest only with pairs. - Drawing Properties gains "Allow common line" (Drawing.AllowCommonLine, off by default because customer edge-quality needs vary). The nest's CommonLineUsage (Off / When it adds parts / Whenever available) is set in Auto Nest and seeded from Tools > Nest Defaults; both persist (nest.json, defaults.json). - NestPipeline runs the ordinary solve, then, with a level on, trials where eligible parts are offered as kerf-gap pair compounds. Placed pairs expand into parts plus explicit NestJobCommonLine groups, are validated again, and CommonLineRanking keeps a trial only by the level: count first, then sheets; "when it adds parts" keeps the plain layout on a tie, "whenever" also takes a shorter cut or less material, never at the cost of a part. Engines, FillScore and their comparers are unchanged; Off asks the engine exactly what it did. - NestLayoutCheck exempts members only through a sheet's explicit group list and only when the job allows common lines; materializing and committing create Plate.CommonLines. - Server nesting writes common-line jobs as version 3 and replays the recorded trial answers, so the desktop binds the same groups. - Fill Area honors the level through a PlateFillService overload. - The benchmark gains --common-lines --kerf . Benchmark (synthetic nests + N26-295, --parallel 1): with no flag and with --common-lines off every engine matches the base commit; with a level on no layout becomes invalid and three runs save a sheet at equal nested area. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- OpenNest.Benchmark/BenchmarkRunner.cs | 23 +- OpenNest.Benchmark/CommonLineSetting.cs | 28 ++ OpenNest.Benchmark/Program.cs | 46 ++- OpenNest.Core/CommonLineUsage.cs | 47 +++ OpenNest.Core/Drawing.cs | 8 + OpenNest.Core/Nest.cs | 6 + OpenNest.Core/Splitting/DrawingSplitter.cs | 1 + OpenNest.Data/NestDefaults.cs | 29 ++ OpenNest.Desktop/AutoNestJob.cs | 16 +- .../Jobs/CommonLineAutoPlacementTests.cs | 357 ++++++++++++++++++ .../Jobs/NestJobDocumentPriorityTests.cs | 2 +- .../Jobs/Adapters/DrawingJobMapper.cs | 12 +- .../Jobs/Adapters/NestResultBinder.cs | 17 + .../Jobs/Adapters/NestResultMaterializer.cs | 21 ++ .../Jobs/CommonLines/CommonLinePair.cs | 186 +++++++++ .../CommonLines/CommonLinePipelinePrepass.cs | 165 ++++++++ .../Jobs/CommonLines/CommonLineRanking.cs | 91 +++++ .../Jobs/Cutouts/CutoutPipelinePrepass.cs | 64 +--- OpenNest.Engine/Jobs/NestJobOptions.cs | 30 +- OpenNest.Engine/Jobs/NestJobPart.cs | 6 + OpenNest.Engine/Jobs/NestJobResult.cs | 46 ++- OpenNest.Engine/Jobs/NestJobResultBuilder.cs | 5 +- OpenNest.Engine/Jobs/NestLayoutCheck.cs | 92 ++++- OpenNest.Engine/Jobs/NestPipeline.cs | 73 +++- OpenNest.Engine/Jobs/NestPipelineCommit.cs | 2 + OpenNest.Engine/Jobs/NestRenest.cs | 8 +- OpenNest.Engine/Jobs/PipelinePrepass.cs | 94 +++++ .../Placement/PlateFillService.CommonLines.cs | 116 ++++++ .../Jobs/Placement/PlateFillService.cs | 2 +- .../Jobs/Remote/NestJobDocument.cs | 27 +- .../Jobs/Remote/NestSolveTranscript.cs | 17 +- .../Jobs/Remote/RecordingNestingEngine.cs | 2 +- .../Jobs/Remote/RemoteNestProgress.cs | 6 + OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs | 3 +- .../CommonLineUsagePersistenceTests.cs | 105 ++++++ OpenNest.IO.Tests/DrawingPackageTests.cs | 1 + OpenNest.IO/DrawingPackage.cs | 2 +- OpenNest.IO/NestFormat.cs | 12 + OpenNest.IO/NestReader.cs | 2 + OpenNest.IO/NestWriter.cs | 2 + .../CommonLineAutoPlacementTests.cs | 59 +++ OpenNest.Tests/Data/NestDefaultsTests.cs | 62 +++ .../Forms/CommonLineUsageFormTests.cs | 46 +++ .../Forms/NestDefaultsFormTests.cs | 20 + OpenNest/Actions/ActionFillArea.cs | 20 +- OpenNest/Forms/AutoNestForm.Designer.cs | 27 ++ OpenNest/Forms/AutoNestForm.cs | 23 ++ OpenNest/Forms/Commands/AutoNestCommand.cs | 20 +- OpenNest/Forms/EditDrawingForm.Designer.cs | 31 +- OpenNest/Forms/EditDrawingForm.cs | 8 + OpenNest/Forms/MainForm.Tools.cs | 5 +- OpenNest/Forms/NestDefaultsForm.Designer.cs | 57 ++- OpenNest/Forms/NestDefaultsForm.cs | 21 +- docs/automatic-nesting.md | 1 + docs/common-line-cutting.md | 58 ++- docs/nest-file-format.md | 12 +- docs/nesting-engines.md | 12 + docs/performance/engine-benchmarking.md | 2 + docs/server-nesting.md | 13 +- docs/shared-settings.md | 2 +- 61 files changed, 2155 insertions(+), 118 deletions(-) create mode 100644 OpenNest.Benchmark/CommonLineSetting.cs create mode 100644 OpenNest.Core/CommonLineUsage.cs create mode 100644 OpenNest.Engine.Tests/Jobs/CommonLineAutoPlacementTests.cs create mode 100644 OpenNest.Engine/Jobs/CommonLines/CommonLinePair.cs create mode 100644 OpenNest.Engine/Jobs/CommonLines/CommonLinePipelinePrepass.cs create mode 100644 OpenNest.Engine/Jobs/CommonLines/CommonLineRanking.cs create mode 100644 OpenNest.Engine/Jobs/PipelinePrepass.cs create mode 100644 OpenNest.Engine/Jobs/Placement/PlateFillService.CommonLines.cs create mode 100644 OpenNest.IO.Tests/CommonLineUsagePersistenceTests.cs create mode 100644 OpenNest.Tests/CommonLines/CommonLineAutoPlacementTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/CommonLineUsageFormTests.cs diff --git a/AGENTS.md b/AGENTS.md index 06280cfd..b4f0d1a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Read the relevant contract before changing its behavior: - [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material - [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them - [Dimensions](docs/dimensions.md): CAD Converter annotations that store placement only, are measured from the cut geometry when shown and never affect the program -- [Common-line cutting](docs/common-line-cutting.md): parts one kerf apart in `Plate.CommonLines` share an edge; spans are derived, never stored, and spacing checks hold group members to the kerf. Plan Cutting cuts each shared span once (owner first, open `CommonLine` centerline cut, then each member's remainder) unless the plate is set to cut them twice; only posts implementing `ICommonLinePostProcessor` (CI Fiber) may write it, others refuse. Machine trial pending +- [Common-line cutting](docs/common-line-cutting.md): parts one kerf apart in `Plate.CommonLines` share an edge; spans are derived, never stored, and spacing checks hold group members to the kerf. Plan Cutting cuts each shared span once (owner first, open `CommonLine` centerline cut, then each member's remainder) unless the plate is set to cut them twice; only posts implementing `ICommonLinePostProcessor` (CI Fiber) may write it, others refuse. Machine trial pending. Auto Nest and Fill Area pair `AllowCommonLine` drawings at the nest's `CommonLineUsage` (Off by default, byte-identical to no common lines); `NestLayoutCheck` exempts only explicit result groups - [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level - [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md) - [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md) diff --git a/OpenNest.Benchmark/BenchmarkRunner.cs b/OpenNest.Benchmark/BenchmarkRunner.cs index 2a19fa83..91b15b48 100644 --- a/OpenNest.Benchmark/BenchmarkRunner.cs +++ b/OpenNest.Benchmark/BenchmarkRunner.cs @@ -46,7 +46,8 @@ namespace OpenNest.Benchmark string outputDirectory = null, int maxParallelism = 1, System.IO.TextWriter progressLog = null, - TimeSpan? timeLimit = null + TimeSpan? timeLimit = null, + CommonLineSetting commonLines = null ) { var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine))) @@ -83,7 +84,8 @@ namespace OpenNest.Benchmark minimumSalvageDimension, outputDirectory, progressLog, - timeLimit + timeLimit, + commonLines ) ); @@ -240,7 +242,8 @@ namespace OpenNest.Benchmark double? minimumSalvageDimension, string outputDirectory, System.IO.TextWriter progressLog, - TimeSpan? timeLimit + TimeSpan? timeLimit, + CommonLineSetting commonLines = null ) { var requested = job.TotalRequestedQuantity; @@ -257,7 +260,19 @@ namespace OpenNest.Benchmark if (timeLimit is { } limit) engine = new DeadlineNestingEngine(engine, limit); using var cts = new CancellationTokenSource(HarnessTimeout(timeLimit)); - var jobResult = engine.Solve(nestJob, log, cts.Token); + NestJobResult jobResult; + if (commonLines != null) + { + // Through the desktop's pipeline, so its common-line trial runs; every part + // may pair. "off" is the like-for-like baseline for the levels. + nestJob = commonLines.Apply(nestJob); + var pipeline = NestPipeline.Run(engine, engineInfo.Name, nestJob, + job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing), log, cts.Token); + nestJob = pipeline.Job; + jobResult = pipeline.Raw; + } + else + jobResult = engine.Solve(nestJob, log, cts.Token); log?.Finished(jobResult, sw.ElapsedMilliseconds); var materialized = NestResultMaterializer.Materialize(nestJob, jobResult); diff --git a/OpenNest.Benchmark/CommonLineSetting.cs b/OpenNest.Benchmark/CommonLineSetting.cs new file mode 100644 index 00000000..07854e2f --- /dev/null +++ b/OpenNest.Benchmark/CommonLineSetting.cs @@ -0,0 +1,28 @@ +using System.Linq; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Benchmark +{ + /// + /// --common-lines: solve every job through at this usage + /// level and kerf, with every part allowed to share a common line. + /// + public sealed record CommonLineSetting(CommonLineUsage Usage, double Kerf) + { + public NestJob Apply(NestJob job) + { + var o = job.Options; + var options = new NestJobOptions(o.PlacementStrategy, o.MaxPlates, o.SalvageRate, + o.MinimumSalvageDimension, o.SearchUntilCancelled, o.OffcutCut) + { + CommonLineUsage = Usage, + CommonLineKerf = Kerf, + }; + var parts = job.Parts.Select(p => new NestJobPart(p.Id, p.Geometry, p.Quantity, p.Priority, p.Rotation) + { + AllowCommonLine = true, + }); + return new NestJob(parts, job.Plates, options); + } + } +} diff --git a/OpenNest.Benchmark/Program.cs b/OpenNest.Benchmark/Program.cs index 16ceee5e..7778f8bf 100644 --- a/OpenNest.Benchmark/Program.cs +++ b/OpenNest.Benchmark/Program.cs @@ -128,7 +128,8 @@ static class BenchmarkConsole options.OutputDirectory, options.Parallel, options.Progress ? Console.Out : null, - options.TimeLimit + options.TimeLimit, + options.CommonLines ); Report.PrintDetailed(results); @@ -217,6 +218,15 @@ static class BenchmarkConsole o.Progress = true; break; + case "--common-lines" when i + 1 < args.Length: + o.CommonLineLevel = args[++i]; + break; + + case "--kerf" when i + 1 < args.Length: + o.Kerf = double.TryParse(args[++i], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var kerf) ? kerf : double.NaN; + break; + case "--help": PrintUsage(); return null; @@ -228,6 +238,25 @@ static class BenchmarkConsole } } + if (o.CommonLineLevel != null) + { + CommonLineUsage? usage = o.CommonLineLevel.ToLowerInvariant() switch + { + "off" => CommonLineUsage.Off, + "when-it-adds-parts" => CommonLineUsage.WhenItAddsParts, + "whenever" => CommonLineUsage.Whenever, + _ => null, + }; + if (usage == null || o.Kerf is not { } kerf || !double.IsFinite(kerf) || kerf <= 0) + { + Console.Error.WriteLine( + "Error: --common-lines needs off, when-it-adds-parts or whenever, and a positive --kerf" + ); + o.Invalid = true; + } + else + o.CommonLines = new CommonLineSetting(usage.Value, kerf); + } return o; } @@ -368,6 +397,18 @@ static class BenchmarkConsole Console.Error.WriteLine( " the best valid plan reported; none = timed out" ); + Console.Error.WriteLine( + " --common-lines Solve through the Auto Nest pipeline with every part allowed" + ); + Console.Error.WriteLine( + " to share a common line: off, when-it-adds-parts or whenever" + ); + Console.Error.WriteLine( + " (needs --kerf; off is the baseline to compare levels with)" + ); + Console.Error.WriteLine( + " --kerf Common-line kerf in job units (with --common-lines)" + ); Console.Error.WriteLine(" --help Show this message"); } @@ -384,6 +425,9 @@ static class BenchmarkConsole public int Parallel = 3; public bool Progress; public TimeSpan? TimeLimit; + public string CommonLineLevel; + public double? Kerf; + public CommonLineSetting CommonLines; public bool Invalid; } } diff --git a/OpenNest.Core/CommonLineUsage.cs b/OpenNest.Core/CommonLineUsage.cs new file mode 100644 index 00000000..2693115a --- /dev/null +++ b/OpenNest.Core/CommonLineUsage.cs @@ -0,0 +1,47 @@ +namespace OpenNest +{ + /// + /// When automatic nesting and fills may place parts one kerf apart on a common line (see + /// docs/common-line-cutting.md). Only drawings with take part. + /// + public enum CommonLineUsage + { + /// Never: engines and fills behave exactly as without common lines. + Off, + + /// + /// Only when it places more parts (or, in a whole job, uses fewer sheets) than the best + /// layout without common lines; a tie keeps the layout without them. + /// + WhenItAddsParts, + + /// + /// Whenever it shortens the cut or uses less material, never at the cost of a part: + /// part count still ranks first. + /// + Whenever, + } +} + +namespace OpenNest +{ + /// The names is saved under (nest files and defaults.json). + public static class CommonLineUsageNames + { + /// The saved name, or null for (omitted). + public static string Format(CommonLineUsage usage) => usage switch + { + CommonLineUsage.WhenItAddsParts => "whenItAddsParts", + CommonLineUsage.Whenever => "whenever", + _ => null, + }; + + /// Reads a saved name case-insensitively; anything else is off. + public static CommonLineUsage Parse(string name) => + string.Equals(name, "whenItAddsParts", System.StringComparison.OrdinalIgnoreCase) + ? CommonLineUsage.WhenItAddsParts + : string.Equals(name, "whenever", System.StringComparison.OrdinalIgnoreCase) + ? CommonLineUsage.Whenever + : CommonLineUsage.Off; + } +} diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs index 8e24dbab..de3fee1f 100644 --- a/OpenNest.Core/Drawing.cs +++ b/OpenNest.Core/Drawing.cs @@ -120,6 +120,14 @@ namespace OpenNest /// public bool CutFirst { get; set; } + /// + /// Lets automatic nesting place this drawing's parts one kerf apart so they share a cut + /// (see docs/common-line-cutting.md), when the nest's common-line usage allows it. Off by + /// default: a shared edge is cut once from the centerline, which some customers' + /// edge-quality requirements do not accept. + /// + public bool AllowCommonLine { get; set; } + public NestConstraints Constraints { get; set; } public SourceInfo Source { get; set; } diff --git a/OpenNest.Core/Nest.cs b/OpenNest.Core/Nest.cs index f0a85bcc..45210ac8 100644 --- a/OpenNest.Core/Nest.cs +++ b/OpenNest.Core/Nest.cs @@ -112,6 +112,12 @@ namespace OpenNest public double SalvageRate { get; set; } = 0.5; + /// + /// When Auto Nest and the fills may place parts on common lines (only drawings with + /// ). Off unless the nest or the new-nest defaults say so. + /// + public CommonLineUsage CommonLineUsage { get; set; } + /// /// The smallest side, in model units, of an offcut credited as salvage or reported as the /// plate's expected remnant (). Auto Nest records the diff --git a/OpenNest.Core/Splitting/DrawingSplitter.cs b/OpenNest.Core/Splitting/DrawingSplitter.cs index 9041d4bd..d6eccd45 100644 --- a/OpenNest.Core/Splitting/DrawingSplitter.cs +++ b/OpenNest.Core/Splitting/DrawingSplitter.cs @@ -131,6 +131,7 @@ public static class DrawingSplitter piece.HasCustomColor = source.HasCustomColor; piece.Priority = source.Priority; piece.CutFirst = source.CutFirst; + piece.AllowCommonLine = source.AllowCommonLine; piece.Material = source.Material; piece.Constraints = source.Constraints; piece.Customer = source.Customer; diff --git a/OpenNest.Data/NestDefaults.cs b/OpenNest.Data/NestDefaults.cs index cf4b78e0..e51b4d26 100644 --- a/OpenNest.Data/NestDefaults.cs +++ b/OpenNest.Data/NestDefaults.cs @@ -52,6 +52,12 @@ public sealed class NestDefaults /// public Guid? MachineId { get; set; } + /// + /// The common-line usage new nests start with (). Off by + /// default; saved as commonLineUsage and omitted when off. + /// + public CommonLineUsage CommonLineUsage { get; set; } + /// /// The built-in defaults used when no file exists and for every field /// that is missing or invalid. Matches the historical @@ -176,6 +182,9 @@ public sealed class NestDefaults && Guid.TryParse(machineId.GetString(), out var machine) && machine != Guid.Empty) defaults.MachineId = machine; + if (dto.CommonLineUsage is { ValueKind: JsonValueKind.String } usage) + defaults.CommonLineUsage = CommonLineUsageNames.Parse(usage.GetString()); + return defaults; } @@ -201,6 +210,7 @@ public sealed class NestDefaults PartSpacing = plate.PartSpacing, EdgeSpacing = plate.EdgeSpacing, MachineId = nest.Machine?.Id, + CommonLineUsage = nest.CommonLineUsage, }; } @@ -221,6 +231,19 @@ public sealed class NestDefaults }; } + /// + /// , keeping the settings a plate does not hold (the + /// common-line usage) from , the defaults being replaced, so saving a + /// plate as the defaults never resets them. + /// + public static NestDefaults FromPlate(Units units, Plate plate, NestDefaults saved) + { + ArgumentNullException.ThrowIfNull(saved); + var defaults = FromPlate(units, plate); + defaults.CommonLineUsage = saved.CommonLineUsage; + return defaults; + } + /// /// These defaults in : plate size and spacings are converted /// (25.4 mm per inch) and rounded to four decimals; the quadrant is kept. Returns a copy @@ -239,6 +262,7 @@ public sealed class NestDefaults EdgeSpacing = new Spacing(Convert(EdgeSpacing.Left), Convert(EdgeSpacing.Bottom), Convert(EdgeSpacing.Right), Convert(EdgeSpacing.Top)), MachineId = MachineId, + CommonLineUsage = CommonLineUsage, }; } @@ -253,6 +277,7 @@ public sealed class NestDefaults plate.Quadrant = Quadrant; plate.PartSpacing = PartSpacing; plate.EdgeSpacing = EdgeSpacing; + nest.CommonLineUsage = CommonLineUsage; } /// @@ -282,6 +307,8 @@ public sealed class NestDefaults Top = EdgeSpacing.Top, }, MachineId = MachineId is { } machineId ? JsonSerializer.SerializeToElement(machineId.ToString()) : null, + CommonLineUsage = CommonLineUsageNames.Format(CommonLineUsage) is { } usage + ? JsonSerializer.SerializeToElement(usage) : null, }; return JsonSerializer.Serialize(dto, JsonOptions); @@ -316,6 +343,8 @@ public sealed class NestDefaults // Raw JSON for the same reason as Units. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public JsonElement? MachineId { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? CommonLineUsage { get; init; } } private sealed record SizeDto diff --git a/OpenNest.Desktop/AutoNestJob.cs b/OpenNest.Desktop/AutoNestJob.cs index b3a640e4..cb7d6fd2 100644 --- a/OpenNest.Desktop/AutoNestJob.cs +++ b/OpenNest.Desktop/AutoNestJob.cs @@ -41,7 +41,21 @@ namespace OpenNest.Desktop return new NestPipelineRequest(engineName, items, NestStockBuilder.FromTemplate(template, null), new NestJobOptions(maxPlates: NestRenest.MaxPlates, salvageRate: 0, - minimumSalvageDimension: nest.MinimumRemnantDimension)); + minimumSalvageDimension: nest.MinimumRemnantDimension) + { + CommonLineUsage = nest.CommonLineUsage, + CommonLineKerf = CommonLineKerf(template), + }); + } + + /// + /// The template plate's saved cutting kerf, or zero (no common lines) when it has none. + /// This toolkit-neutral path has no machine defaults to fall back on. + /// + private static double CommonLineKerf(Plate template) + { + var kerf = template.CuttingParameters?.Kerf ?? 0; + return double.IsFinite(kerf) && kerf > 0 ? kerf : 0; } /// diff --git a/OpenNest.Engine.Tests/Jobs/CommonLineAutoPlacementTests.cs b/OpenNest.Engine.Tests/Jobs/CommonLineAutoPlacementTests.cs new file mode 100644 index 00000000..2bb64743 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/CommonLineAutoPlacementTests.cs @@ -0,0 +1,357 @@ +using System.Threading; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.CommonLines; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Engine.Jobs.Remote; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// Automatic common-line placement (docs/common-line-cutting.md): the pipeline's pair trial at +/// each usage level, the explicit-group validation, materialization and server replay. +/// +public class CommonLineAutoPlacementTests +{ + private const double Kerf = 0.012; + + /// A 6 x 12.44 plate with 0.38 chamfers on all four corners, like N26-295's PT42. + private static Drawing Chamfered(string name = "pt", bool allow = true) + { + var program = new Program(Mode.Absolute); + var points = new (double X, double Y)[] + { + (0.38, 0), (5.62, 0), (6, 0.38), (6, 12.06), (5.62, 12.44), (0.38, 12.44), (0, 12.06), (0, 0.38), + }; + program.Codes.Add(new RapidMove(points[0].X, points[0].Y)); + foreach (var point in points.Skip(1).Append(points[0])) + program.Codes.Add(new LinearMove(point.X, point.Y)); + return new Drawing(name, program) { AllowCommonLine = allow }; + } + + /// + /// One row: the 55.1 x 12.44 work area holds 8 parts at 0.25 spacing (9 would need 56), but + /// 4 kerf-gap pairs and a single need only 55.05. + /// + private static NestPipelineRequest Row(Drawing drawing, CommonLineUsage usage, double kerf = Kerf, + int quantity = 9, double length = 55.6, string engine = "Rectangles") => + new(engine, [new NestItem { Drawing = drawing, Quantity = quantity }], + [new NestPlateStock("sheet", new Size(12.94, length), 1, 0.25, new Spacing(0.25, 0.25, 0.25, 0.25), 1)], + new NestJobOptions { CommonLineUsage = usage, CommonLineKerf = kerf }); + + private static int Placed(NestPipelineResult result) => result.Raw.Plates.Sum(p => p.Placements.Count); + + private static IEnumerable<(string, double, double, double)> Poses(NestPipelineResult result) => + result.Raw.Plates.SelectMany(p => p.Placements).Select(p => (p.PartId, p.X, p.Y, p.Rotation)); + + private static int Solves(NestPipelineRequest request) + { + var recorder = new RecordingNestingEngine(NestingEngineRegistry.Create(request.EngineName)); + NestPipeline.Run(recorder, request.EngineName, request); + return recorder.Transcript.Solves.Count; + } + + [Theory] + [InlineData("Rectangles")] + [InlineData("Default")] + public void Off_IsTheLayoutWithoutCommonLineOptions_AndAsksNoMoreSolves(string engine) + { + var drawing = Chamfered(); + var plain = Row(drawing, CommonLineUsage.Off, kerf: 0, engine: engine) with { Options = new NestJobOptions() }; + var off = Row(drawing, CommonLineUsage.Off, engine: engine); + + Assert.Equal(Poses(NestPipeline.Run(plain)), Poses(NestPipeline.Run(off))); + Assert.Equal(Solves(plain), Solves(off)); + } + + [Fact] + public void LevelOn_WithoutAnEligibleDrawingOrKerf_IsTheOrdinarySolve() + { + var plain = NestPipeline.Run(Row(Chamfered(), CommonLineUsage.Off)); + foreach (var request in new[] + { + Row(Chamfered(allow: false), CommonLineUsage.Whenever), + Row(Chamfered(), CommonLineUsage.Whenever, kerf: 0), + }) + { + Assert.Equal(1, Solves(request)); + Assert.Equal(Poses(plain), Poses(NestPipeline.Run(request))); + } + } + + [Theory] + [InlineData(CommonLineUsage.WhenItAddsParts, "Rectangles")] + [InlineData(CommonLineUsage.Whenever, "Rectangles")] + [InlineData(CommonLineUsage.WhenItAddsParts, "Default")] + public void PairsFitAPartTheSpacingCannot(CommonLineUsage usage, string engine) + { + var drawing = Chamfered(); + var off = NestPipeline.Run(Row(drawing, CommonLineUsage.Off, engine: engine)); + var on = NestPipeline.Run(Row(drawing, usage, engine: engine)); + + Assert.Equal(8, Placed(off)); + Assert.Equal(9, Placed(on)); + Assert.True(on.IsValid, string.Join("; ", on.Violations)); + var plate = Assert.Single(on.Plates); + Assert.NotEmpty(plate.CommonLines); + Assert.All(plate.CommonLines, group => + { + Assert.Equal(Kerf, group.Kerf); + Assert.True(CommonLineGeometry.Check(group).IsIntact); + Assert.All(group.Members, member => Assert.Contains(member, plate.Parts)); + }); + } + + [Fact] + public void WhenItAddsParts_KeepsTheLayoutWithoutCommonLinesOnATie() + { + // A long sheet holds all nine either way. + var drawing = Chamfered(); + var off = NestPipeline.Run(Row(drawing, CommonLineUsage.Off, length: 120)); + var on = NestPipeline.Run(Row(drawing, CommonLineUsage.WhenItAddsParts, length: 120)); + + Assert.Equal(9, Placed(off)); + Assert.Equal(Poses(off), Poses(on)); + Assert.All(on.Plates, plate => Assert.Empty(plate.CommonLines)); + } + + [Theory] + [InlineData(55.6)] + [InlineData(120)] + [InlineData(40)] + public void Whenever_NeverLosesAPart(double length) + { + var drawing = Chamfered(); + var off = NestPipeline.Run(Row(drawing, CommonLineUsage.Off, length: length)); + var on = NestPipeline.Run(Row(drawing, CommonLineUsage.Whenever, length: length)); + + Assert.True(on.IsValid, string.Join("; ", on.Violations)); + Assert.True(Placed(on) >= Placed(off)); + } + + [Fact] + public void Ranking_CountFirst_TiesByLevel() + { + var drawing = Chamfered(); + var eight = NestPipeline.Run(Row(drawing, CommonLineUsage.Off)); + var nine = NestPipeline.Run(Row(drawing, CommonLineUsage.Whenever)); + + foreach (var usage in new[] { CommonLineUsage.WhenItAddsParts, CommonLineUsage.Whenever }) + { + Assert.True(CommonLineRanking.Prefers(usage, nine, eight)); + Assert.False(CommonLineRanking.Prefers(usage, eight, nine)); + } + Assert.False(CommonLineRanking.Prefers(CommonLineUsage.Off, nine, eight)); + // Equal layouts: never a reason to switch. + Assert.False(CommonLineRanking.Prefers(CommonLineUsage.WhenItAddsParts, nine, nine)); + Assert.False(CommonLineRanking.Prefers(CommonLineUsage.Whenever, nine, nine)); + // Shared spans are cut once. + Assert.True(CommonLineRanking.CutLength(nine) < 9 * CutLengthOfOne(drawing)); + } + + private static double CutLengthOfOne(Drawing drawing) => + Converters.ConvertProgram.ToGeometry(drawing.Program).Sum(entity => entity.Length); + + [Fact] + public void Pair_SharesTheLongestEdgeOneKerfApart() + { + var drawing = Chamfered(); + var pair = CommonLinePair.Build(PartGeometrySnapshot.FromProgram(drawing.Program), Kerf); + + Assert.NotNull(pair); + Assert.Equal(12.44 - 2 * 0.38, pair!.SharedLength, 6); + var first = new Part(drawing); + var second = new Part(drawing); + second.Rotate(CommonLinePair.SecondRotation); + second.Location = new Vector(pair.SecondX, pair.SecondY); + var span = Assert.Single(CommonLineGeometry.Spans(first, second, Kerf)); + Assert.Equal(pair.SharedLength, span.Length, 6); + // The compound covers both copies: its box is theirs. + var proxy = new Part(new Drawing("pair", OpenNest.Engine.Jobs.Adapters.DrawingJobMapper.ToProgram(pair.Geometry))); + Assert.Equal(12 + Kerf, proxy.BoundingBox.Length, 4); + Assert.Equal(12.44, proxy.BoundingBox.Width, 4); + } + + [Fact] + public void Pair_NoneWithoutALongStraightEdge() + { + var circle = new Program(Mode.Absolute); + circle.Codes.Add(new RapidMove(5, 0)); + circle.Codes.Add(new ArcMove(5, 0, 0, 0, RotationType.CCW)); + Assert.Null(CommonLinePair.Build(PartGeometrySnapshot.FromProgram(circle), Kerf)); + } + + [Theory] + [InlineData("Default")] + [InlineData("Rectangles")] + [InlineData("Lattice")] + public void Wedges_PairIntoUsableGeometry_AndStayValid(string engine) + { + // A long thin wedge: the pair's union once kept a near-repeated vertex that engines refuse. + var program = new Program(Mode.Absolute); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(28, 0)); + program.Codes.Add(new LinearMove(0, 10)); + program.Codes.Add(new LinearMove(0, 0)); + var wedge = new Drawing("wedge", program) { AllowCommonLine = true }; + var pair = CommonLinePair.Build(PartGeometrySnapshot.FromProgram(program), Kerf); + Assert.NotNull(pair); + Assert.NotNull(JobPartGeometry.Read(pair!.Geometry)); + + foreach (var usage in new[] { CommonLineUsage.WhenItAddsParts, CommonLineUsage.Whenever }) + { + var result = NestPipeline.Run(new NestPipelineRequest(engine, + [new NestItem { Drawing = wedge, Quantity = 2 }], + [new NestPlateStock("sheet", new Size(15, 30), null, 0.25, new Spacing(0.25, 0.25, 0.25, 0.25), 1)], + new NestJobOptions { CommonLineUsage = usage, CommonLineKerf = Kerf })); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(2, Placed(result)); + } + } + + // Two copies placed as a pair, without asking any engine. + private static (NestJob Job, NestJobResult Result) PairResult(bool group, bool allow = true, + CommonLineUsage usage = CommonLineUsage.Whenever, double gap = Kerf) + { + var drawing = Chamfered(allow: allow); + var part = OpenNest.Engine.Jobs.Adapters.DrawingJobMapper.FromDrawing("pt", drawing, 2); + var stock = new NestPlateStock("sheet", new Size(48, 96), 1, 0.25, new Spacing(0.25, 0.25, 0.25, 0.25), 1); + var job = new NestJob([part], [stock], new NestJobOptions { CommonLineUsage = usage, CommonLineKerf = Kerf }); + var pair = CommonLinePair.Build(part.Geometry, gap)!; + var placements = new[] + { + new NestJobPlacement("pt", 0, 1, 1, 0), + new NestJobPlacement("pt", 1, 1 + pair.SecondX, 1 + pair.SecondY, System.Math.PI), + }; + var sheet = new NestJobPlateResult(0, stock, placements, + group ? [new NestJobCommonLine(Kerf, [0, 1])] : null); + return (job, new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, [sheet], + [new PartFulfillment("pt", 2, 2, 0)], [new StockUsage("sheet", 1, 0)])); + } + + [Fact] + public void LayoutCheck_ExemptsOnlyAnExplicitGroup() + { + var (job, ungrouped) = PairResult(group: false); + Assert.Contains(NestLayoutCheck.Violations(job, ungrouped), v => v.Contains("closer than the required spacing")); + + var (_, grouped) = PairResult(group: true); + Assert.Empty(NestLayoutCheck.Violations(job, grouped)); + } + + [Fact] + public void LayoutCheck_RefusesGroupsTheJobDoesNotAllow() + { + var (offJob, off) = PairResult(group: true, usage: CommonLineUsage.Off); + Assert.Contains(NestLayoutCheck.Violations(offJob, off), v => v.Contains("does not allow")); + + var (notAllowedJob, notAllowed) = PairResult(group: true, allow: false); + Assert.Contains(NestLayoutCheck.Violations(notAllowedJob, notAllowed), v => v.Contains("does not allow common lines")); + } + + [Fact] + public void LayoutCheck_RefusesAGroupThatSharesNoEdge() + { + // Built at twice the kerf: the members do not share at the group's kerf. + var (job, wide) = PairResult(group: true, gap: 2 * Kerf); + Assert.Contains(NestLayoutCheck.Violations(job, wide), v => v.Contains("no longer shares")); + } + + [Fact] + public void Materializing_CreatesThePlatesCommonLines() + { + var (job, result) = PairResult(group: true); + var plate = Assert.Single(OpenNest.Engine.Jobs.Adapters.NestResultMaterializer.Materialize(job, result).Nest.Plates); + var group = Assert.Single(plate.CommonLines); + Assert.Equal(plate.Parts, group.Members); + } + + [Fact] + public void Commit_AddsTheGroupsToThePlate() + { + var result = NestPipeline.Run(Row(Chamfered(), CommonLineUsage.WhenItAddsParts)); + var nest = new Nest(); + var plate = Assert.Single(NestPipelineCommit.ApplyToPlates(result, () => + { + var created = new Plate(new Size(12.94, 55.6)); + nest.Plates.Add(created); + return created; + })); + Assert.Equal(result.Plates[0].CommonLines.Count, plate.CommonLines.Count); + Assert.All(plate.CommonLines, group => Assert.All(group.Members, m => Assert.Contains(m, plate.Parts))); + } + + [Fact] + public void ServerReplay_BindsTheSameGroups() + { + var drawing = Chamfered(); + var request = Row(drawing, CommonLineUsage.WhenItAddsParts); + var recorder = new RecordingNestingEngine(NestingEngineRegistry.Create(request.EngineName)); + NestPipeline.Run(recorder, request.EngineName, request); + var transcript = NestSolveTranscript.FromJson(recorder.Transcript.ToJson()); + + var local = NestPipeline.Run(request); + var replayed = new RemoteNestSubmission(request).Complete(transcript); + + Assert.True(replayed.IsValid, string.Join("; ", replayed.Violations)); + Assert.Equal(Poses(local), Poses(replayed)); + Assert.Equal( + local.Plates.Select(p => p.CommonLines.Select(g => g.Members.Select(m => p.Parts.ToList().IndexOf(m)).ToArray()).ToArray()), + replayed.Plates.Select(p => p.CommonLines.Select(g => g.Members.Select(m => p.Parts.ToList().IndexOf(m)).ToArray()).ToArray())); + Assert.All(replayed.Plates.SelectMany(p => p.Parts), part => Assert.Same(drawing, part.BaseDrawing)); + } + + [Fact] + public void JobDocument_CarriesTheLevelAndKerf() + { + var drawing = Chamfered(); + var on = NestJobDocument.From(Row(drawing, CommonLineUsage.Whenever), [drawing]); + Assert.Equal(NestJobDocument.CommonLineVersion, on.Version); + var options = NestJobDocument.FromJson(on.ToJson()).ToRequest([drawing]).Options!; + Assert.Equal(CommonLineUsage.Whenever, options.CommonLineUsage); + Assert.Equal(Kerf, options.CommonLineKerf); + + var off = NestJobDocument.From(Row(drawing, CommonLineUsage.Off), [drawing]); + Assert.Equal(NestJobDocument.CurrentVersion, off.Version); + } + + [Fact] + public void Fill_OffIsThePlainFill() + { + var plate = new Plate(new Size(12.94, 55.6)) { PartSpacing = 0.25, EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25) }; + var drawing = Chamfered(); + var plain = PlateFillService.FillItem("Lattice", plate, new NestItem { Drawing = drawing }, plate.WorkArea(), + null, CancellationToken.None); + var off = PlateFillService.FillItem("Lattice", plate, new NestItem { Drawing = drawing }, plate.WorkArea(), + CommonLineUsage.Off, Kerf, null, CancellationToken.None); + + Assert.Empty(off.CommonLines); + Assert.Equal(plain.Select(p => (p.Location, p.Rotation)), off.Parts.Select(p => (p.Location, p.Rotation))); + } + + [Theory] + [InlineData(CommonLineUsage.WhenItAddsParts)] + [InlineData(CommonLineUsage.Whenever)] + public void Fill_PairsAreIntactAndNeverCostAPart(CommonLineUsage usage) + { + var plate = new Plate(new Size(12.94, 55.6)) { PartSpacing = 0.25, EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25) }; + var drawing = Chamfered(); + var plain = PlateFillService.FillItem("Lattice", plate, new NestItem { Drawing = drawing }, plate.WorkArea(), + null, CancellationToken.None); + var fill = PlateFillService.FillItem("Lattice", plate, new NestItem { Drawing = drawing }, plate.WorkArea(), + usage, Kerf, null, CancellationToken.None); + + Assert.True(fill.Parts.Count >= plain.Count); + if (fill.CommonLines.Count == 0) + return; + Assert.All(fill.CommonLines, group => Assert.True(CommonLineGeometry.Check(group).IsIntact)); + foreach (var part in fill.Parts) + plate.Parts.Add(part); + foreach (var group in fill.CommonLines) + plate.CommonLines.Add(group); + var violations = NestLayoutCheck.Validate([(plate, plate.Parts.ToList())], + new Dictionary { [drawing] = ("pt", int.MaxValue) }); + Assert.Empty(violations); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/NestJobDocumentPriorityTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobDocumentPriorityTests.cs index 9a421607..c8e39d2a 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobDocumentPriorityTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobDocumentPriorityTests.cs @@ -30,7 +30,7 @@ public class NestJobDocumentPriorityTests [Fact] public void UnknownVersionIsRefused() { - Assert.Throws(() => Document(NestJobDocument.CurrentVersion + 1, 0).ToRequest(Drawings())); + Assert.Throws(() => Document(NestJobDocument.CommonLineVersion + 1, 0).ToRequest(Drawings())); } private static Drawing[] Drawings() => [new Drawing("a", TestDrawingFactory.Rectangle(10, 20))]; diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs index 3522987d..be13f585 100644 --- a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs +++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs @@ -26,7 +26,8 @@ public static class DrawingJobMapper constraints.EndAngle, constraints.Allow180Equivalent ) - ); + ) + { AllowCommonLine = drawing.AllowCommonLine }; } public static NestJobPart FromItem(string partId, NestItem item) @@ -44,7 +45,8 @@ public static class DrawingJobMapper item.RotationEnd, item.Drawing.Constraints?.Allow180Equivalent ?? false ) - ); + ) + { AllowCommonLine = item.Drawing.AllowCommonLine }; } /// Available stock is explicit; the legacy plate repeat count is not inventory. @@ -98,7 +100,11 @@ public static class DrawingJobMapper /// geometry in a later job, engine instance or Auto Nest run does not search again. internal static Drawing CreateDrawing(NestJobPart part) { - var drawing = new Drawing(part.Id, ToProgram(part.Geometry)) { Priority = part.Priority }; + var drawing = new Drawing(part.Id, ToProgram(part.Geometry)) + { + Priority = part.Priority, + AllowCommonLine = part.AllowCommonLine, + }; drawing.Quantity.Required = part.Quantity; drawing.Constraints = new NestConstraints { diff --git a/OpenNest.Engine/Jobs/Adapters/NestResultBinder.cs b/OpenNest.Engine/Jobs/Adapters/NestResultBinder.cs index 559383cd..9d5adaef 100644 --- a/OpenNest.Engine/Jobs/Adapters/NestResultBinder.cs +++ b/OpenNest.Engine/Jobs/Adapters/NestResultBinder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using OpenNest.Geometry; namespace OpenNest.Engine.Jobs.Adapters; @@ -37,4 +38,20 @@ public static class NestResultBinder } return parts; } + + /// The sheet's common-line groups over , which + /// returned for that sheet (same order). + public static IReadOnlyList BindCommonLines(NestJobPlateResult sheet, IReadOnlyList parts) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(parts); + var groups = new List(sheet.CommonLines.Count); + foreach (var group in sheet.CommonLines) + { + if (group.Placements.Count < 2 || group.Placements.Any(i => i < 0 || i >= parts.Count)) + throw new ArgumentException("A common-line group names a placement the sheet does not have.", nameof(sheet)); + groups.Add(new CommonLineGroup(group.Placements.Select(i => parts[i]), group.Kerf)); + } + return groups; + } } diff --git a/OpenNest.Engine/Jobs/Adapters/NestResultMaterializer.cs b/OpenNest.Engine/Jobs/Adapters/NestResultMaterializer.cs index 4a38a8ef..47fbf6ef 100644 --- a/OpenNest.Engine/Jobs/Adapters/NestResultMaterializer.cs +++ b/OpenNest.Engine/Jobs/Adapters/NestResultMaterializer.cs @@ -52,8 +52,29 @@ public static class NestResultMaterializer // Quantity=1 is set before the only attachment; Plate's event owns Nested accounting. plate.Parts.Add(part); } + AddCommonLines(plate, plate.Parts.ToList(), sheet.CommonLines); nest.Plates.Add(plate); } return new MaterializedNestResult(nest, drawings); } + + /// + /// Adds a sheet's explicit common-line groups to ; members are the + /// sheet's placements by position in . A group naming a position + /// outside the sheet, a part twice, a part of an earlier group, or fewer than two parts throws: + /// such a result is malformed. + /// + internal static void AddCommonLines(Plate plate, IReadOnlyList parts, + IReadOnlyList groups) + { + if (groups.Count == 0) + return; + var taken = new HashSet(); + foreach (var group in groups) + { + if (group.Placements.Count < 2 || group.Placements.Any(i => i < 0 || i >= parts.Count || !taken.Add(i))) + throw new ArgumentException("A common-line group names a placement that is missing, repeated or in another group."); + plate.CommonLines.Add(new CommonLineGroup(group.Placements.Select(i => parts[i]), group.Kerf)); + } + } } diff --git a/OpenNest.Engine/Jobs/CommonLines/CommonLinePair.cs b/OpenNest.Engine/Jobs/CommonLines/CommonLinePair.cs new file mode 100644 index 00000000..0fc7c198 --- /dev/null +++ b/OpenNest.Engine/Jobs/CommonLines/CommonLinePair.cs @@ -0,0 +1,186 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Clipper2Lib; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs.CommonLines; + +/// +/// Two copies of one part sharing their longest straight edge one kerf apart: the second copy +/// is the first turned half a turn, so that edge faces its own copy. Engines place the pair as one +/// compound part (), and the pipeline expands it back into the two copies +/// and their common-line group. +/// +internal sealed class CommonLinePair +{ + /// Chord tolerance of the compound outline; arcs are circumscribed, so it only + /// controls vertex count, never makes the compound smaller than its copies. + private const double FlattenTolerance = 0.002; + + /// How far the bridge over the kerf reaches into each copy's material, so the union + /// joins the copies into one outline. + private const double BridgeOverlap = 1e-4; + + private CommonLinePair(PartGeometrySnapshot geometry, double secondX, double secondY, double sharedLength) + { + Geometry = geometry; + SecondX = secondX; + SecondY = secondY; + SharedLength = sharedLength; + } + + /// The compound: both copies and the kerf between them as one solid outline, in the + /// first copy's snapshot frame. Holes are not offered (they read as solid). + public PartGeometrySnapshot Geometry { get; } + + /// Where the second copy sits in the compound's frame; it is turned half a turn. + public double SecondX { get; } + + public double SecondY { get; } + + /// Length of the shared span (the cut saved once per pair when cut once). + public double SharedLength { get; } + + /// The second copy's rotation relative to the compound. + public const double SecondRotation = System.Math.PI; + + /// + /// The pair for at , or null when the part + /// has no straight edge of at least , or when + /// the half-turned copy would come closer than the kerf anywhere else. + /// + public static CommonLinePair? Build(PartGeometrySnapshot geometry, double kerf) + { + ArgumentNullException.ThrowIfNull(geometry); + if (!double.IsFinite(kerf) || kerf <= 0) + return null; + Drawing drawing; + Part first; + IReadOnlyList edges; + try + { + drawing = new Drawing("pair", DrawingJobMapper.ToProgram(geometry)); + first = new Part(drawing); + edges = CommonLineGeometry.Edges(first, new CommonLineGeometry.ProfileCache()); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException + or NotSupportedException or ArithmeticException) + { + return null; + } + // Longest edges first; equal lengths keep perimeter order, so the choice is deterministic. + foreach (var edge in edges.OrderByDescending(e => System.Math.Round(e.Length, 9)).Take(4)) + { + var pair = TryEdge(drawing, first, edge, kerf); + if (pair != null) + return pair; + } + return null; + } + + private static CommonLinePair? TryEdge(Drawing drawing, Part first, CommonLineGeometry.Edge edge, double kerf) + { + // Turning a copy half a turn maps this edge to itself reversed, outward normal opposed; + // moving it to the edge's mirror across the kerf centerline makes the two face each other + // over their whole length. + var middle = new Vector((edge.Start.X + edge.End.X) / 2, (edge.Start.Y + edge.End.Y) / 2); + var offset = new Vector(2 * middle.X + edge.Outward.X * kerf, 2 * middle.Y + edge.Outward.Y * kerf); + var second = new Part(drawing); + second.Rotate(SecondRotation); + second.Location = offset; + second.UpdateBounds(); + + var spans = CommonLineGeometry.Spans(first, second, kerf); + if (spans.Count == 0) + return null; + var firstProfile = Profile(first); + var secondProfile = Profile(second); + if (firstProfile == null || secondProfile == null) + return null; + // Away from the shared edge the copies must still clear one kerf (a concave part's + // half-turned copy can reach back into it). + if (NativeContours.Of(firstProfile, first.Location).TooClose(NativeContours.Of(secondProfile, second.Location), kerf)) + return null; + + var outline = Outline(firstProfile, first.Location, secondProfile, second.Location, edge, kerf); + if (outline == null) + return null; + var geometry = Snapshot(outline); + // Engines accept only usable closed edges; a compound they would refuse is no pair. + try + { + _ = JobPartGeometry.Read(geometry); + } + catch (ArgumentException) + { + return null; + } + return new CommonLinePair(geometry, offset.X, offset.Y, spans.Sum(s => s.Length)); + } + + private static ShapeProfile? Profile(Part part) + { + var entities = ConvertProgram.ToGeometry(part.Program).ForNesting().ToList(); + if (entities.Count == 0) + return null; + var profile = new ShapeProfile(entities); + return profile.Perimeter == null ? null : profile; + } + + /// The union of both perimeters (arcs circumscribed) and the kerf between them, or + /// null when it is not one outline. + private static PathD? Outline(ShapeProfile first, Vector firstAt, ShapeProfile second, Vector secondAt, + CommonLineGeometry.Edge edge, double kerf) + { + var a = ClipperBridge.ToPath(ClipperBridge.Flatten(first.Perimeter, FlattenTolerance, circumscribe: true), firstAt); + var b = ClipperBridge.ToPath(ClipperBridge.Flatten(second.Perimeter, FlattenTolerance, circumscribe: true), secondAt); + var n = edge.Outward; + double inner = -BridgeOverlap, outer = kerf + BridgeOverlap; + var bridge = new PathD + { + new(edge.Start.X + n.X * inner, edge.Start.Y + n.Y * inner), + new(edge.End.X + n.X * inner, edge.End.Y + n.Y * inner), + new(edge.End.X + n.X * outer, edge.End.Y + n.Y * outer), + new(edge.Start.X + n.X * outer, edge.Start.Y + n.Y * outer), + }; + // Clipper rounds PathsD to its precision; 8 decimals keeps the outline within 5e-9 of the copies. + // Every piece counter-clockwise, whatever the program's winding, so overlaps add up. + var pieces = new PathsD { a, b, bridge }; + foreach (var piece in pieces) + if (!Clipper.IsPositive(piece)) + piece.Reverse(); + var union = Clipper.Union(pieces, new PathsD(), FillRule.NonZero, 8); + var outers = union.Where(path => Clipper.Area(path) > 0).ToList(); + return outers.Count == 1 ? outers[0] : null; + } + + private static PartGeometrySnapshot Snapshot(PathD outline) + { + // Clipper can return (nearly) repeated vertices; a zero-length edge is not usable geometry. + var points = new PathD(outline.Count); + foreach (var point in outline) + if (points.Count == 0 || !Same(points[^1], point)) + points.Add(point); + while (points.Count > 1 && Same(points[^1], points[0])) + points.RemoveAt(points.Count - 1); + outline = points; + var program = new Program(Mode.Absolute); + program.Codes.Add(new RapidMove(outline[0].x, outline[0].y)); + for (var i = 1; i <= outline.Count; i++) + { + var point = outline[i % outline.Count]; + program.Codes.Add(new LinearMove(point.x, point.y) { Layer = LayerType.Cut }); + } + return PartGeometrySnapshot.FromProgram(program); + } + + // Engines refuse edges shorter than 1e-7; dropping a vertex this close moves the outline by + // far less than the spacing slack. + private static bool Same(PointD a, PointD b) => + System.Math.Abs(a.x - b.x) <= 1e-6 && System.Math.Abs(a.y - b.y) <= 1e-6; +} diff --git a/OpenNest.Engine/Jobs/CommonLines/CommonLinePipelinePrepass.cs b/OpenNest.Engine/Jobs/CommonLines/CommonLinePipelinePrepass.cs new file mode 100644 index 00000000..b3c7e0be --- /dev/null +++ b/OpenNest.Engine/Jobs/CommonLines/CommonLinePipelinePrepass.cs @@ -0,0 +1,165 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest.Engine.Jobs.CommonLines; + +/// +/// The common-line trial runs beside the ordinary solve when the +/// job's is on: every part that allows common lines +/// is offered as kerf-gap pairs () plus its odd copy, so any engine +/// places them without knowing about common lines. Placed pairs expand into their two copies and +/// one explicit group each; the expanded layout is validated again. +/// +internal sealed class CommonLinePipelinePrepass : IPipelinePrepass +{ + /// Every pair proxy's part id starts with this; ordinary parts never do. + internal const string ProxyPrefix = "__common-line-pair-"; + + /// How many of a part's copies a trial offers as pairs. + internal enum Share + { + /// As many pairs as the quantity allows. + All, + + /// About half the copies paired, the rest single: rows that fit an odd count. + Half, + } + + private sealed record Proxy(string PartId, CommonLinePair Pair); + + private readonly IReadOnlyDictionary proxies; + private readonly double kerf; + + private CommonLinePipelinePrepass(NestJob engineJob, IReadOnlyDictionary proxies, double kerf) + { + EngineJob = engineJob; + this.proxies = proxies; + this.kerf = kerf; + } + + public NestJob EngineJob { get; } + + public string ReconciliationFailure => "Common-line engine output failed reconciliation"; + + /// True for a job holding pair proxies: a common-line trial's engine job. + internal static bool HasProxies(NestJob job) => + job.Parts.Any(p => p.Id.StartsWith(ProxyPrefix, StringComparison.Ordinal)); + + /// + /// The trial job, or null when the job does not use common lines or no part can pair + /// (none allows it, has two copies, may turn half a turn and has a long enough straight edge). + /// + internal static CommonLinePipelinePrepass? Prepare(NestJob original, Share share, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(original); + token.ThrowIfCancellationRequested(); + if (!original.Options.UsesCommonLines || original.Plates.Count == 0) + return null; + var kerf = original.Options.CommonLineKerf; + // A pair a kerf apart only saves anything where the part spacing is wider than the kerf. + if (original.Plates.All(stock => !(kerf < stock.PartSpacing))) + return null; + var ids = original.Parts.Select(p => p.Id).ToHashSet(StringComparer.Ordinal); + var proxies = new Dictionary(StringComparer.Ordinal); + var parts = new List(); + foreach (var part in original.Parts) + { + token.ThrowIfCancellationRequested(); + var pairs = Pairs(part, share); + var pair = pairs > 0 && CanTurnHalf(part.Rotation) ? CommonLinePair.Build(part.Geometry, kerf) : null; + if (pair == null) + { + parts.Add(part); + continue; + } + var proxyId = ProxyPrefix + part.Id; + if (ids.Contains(proxyId)) + throw new InvalidOperationException("Common-line proxy ID collides with a requirement."); + proxies.Add(proxyId, new Proxy(part.Id, pair)); + // The pair first: it is the larger piece, and engines keep input order among equals. + parts.Add(new NestJobPart(proxyId, pair.Geometry, pairs, part.Priority, part.Rotation)); + var singles = part.Quantity - 2 * pairs; + if (singles > 0) + parts.Add(new NestJobPart(part.Id, part.Geometry, singles, part.Priority, part.Rotation) + { + AllowCommonLine = part.AllowCommonLine, + }); + } + if (proxies.Count == 0) + return null; + var engineJob = new NestJob(parts, original.Plates, original.Options); + try + { + NestJobValidator.Validate(engineJob); + } + catch (ArgumentException) + { + return null; // A trial the engine could not be asked is no trial; the ordinary solve stands. + } + return new CommonLinePipelinePrepass(engineJob, proxies, kerf); + } + + private static int Pairs(NestJobPart part, Share share) + { + if (!part.AllowCommonLine || part.Quantity < 2) + return 0; + return share == Share.All ? part.Quantity / 2 : part.Quantity / 4; + } + + // The second copy turns half a turn from the first, so the part must allow both angles. + private static bool CanTurnHalf(RotationPolicy rotation) => + rotation.Kind == RotationPolicyKind.Automatic || rotation.Allow180Equivalent; + + public IReadOnlyDictionary ReleasedInserts(NestJobResult engineResult) => + new Dictionary(StringComparer.Ordinal); + + public bool TryExpand(NestJob original, NestJobResult result, CancellationToken token, + out NestJobResult? expanded, out string failure) + { + expanded = null; + failure = "Invalid common-line engine accounting"; + if (!PipelinePrepassAccounting.Matches(EngineJob, result, token)) + return false; + var builder = new NestJobResultBuilder(original); + foreach (var sheet in result.Plates) + { + token.ThrowIfCancellationRequested(); + if (sheet.CommonLines.Count > 0) + return false; // Engines place pairs as parts; they never return groups of their own here. + var poses = new List<(string PartId, double X, double Y, double Rotation)>(); + var groups = new List(); + foreach (var pose in sheet.Placements) + { + if (!proxies.TryGetValue(pose.PartId, out var proxy)) + { + poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation)); + continue; + } + var sine = System.Math.Sin(pose.Rotation); + var cosine = System.Math.Cos(pose.Rotation); + var pair = proxy.Pair; + groups.Add(new NestJobCommonLine(kerf, new[] { poses.Count, poses.Count + 1 })); + poses.Add((proxy.PartId, pose.X, pose.Y, pose.Rotation)); + poses.Add((proxy.PartId, + pose.X + pair.SecondX * cosine - pair.SecondY * sine, + pose.Y + pair.SecondX * sine + pair.SecondY * cosine, + pose.Rotation + CommonLinePair.SecondRotation)); + } + var stock = original.Plates.Single(s => s.Id == sheet.StockId); + try + { + builder.AddSheet(stock, poses, groups); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return false; + } + } + token.ThrowIfCancellationRequested(); + expanded = builder.Build(result.StopReason); + return true; + } +} diff --git a/OpenNest.Engine/Jobs/CommonLines/CommonLineRanking.cs b/OpenNest.Engine/Jobs/CommonLines/CommonLineRanking.cs new file mode 100644 index 00000000..cec0750d --- /dev/null +++ b/OpenNest.Engine/Jobs/CommonLines/CommonLineRanking.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Converters; + +namespace OpenNest.Engine.Jobs.CommonLines; + +/// +/// Decides whether a common-line layout replaces the best layout without common lines (or an +/// earlier common-line trial), by the job's . Both must be valid, +/// keepable proposals of the same job; an invalid incumbent always loses to a valid candidate. +/// The engines' own ranking (and FillScore) is untouched: this only chooses between the +/// layouts they returned. +/// +internal static class CommonLineRanking +{ + /// Differences below this are rounding, never a reason to prefer either layout. + private const double Epsilon = 1e-6; + + internal static bool Prefers(CommonLineUsage usage, NestPipelineResult candidate, NestPipelineResult incumbent) + { + ArgumentNullException.ThrowIfNull(candidate); + ArgumentNullException.ThrowIfNull(incumbent); + if (usage == CommonLineUsage.Off || !Keepable(candidate)) + return false; + if (!Keepable(incumbent)) + return true; + var placed = Placed(candidate).CompareTo(Placed(incumbent)); + if (placed != 0) + return placed > 0; + var sheets = Sheets(candidate).CompareTo(Sheets(incumbent)); + if (sheets != 0) + return sheets < 0; + // Equal parts and sheets: "when it adds parts" keeps the incumbent. + if (usage != CommonLineUsage.Whenever) + return false; + var material = Material(candidate, incumbent); + var cut = Compare(CutLength(candidate), CutLength(incumbent)); + // Better on one measure and no worse on the other. + return (material > 0 && cut <= 0) || (cut < 0 && material >= 0); + } + + private static bool Keepable(NestPipelineResult result) => result.CanKeep && result.IsValid; + + private static int Placed(NestPipelineResult result) => result.Raw.Plates.Sum(p => p.Placements.Count); + + private static int Sheets(NestPipelineResult result) => result.Raw.Plates.Count; + + /// Positive when uses less material than : + /// a lower net cost (a larger salvage-credited remnant), then the larger compactness offcut + /// (a shorter used length). + internal static int Material(NestPipelineResult a, NestPipelineResult b) + { + var cost = Compare(NestJobCost.Evaluate(a.Job, a.Raw), NestJobCost.Evaluate(b.Job, b.Raw)); + if (cost != 0) + return -cost; + return Compare(NestJobCost.CompactnessOffcut(a.Job, a.Raw), NestJobCost.CompactnessOffcut(b.Job, b.Raw)); + } + + /// + /// Total cut length: every part's cut contours, less each shared span once (a common line is + /// cut once). Repeated sheets count each copy. + /// + internal static double CutLength(NestPipelineResult result) + { + var lengths = new Dictionary(ReferenceEqualityComparer.Instance); + var total = 0.0; + foreach (var plate in result.Plates) + { + var length = plate.Parts.Sum(part => ContourLength(part.BaseDrawing, lengths)); + foreach (var group in plate.CommonLines) + length -= CommonLineGeometry.Check(group).Spans.Sum(span => span.Length); + total += plate.Quantity * length; + } + return total; + } + + private static double ContourLength(Drawing drawing, Dictionary lengths) + { + if (!lengths.TryGetValue(drawing, out var length)) + { + length = ConvertProgram.ToGeometry(drawing.Program).ForNesting().Sum(entity => entity.Length); + lengths.Add(drawing, length); + } + return length; + } + + private static int Compare(double a, double b) => + System.Math.Abs(a - b) <= Epsilon * System.Math.Max(1, System.Math.Max(System.Math.Abs(a), System.Math.Abs(b))) + ? 0 : a.CompareTo(b); +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs index 2f9598c8..18370aaf 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -16,7 +16,7 @@ namespace OpenNest.Engine.Jobs.Cutouts; /// Those go only through , which keeps every disc clear of the /// others' whole enclosing circles; lattice and NFP placement could seat a part in a ring's /// empty bore, or over a nested bundle's reserved inserts. -internal sealed class CutoutPipelinePrepass +internal sealed class CutoutPipelinePrepass : IPipelinePrepass { // A job with thousands of repeats still offers the residual as ordinary demand; // do not multiply expensive Clipper/Fill preparation by every physical instance. @@ -44,7 +44,9 @@ internal sealed class CutoutPipelinePrepass this.bundles = bundles; } - internal NestJob EngineJob { get; } + public NestJob EngineJob { get; } + + public string ReconciliationFailure => "Cutout engine output failed reconciliation"; internal static CutoutPipelinePrepass? Prepare(NestJob original, CancellationToken token) { @@ -242,7 +244,7 @@ internal sealed class CutoutPipelinePrepass /// Reserved insert copies per original requirement whose frame proxy the engine /// did not place; call only on engine output that accepted. - internal IReadOnlyDictionary ReleasedInserts(NestJobResult engineResult) + public IReadOnlyDictionary ReleasedInserts(NestJobResult engineResult) { var placed = engineResult.Plates.SelectMany(p => p.Placements) .Select(p => p.PartId).ToHashSet(StringComparer.Ordinal); @@ -271,65 +273,13 @@ internal sealed class CutoutPipelinePrepass /// Reject untrusted accounting before expansion. A failed trial is never bindable, /// including with allowInvalid. Only actually placed proxies consume reserved inserts; /// an unplaced proxy's inserts stay unplaced demand of their own requirements. - internal bool TryExpand(NestJob original, NestJobResult result, CancellationToken token, + public bool TryExpand(NestJob original, NestJobResult result, CancellationToken token, out NestJobResult? expanded, out string failure) { expanded = null; failure = "Invalid cutout engine accounting"; - var parts = EngineJob.Parts.ToDictionary(p => p.Id); - var counts = parts.ToDictionary(p => p.Key, _ => 0); - var indices = parts.ToDictionary(p => p.Key, _ => new HashSet()); - var stockById = EngineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal); - var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal); - var expectedIndex = 0; - foreach (var sheet in result.Plates) - { - token.ThrowIfCancellationRequested(); - if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0 - || !used.ContainsKey(sheet.StockId)) - return false; - used[sheet.StockId]++; - foreach (var pose in sheet.Placements) - { - if (pose.PartId == null || !parts.ContainsKey(pose.PartId) - || pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex) - || ++counts[pose.PartId] > parts[pose.PartId].Quantity) - return false; - } - } - foreach (var row in indices) - { - token.ThrowIfCancellationRequested(); - if (row.Value.Any(index => index >= counts[row.Key])) - return false; - } - if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count) - return false; - var seenParts = new HashSet(StringComparer.Ordinal); - foreach (var row in result.Fulfillment) - { - token.ThrowIfCancellationRequested(); - if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part) - || !seenParts.Add(row.PartId) - || row.Requested != part.Quantity || row.Placed != counts[row.PartId] - || row.Unplaced != part.Quantity - counts[row.PartId]) - return false; - } - var seenStock = new HashSet(StringComparer.Ordinal); - foreach (var row in result.StockUsage) - { - token.ThrowIfCancellationRequested(); - if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock) - || !seenStock.Add(row.StockId) - || row.Used != used[row.StockId] - || row.Remaining != stock.Quantity - row.Used) - return false; - } - var complete = parts.Values.All(p => counts[p.Id] == p.Quantity); - if (result.Status != (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete) - || (complete && result.StopReason != NestJobStopReason.Completed) - || (!complete && result.StopReason == NestJobStopReason.Completed)) + if (!PipelinePrepassAccounting.Matches(EngineJob, result, token)) return false; var builder = new NestJobResultBuilder(original); diff --git a/OpenNest.Engine/Jobs/NestJobOptions.cs b/OpenNest.Engine/Jobs/NestJobOptions.cs index f6d73120..7e668e27 100644 --- a/OpenNest.Engine/Jobs/NestJobOptions.cs +++ b/OpenNest.Engine/Jobs/NestJobOptions.cs @@ -53,7 +53,35 @@ public sealed class NestJobOptions /// A copy of these options with set. public NestJobOptions WithSearchUntilCancelled(bool value) => - new(PlacementStrategy, MaxPlates, SalvageRate, MinimumSalvageDimension, value, OffcutCut); + new(PlacementStrategy, MaxPlates, SalvageRate, MinimumSalvageDimension, value, OffcutCut) + { + CommonLineUsage = CommonLineUsage, + CommonLineKerf = CommonLineKerf, + }; + + /// + /// When the pipeline may place parts that allow it on common lines, one + /// apart (see docs/common-line-cutting.md). Off by default; + /// without a positive kerf it acts as off. + /// + public CommonLineUsage CommonLineUsage + { + get => commonLineUsage; + init => commonLineUsage = Enum.IsDefined(value) ? value : throw new ArgumentOutOfRangeException(nameof(CommonLineUsage)); + } + + private readonly CommonLineUsage commonLineUsage; + private readonly double commonLineKerf; + + /// The cutting kerf for the job's material and thickness, in job units; zero when unknown. + public double CommonLineKerf + { + get => commonLineKerf; + init => commonLineKerf = double.IsFinite(value) && value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(CommonLineKerf)); + } + + /// True when common lines may be made: a level is on and the kerf is positive. + public bool UsesCommonLines => CommonLineUsage != CommonLineUsage.Off && CommonLineKerf > 0; /// Fraction of eligible edge-offcut area credited by (0..1). public double SalvageRate { get; } diff --git a/OpenNest.Engine/Jobs/NestJobPart.cs b/OpenNest.Engine/Jobs/NestJobPart.cs index f8c17801..f05165ee 100644 --- a/OpenNest.Engine/Jobs/NestJobPart.cs +++ b/OpenNest.Engine/Jobs/NestJobPart.cs @@ -42,4 +42,10 @@ public sealed class NestJobPart /// public int Priority { get; } public RotationPolicy Rotation { get; } + + /// + /// The part may share a common line with copies of itself when the job's + /// allows it (Drawing.AllowCommonLine). + /// + public bool AllowCommonLine { get; init; } } diff --git a/OpenNest.Engine/Jobs/NestJobResult.cs b/OpenNest.Engine/Jobs/NestJobResult.cs index 31e6c313..1d782248 100644 --- a/OpenNest.Engine/Jobs/NestJobResult.cs +++ b/OpenNest.Engine/Jobs/NestJobResult.cs @@ -36,25 +36,69 @@ public sealed record PartFulfillment(string PartId, int Requested, int Placed, i /// Used counts physical sheets; Remaining is null only for unlimited stock. public sealed record StockUsage(string StockId, int Used, int? Remaining); +/// +/// One common-line group on a result sheet: the zero-based positions of its members in the +/// sheet's and the kerf they were placed at. The +/// validation exempts members from the part spacing only through this explicit list, never by +/// guessing from proximity (see docs/common-line-cutting.md). +/// +public sealed record NestJobCommonLine +{ + public NestJobCommonLine(double kerf, IEnumerable placements) + { + Kerf = kerf; + Placements = NestJob.Own(placements ?? throw new ArgumentNullException(nameof(placements))); + } + + public double Kerf { get; } + public IReadOnlyList Placements { get; } + + public bool Equals(NestJobCommonLine other) => + other is not null + && BitConverter.DoubleToInt64Bits(Kerf) == BitConverter.DoubleToInt64Bits(other.Kerf) + && System.Linq.Enumerable.SequenceEqual(Placements, other.Placements); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(BitConverter.DoubleToInt64Bits(Kerf)); + foreach (var index in Placements) + hash.Add(index); + return hash.ToHashCode(); + } +} + /// One physical sheet, with owned ordered placements and immutable stock/settings snapshot. public sealed class NestJobPlateResult { public NestJobPlateResult( int plateIndex, NestPlateStock stock, - IEnumerable placements + IEnumerable placements, + IEnumerable commonLines = null ) { ArgumentNullException.ThrowIfNull(stock); PlateIndex = plateIndex; Stock = stock; Placements = NestJob.Own(placements); + CommonLines = commonLines == null ? Array.Empty() : NestJob.Own(commonLines); } public int PlateIndex { get; } public string StockId => Stock.Id; public NestPlateStock Stock { get; } public IReadOnlyList Placements { get; } + + /// Common-line groups among ; empty when there are none. + [System.Text.Json.Serialization.JsonIgnore] + public IReadOnlyList CommonLines { get; } + + /// when serialized: omitted when empty, so a sheet without + /// groups serializes exactly as before they existed. + [System.Text.Json.Serialization.JsonPropertyName("CommonLines")] + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList SerializedCommonLines => CommonLines.Count == 0 ? null : CommonLines; } /// Detached result values in commit/input order; no mutable Drawing, Plate, or NestItem escapes. diff --git a/OpenNest.Engine/Jobs/NestJobResultBuilder.cs b/OpenNest.Engine/Jobs/NestJobResultBuilder.cs index da5e1b95..3c90452b 100644 --- a/OpenNest.Engine/Jobs/NestJobResultBuilder.cs +++ b/OpenNest.Engine/Jobs/NestJobResultBuilder.cs @@ -35,7 +35,8 @@ public sealed class NestJobResultBuilder /// public int AddSheet( NestPlateStock stock, - IEnumerable<(string PartId, double X, double Y, double Rotation)> placements + IEnumerable<(string PartId, double X, double Y, double Rotation)> placements, + IEnumerable commonLines = null ) { ArgumentNullException.ThrowIfNull(placements); @@ -57,7 +58,7 @@ public sealed class NestJobResultBuilder } var plateIndex = sheets.Count; - sheets.Add(new NestJobPlateResult(plateIndex, stock, poses)); + sheets.Add(new NestJobPlateResult(plateIndex, stock, poses, commonLines)); foreach (var count in counts) placed[count.Key] = count.Value; used[stock.Id]++; diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index 1da0e57a..267ae15b 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -90,6 +90,11 @@ public static class NestLayoutCheck if (!double.IsFinite(pose.Rotation)) violations.Add($"{description} has nonfinite Rotation ({pose.Rotation})"); } + var grouped = new HashSet(); + foreach (var group in sheet.CommonLines) + if (group.Placements.Count < 2 || !double.IsFinite(group.Kerf) || group.Kerf <= 0 + || group.Placements.Any(i => i < 0 || i >= sheet.Placements.Count || !grouped.Add(i))) + violations.Add($"Plate {sheet.PlateIndex} has a malformed common-line group"); } } @@ -232,7 +237,8 @@ public static class NestLayoutCheck ValidateBounds(parts, plate, requirements, result); ValidateAreaBudget(parts, plate, result); - ValidateSpacing(parts, plate.PartSpacing, requirements, result); + ValidateSpacing(parts, plate.PartSpacing, requirements, result, plate.CommonLines); + ValidateCommonLines(plate, parts, requirements, result); if (plate.ClampZones.Count > 0) ValidateClamps(parts, plate, requirements, result); } @@ -363,6 +369,9 @@ public static class NestLayoutCheck } } + foreach (var sheet in jobResult.Plates) + ValidateCommonLinesAgainstJob(job, sheet, partsById, displayNames, result); + foreach (var sheet in jobResult.Plates) { foreach (var placement in sheet.Placements) @@ -384,6 +393,43 @@ public static class NestLayoutCheck } } + /// + /// A sheet's explicit common-line groups must be allowed by the job: a common-line level on, + /// the job's kerf, members that allow common lines, and positions on the sheet that no other + /// group takes. Geometry (that the members really share edges) is checked on the plate. + /// + private static void ValidateCommonLinesAgainstJob(NestJob job, NestJobPlateResult sheet, + IReadOnlyDictionary partsById, IReadOnlyDictionary displayNames, + List result) + { + if (sheet.CommonLines.Count == 0) + return; + if (!job.Options.UsesCommonLines) + { + result.Add($"Plate {sheet.PlateIndex} has common-line groups, which this job does not allow"); + return; + } + var taken = new HashSet(); + foreach (var group in sheet.CommonLines) + { + if (BitConverter.DoubleToInt64Bits(group.Kerf) != BitConverter.DoubleToInt64Bits(job.Options.CommonLineKerf)) + result.Add($"Plate {sheet.PlateIndex} has a common-line group at kerf {group.Kerf:F4}; the job's kerf is {job.Options.CommonLineKerf:F4}"); + if (group.Placements.Count < 2) + result.Add($"Plate {sheet.PlateIndex} has a common-line group of fewer than two parts"); + foreach (var index in group.Placements) + { + if (index < 0 || index >= sheet.Placements.Count || !taken.Add(index)) + { + result.Add($"Plate {sheet.PlateIndex} has a common-line group naming placement {index}, which is missing or already in a group"); + continue; + } + var id = sheet.Placements[index].PartId; + if (id != null && partsById.TryGetValue(id, out var part) && !part.AllowCommonLine) + result.Add($"'{displayNames.GetValueOrDefault(id, id)}' is in a common-line group on plate {sheet.PlateIndex} but does not allow common lines"); + } + } + } + private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) => ReferenceEquals(expected, actual) || ( @@ -547,9 +593,16 @@ public static class NestLayoutCheck List parts, double spacing, IReadOnlyDictionary requirements, - List result + List result, + IEnumerable commonLines = null ) { + // Members of one explicit common-line group are held to the group's kerf instead. + var groupOf = new Dictionary(ReferenceEqualityComparer.Instance); + if (commonLines != null) + foreach (var group in commonLines) + foreach (var member in group.Members) + groupOf.TryAdd(member, group); var profiles = new ShapeProfile[parts.Count]; var raw = new SpacingOutline[parts.Count]; var inflated = new SpacingOutline[parts.Count]; @@ -596,6 +649,18 @@ public static class NestLayoutCheck if (!Overlaps(inflated[i], raw[j])) continue; + if (groupOf.TryGetValue(parts[i], out var shared) + && groupOf.TryGetValue(parts[j], out var partnerGroup) && ReferenceEquals(shared, partnerGroup)) + { + native[i] ??= NativeContours.Of(profiles[i], parts[i].Location); + native[j] ??= NativeContours.Of(profiles[j], parts[j].Location); + if (native[i].TooClose(native[j], shared.Kerf)) + result.Add( + $"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' share a common line but are closer than its kerf ({shared.Kerf:F4})" + ); + continue; + } + // The outline circumscribes arcs by more than the slack, so confirm a spacing // candidate on the native contours. Zero spacing has no gap to confirm. if (inflateBy > Tolerance.Epsilon) @@ -613,6 +678,29 @@ public static class NestLayoutCheck } } + /// + /// Every explicit common-line group must be intact: each member shares a straight edge one + /// kerf apart with another member and the members form one connected group + /// (). A group that does not share is not a common line, + /// so its spacing exemption would hide a real violation. + /// + private static void ValidateCommonLines(Plate plate, List parts, + IReadOnlyDictionary requirements, List result) + { + foreach (var group in plate.CommonLines) + { + if (group.Members.Any(member => !parts.Contains(member))) + { + result.Add("A common-line group names a part that is not on its plate"); + continue; + } + if (!(group.Kerf < plate.PartSpacing)) + result.Add($"A common-line group's kerf ({group.Kerf:F4}) is not smaller than the part spacing ({plate.PartSpacing:F3})"); + foreach (var problem in CommonLineGeometry.Check(group).Problems) + result.Add($"'{DisplayName(problem.Part, requirements)}' {problem.Message}"); + } + } + /// Analytic world-space material bounds; surface marks never bound material. internal static Box MaterialBounds(Part part) => PlateRemnant.MaterialBounds(part); diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 9fa474fe..a9125c0e 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.CommonLines; using OpenNest.Engine.Jobs.Cutouts; namespace OpenNest.Engine.Jobs; @@ -24,6 +25,10 @@ public sealed record NestPipelineRequest( /// raw physical occurrence; Quantity is the total physical copies, including the first. public sealed record ProposedPlate(int PlateIndex, NestPlateStock Stock, IReadOnlyList Parts) { + /// Common-line groups among ; committing the plate adds them to + /// . Empty when the layout has none. + public IReadOnlyList CommonLines { get; init; } = Array.Empty(); + private int quantity = 1; public int Quantity { @@ -176,7 +181,11 @@ public static class NestPipeline part.UpdateBounds(); placed.Add(part); } - sheets.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock, placed) { Quantity = group.Quantity }); + sheets.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock, placed) + { + Quantity = group.Quantity, + CommonLines = NestResultBinder.BindCommonLines(group.Sheet, placed), + }); } return sheets; } @@ -251,6 +260,55 @@ public static class NestPipeline ) { var (job, drawingsByPartId) = CreateJob(engine, request, token); + return Run(engine, engineName, job, drawingsByPartId, progress, token, cutouts); + } + + /// + /// The pipeline for an already built job whose part ids map to + /// (the benchmark builds its own jobs); identical to otherwise. + /// + internal static NestPipelineResult Run(INestingEngine engine, string engineName, NestJob job, + IReadOnlyDictionary drawingsByPartId, IProgress progress, + CancellationToken token, bool cutouts = true) + { + ArgumentNullException.ThrowIfNull(engine); + ArgumentNullException.ThrowIfNull(job); + NestJobValidator.Validate(job); + var ordinary = RunOrdinary(engine, engineName, job, drawingsByPartId, progress, token, cutouts); + return job.Options.UsesCommonLines + ? WithCommonLines(engine, engineName, job, drawingsByPartId, ordinary, progress, token) + : ordinary; + } + + /// + /// The common-line trials (see ), each solved after + /// the ordinary solve and kept only when prefers it at the + /// job's usage level; otherwise the ordinary result stands as it was. + /// + private static NestPipelineResult WithCommonLines(INestingEngine engine, string engineName, NestJob job, + IReadOnlyDictionary drawingsByPartId, NestPipelineResult ordinary, + IProgress progress, CancellationToken token) + { + var best = ordinary; + var asked = new HashSet(StringComparer.Ordinal); + foreach (var share in new[] { CommonLinePipelinePrepass.Share.All, CommonLinePipelinePrepass.Share.Half }) + { + token.ThrowIfCancellationRequested(); + var prepass = CommonLinePipelinePrepass.Prepare(job, share, token); + // Two shares that pair the same counts would ask the engine the same question. + if (prepass == null || !asked.Add(string.Join("|", Remote.NestSolveTranscript.Fingerprint(prepass.EngineJob)))) + continue; + var trial = Evaluate(engine, engineName, job, prepass, drawingsByPartId, progress, token); + if (CommonLineRanking.Prefers(job.Options.CommonLineUsage, trial, best)) + best = trial; + } + return ReferenceEquals(best, ordinary) ? ordinary : Committed(best, progress, token); + } + + private static NestPipelineResult RunOrdinary(INestingEngine engine, string engineName, NestJob job, + IReadOnlyDictionary drawingsByPartId, IProgress progress, + CancellationToken token, bool cutouts) + { var prepass = cutouts ? CutoutPipelinePrepass.Prepare(job, token) : null; if (prepass == null) return Evaluate(engine, engineName, job, null, drawingsByPartId, progress, token); @@ -331,7 +389,7 @@ public static class NestPipeline INestingEngine engine, string engineName, NestJob job, - CutoutPipelinePrepass prepass, + IPipelinePrepass prepass, IReadOnlyDictionary drawingsByPartId, IProgress progress, CancellationToken token @@ -378,7 +436,7 @@ public static class NestPipeline { // A compound may be physically invalid even when its envelope is legal. // Refuse every unverified trial, including with explicit invalid-result consent. - var failure = "Cutout engine output failed reconciliation"; + var failure = prepass.ReconciliationFailure; if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure)) { released = prepass.ReleasedInserts(raw); @@ -408,9 +466,12 @@ public static class NestPipeline foreach (var group in RepeatedPlateGrouping.Create(raw.Plates, consolidate, token)) { token.ThrowIfCancellationRequested(); - plates.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock, - NestResultBinder.Bind(group.Sheet, drawingsByPartId)) - { Quantity = group.Quantity }); + var bound = NestResultBinder.Bind(group.Sheet, drawingsByPartId); + plates.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock, bound) + { + Quantity = group.Quantity, + CommonLines = NestResultBinder.BindCommonLines(group.Sheet, bound), + }); } // Binding clones the caller's current Program; a concurrent edit during binding diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs index 12decce6..16ab6485 100644 --- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -97,6 +97,8 @@ public static class NestPipelineCommit plate.Quantity = proposed.Quantity; SyncClampZones(plate, proposed.Stock); plate.Parts.AddRange(proposed.Parts); + foreach (var group in proposed.CommonLines) + plate.CommonLines.Add(group); applied.Add(plate); } return applied.AsReadOnly(); diff --git a/OpenNest.Engine/Jobs/NestRenest.cs b/OpenNest.Engine/Jobs/NestRenest.cs index 5e5bb9fe..1797c000 100644 --- a/OpenNest.Engine/Jobs/NestRenest.cs +++ b/OpenNest.Engine/Jobs/NestRenest.cs @@ -190,7 +190,13 @@ public static class NestRenest NestStockBuilder.FromTemplate(nest.PlateDefaults.CreateNew(), options), new NestJobOptions(maxPlates: MaxPlates, salvageRate: options.Count > 0 ? nest.SalvageRate : 0, - minimumSalvageDimension: options.Count > 0 ? minimumSalvageDimension : 0)); + minimumSalvageDimension: options.Count > 0 ? minimumSalvageDimension : 0) + { + // The engine has no machine defaults: a plate's saved cutting kerf, if any. + CommonLineUsage = nest.CommonLineUsage, + CommonLineKerf = nest.Plates.Select(p => p.CuttingParameters?.Kerf ?? 0) + .FirstOrDefault(k => double.IsFinite(k) && k > 0), + }); } /// diff --git a/OpenNest.Engine/Jobs/PipelinePrepass.cs b/OpenNest.Engine/Jobs/PipelinePrepass.cs new file mode 100644 index 00000000..e0b6f211 --- /dev/null +++ b/OpenNest.Engine/Jobs/PipelinePrepass.cs @@ -0,0 +1,94 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest.Engine.Jobs; + +/// +/// A pipeline pre-pass that offers the engine a transformed job (compound proxies standing in +/// for several parts) and expands the engine's answer back to the original job's parts, which +/// are then validated again. Cutout bundles and common-line pairs are the two kinds. +/// +internal interface IPipelinePrepass +{ + /// The job the engine solves. + NestJob EngineJob { get; } + + /// The violation reported when the engine's answer cannot be expanded. + string ReconciliationFailure { get; } + + /// Expands an answer to into one for . + bool TryExpand(NestJob original, NestJobResult result, CancellationToken token, + out NestJobResult? expanded, out string failure); + + /// Copies per original requirement that an unplaced proxy left unplaced as a frame's insert. + IReadOnlyDictionary ReleasedInserts(NestJobResult engineResult); +} + +/// The accounting every pre-pass checks before trusting an engine's answer. +internal static class PipelinePrepassAccounting +{ + /// + /// True when is self-consistent for : + /// consecutive sheet indices, known stock and parts, unique instance indices within quantity, + /// and fulfillment, stock usage and status rows that match the placements. + /// + internal static bool Matches(NestJob engineJob, NestJobResult result, CancellationToken token) + { + var parts = engineJob.Parts.ToDictionary(p => p.Id); + var counts = parts.ToDictionary(p => p.Key, _ => 0); + var indices = parts.ToDictionary(p => p.Key, _ => new HashSet()); + var stockById = engineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal); + var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal); + var expectedIndex = 0; + foreach (var sheet in result.Plates) + { + token.ThrowIfCancellationRequested(); + if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0 + || !used.ContainsKey(sheet.StockId)) + return false; + used[sheet.StockId]++; + foreach (var pose in sheet.Placements) + { + if (pose.PartId == null || !parts.ContainsKey(pose.PartId) + || pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex) + || ++counts[pose.PartId] > parts[pose.PartId].Quantity) + return false; + } + } + foreach (var row in indices) + { + token.ThrowIfCancellationRequested(); + if (row.Value.Any(index => index >= counts[row.Key])) + return false; + } + if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count) + return false; + var seenParts = new HashSet(StringComparer.Ordinal); + foreach (var row in result.Fulfillment) + { + token.ThrowIfCancellationRequested(); + if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part) + || !seenParts.Add(row.PartId) + || row.Requested != part.Quantity || row.Placed != counts[row.PartId] + || row.Unplaced != part.Quantity - counts[row.PartId]) + return false; + } + var seenStock = new HashSet(StringComparer.Ordinal); + foreach (var row in result.StockUsage) + { + token.ThrowIfCancellationRequested(); + if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock) + || !seenStock.Add(row.StockId) + || row.Used != used[row.StockId] + || row.Remaining != stock.Quantity - row.Used) + return false; + } + var complete = parts.Values.All(p => counts[p.Id] == p.Quantity); + return result.Status == (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete) + && (!complete || result.StopReason == NestJobStopReason.Completed) + && (complete || result.StopReason != NestJobStopReason.Completed); + } +} diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.CommonLines.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.CommonLines.cs new file mode 100644 index 00000000..f5bd6c3c --- /dev/null +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.CommonLines.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.CommonLines; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs.Placement; + +/// A proposed fill: new parts and the common-line groups among them (empty when none). +public sealed record PlateFillProposal(List Parts, IReadOnlyList CommonLines); + +public static partial class PlateFillService +{ + /// + /// + /// at a common-line usage level. With the level off, a drawing that does not allow common lines, + /// or no positive kerf, it is exactly that fill. Otherwise the area is also filled with kerf-gap + /// pairs of the drawing (), each expanded into its two parts and a + /// group, and the pair fill replaces the plain one when it places more parts, or (at + /// ) the same number and the strategy's own ranking does + /// not prefer the plain fill. Ties at keep the + /// plain fill. The proposal is not committed: the caller adds the parts, then the groups. + /// + public static PlateFillProposal FillItem( + string strategy, + Plate plate, + NestItem item, + Box workArea, + CommonLineUsage usage, + double kerf, + IProgress progress, + CancellationToken token + ) + { + ArgumentNullException.ThrowIfNull(item); + var plain = FillItem(strategy, plate, item, workArea, progress, token); + var none = new PlateFillProposal(plain, Array.Empty()); + if (usage == CommonLineUsage.Off || item.Drawing?.AllowCommonLine != true + || !double.IsFinite(kerf) || kerf <= 0 || !(kerf < plate.PartSpacing) + || item.Quantity is > 0 and < 2 || token.IsCancellationRequested) + return none; + var constraints = item.Drawing.Constraints; + // The second copy turns half a turn, so a fixed rotation must allow its 180° equivalent. + if (item.StepAngle > 0 && constraints?.Allow180Equivalent != true) + return none; + + CommonLinePair pair; + try + { + pair = CommonLinePair.Build(PartGeometrySnapshot.FromProgram(item.Drawing.Program), kerf); + } + catch (NotSupportedException) + { + return none; + } + if (pair == null) + return none; + + var proxy = new Drawing(item.Drawing.Name, DrawingJobMapper.ToProgram(pair.Geometry)) + { + Priority = item.Drawing.Priority, + Constraints = constraints, + }; + var proxyItem = new NestItem + { + Drawing = proxy, + Priority = item.Priority, + Quantity = item.Quantity > 0 ? item.Quantity / 2 : item.Quantity, + StepAngle = item.StepAngle, + RotationStart = item.RotationStart, + RotationEnd = item.RotationEnd, + }; + // Previews show the plain fill; the pair fill is compared once both are done. + var pairs = FillItem(strategy, plate, proxyItem, workArea, null, token); + if (pairs.Count == 0 || token.IsCancellationRequested) + return none; + + var parts = new List(2 * pairs.Count); + var groups = new List(pairs.Count); + foreach (var placed in pairs) + { + var rotation = placed.Rotation; + var first = Place(item.Drawing, rotation, placed.Location); + var sine = System.Math.Sin(rotation); + var cosine = System.Math.Cos(rotation); + var second = Place(item.Drawing, rotation + CommonLinePair.SecondRotation, + new Vector(placed.Location.X + pair.SecondX * cosine - pair.SecondY * sine, + placed.Location.Y + pair.SecondX * sine + pair.SecondY * cosine)); + parts.Add(first); + parts.Add(second); + groups.Add(new CommonLineGroup(new[] { first, second }, kerf)); + } + + if (parts.Count < plain.Count) + return none; + if (parts.Count == plain.Count) + { + if (usage != CommonLineUsage.Whenever + || RequireFiller(strategy, plate).IsBetterFill(plain, parts, workArea)) + return none; + } + return new PlateFillProposal(parts, groups); + } + + // The proxy's frame is the drawing's own: rotate about the program origin, then move. + private static Part Place(Drawing drawing, double rotation, Vector location) + { + var part = new Part(drawing); + part.Rotate(rotation); + part.Location = location; + part.UpdateBounds(); + return part; + } +} diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs index bb66e63d..2bb90edd 100644 --- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs @@ -15,7 +15,7 @@ namespace OpenNest.Engine.Jobs.Placement; /// (accepting a preview, adding parts to a plate) and cancel/discard behavior remain with the /// caller, exactly as they were with the legacy single-plate engine surface. /// -public static class PlateFillService +public static partial class PlateFillService { /// Strategy used when a caller selection names none. public const string DefaultStrategy = "Lattice"; diff --git a/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs b/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs index 49fe7fa0..392e3bb9 100644 --- a/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs +++ b/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs @@ -24,6 +24,13 @@ public sealed class NestJobDocument private const int LowerFirstPriorityVersion = 1; + /// + /// A version 2 job that also uses common lines ( on + /// with a positive kerf). Written only then, so a worker that cannot make common lines refuses + /// the job rather than solving it without them, which the desktop's replay would reject. + /// + public const int CommonLineVersion = 3; + private static readonly JsonSerializerOptions Options = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -70,8 +77,12 @@ public sealed class NestJobDocument SalvageRate = o.SalvageRate, MinimumSalvageDimension = o.MinimumSalvageDimension, OffcutCut = o.OffcutCut, + CommonLineUsage = o.CommonLineUsage, + CommonLineKerf = o.CommonLineKerf, } : null, }; + if (request.Options?.UsesCommonLines == true) + document.Version = CommonLineVersion; foreach (var item in request.Items) { var index = item?.Drawing == null ? -1 : IndexOf(drawings, item.Drawing); @@ -94,8 +105,8 @@ public sealed class NestJobDocument public NestPipelineRequest ToRequest(IReadOnlyList drawings) { ArgumentNullException.ThrowIfNull(drawings); - if (Version != CurrentVersion && Version != LowerFirstPriorityVersion) - throw new FormatException($"Nest job version {Version} is not supported; this build reads {CurrentVersion}."); + if (Version != CurrentVersion && Version != LowerFirstPriorityVersion && Version != CommonLineVersion) + throw new FormatException($"Nest job version {Version} is not supported; this build reads {CommonLineVersion}."); if (string.IsNullOrWhiteSpace(EngineName)) throw new FormatException("The nest job names no engine."); if (DrawingNames == null || DrawingNames.Count != drawings.Count @@ -124,6 +135,12 @@ public sealed class NestJobDocument var options = JobOptions is { } o ? new NestJobOptions(o.PlacementStrategy ?? "Lattice", o.MaxPlates, o.SalvageRate, o.MinimumSalvageDimension, searchUntilCancelled: false, o.OffcutCut) + { + CommonLineUsage = Enum.IsDefined(o.CommonLineUsage) ? o.CommonLineUsage + : throw new FormatException("The nest job's common-line usage is not one this build knows."), + CommonLineKerf = double.IsFinite(o.CommonLineKerf) && o.CommonLineKerf >= 0 ? o.CommonLineKerf + : throw new FormatException("The nest job's common-line kerf is not a finite, nonnegative number."), + } : null; return new NestPipelineRequest(EngineName, items, (Stock ?? new()).Select(s => s?.ToStock() ?? throw new FormatException("A nest job stock entry is empty.")).ToList(), @@ -169,5 +186,11 @@ public sealed class NestJobDocument public double SalvageRate { get; set; } public double MinimumSalvageDimension { get; set; } public CutOffAxis? OffcutCut { get; set; } + + /// ; absent (off) before version 3. + public CommonLineUsage CommonLineUsage { get; set; } + + /// ; absent (zero) before version 3. + public double CommonLineKerf { get; set; } } } diff --git a/OpenNest.Engine/Jobs/Remote/NestSolveTranscript.cs b/OpenNest.Engine/Jobs/Remote/NestSolveTranscript.cs index 99d56651..69faf79d 100644 --- a/OpenNest.Engine/Jobs/Remote/NestSolveTranscript.cs +++ b/OpenNest.Engine/Jobs/Remote/NestSolveTranscript.cs @@ -76,6 +76,8 @@ public sealed class NestSolveTranscript PlateIndex = p.PlateIndex, Stock = NestStockJson.From(p.Stock), Placements = p.Placements.ToList(), + CommonLines = p.CommonLines.Count == 0 ? null + : p.CommonLines.Select(g => new CommonLineJson { Kerf = g.Kerf, Placements = g.Placements.ToList() }).ToList(), }).ToList(), Fulfillment = result.Fulfillment.ToList(), StockUsage = result.StockUsage.ToList(), @@ -90,7 +92,9 @@ public sealed class NestSolveTranscript { var stock = plate.Stock?.ToStock() ?? throw new FormatException("A result sheet has no stock."); var own = job.Plates.FirstOrDefault(s => RepeatedPlateGrouping.SameStock(s, stock)); - plates.Add(new NestJobPlateResult(plate.PlateIndex, own ?? stock, plate.Placements ?? new())); + plates.Add(new NestJobPlateResult(plate.PlateIndex, own ?? stock, plate.Placements ?? new(), + plate.CommonLines?.Select(g => g == null ? throw new FormatException("A result common-line group is empty.") + : new NestJobCommonLine(g.Kerf, g.Placements ?? new())))); } return new NestJobResult(Status, StopReason, plates, Fulfillment ?? new(), StockUsage ?? new()); } @@ -101,5 +105,16 @@ public sealed class NestSolveTranscript public int PlateIndex { get; set; } public NestStockJson Stock { get; set; } public List Placements { get; set; } = new(); + + /// Common-line groups an engine returned itself (none of the built-in engines + /// do: the pipeline makes pairs around them). Omitted when empty. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List CommonLines { get; set; } + } + + public sealed class CommonLineJson + { + public double Kerf { get; set; } + public List Placements { get; set; } = new(); } } diff --git a/OpenNest.Engine/Jobs/Remote/RecordingNestingEngine.cs b/OpenNest.Engine/Jobs/Remote/RecordingNestingEngine.cs index 117a5e3c..875eb0ed 100644 --- a/OpenNest.Engine/Jobs/Remote/RecordingNestingEngine.cs +++ b/OpenNest.Engine/Jobs/Remote/RecordingNestingEngine.cs @@ -31,7 +31,7 @@ public sealed class RecordingNestingEngine : INestingEngine { if (Progress != null) { - var tap = CutoutPipelinePrepass.HasProxies(job) ? new NestPipeline.PreviewProgress(Progress) : Progress; + var tap = CutoutPipelinePrepass.HasProxies(job) || CommonLines.CommonLinePipelinePrepass.HasProxies(job) ? new NestPipeline.PreviewProgress(Progress) : Progress; progress = progress == null ? tap : new BothProgress(progress, tap); } var result = inner.Solve(job, progress, token); diff --git a/OpenNest.Engine/Jobs/Remote/RemoteNestProgress.cs b/OpenNest.Engine/Jobs/Remote/RemoteNestProgress.cs index 809dd5d3..4c22431f 100644 --- a/OpenNest.Engine/Jobs/Remote/RemoteNestProgress.cs +++ b/OpenNest.Engine/Jobs/Remote/RemoteNestProgress.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; +using OpenNest.Engine.Jobs.CommonLines; using OpenNest.Engine.Jobs.Cutouts; namespace OpenNest.Engine.Jobs.Remote; @@ -131,6 +132,11 @@ public sealed class RemoteNestPlanPreview foreach (var part in engineJob.Parts) parts.TryAdd(part.Id, part); } + // A common-line trial's pair proxies, from either share (same geometry per part). + foreach (var share in new[] { CommonLinePipelinePrepass.Share.All, CommonLinePipelinePrepass.Share.Half }) + if (CommonLinePipelinePrepass.Prepare(job, share, token)?.EngineJob is { } pairJob) + foreach (var part in pairJob.Parts) + parts.TryAdd(part.Id, part); } if (!Known(plan)) return null; diff --git a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs index 67fb9085..f40573ac 100644 --- a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs +++ b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs @@ -80,7 +80,8 @@ internal static class RepeatedPlateGrouping if (ReferenceEquals(a, b)) return true; if (a == null || b == null || !SameStock(a.Stock, b.Stock) - || a.Placements.Count != b.Placements.Count) + || a.Placements.Count != b.Placements.Count + || !System.Linq.Enumerable.SequenceEqual(a.CommonLines, b.CommonLines)) return false; for (var i = 0; i < a.Placements.Count; i++) { diff --git a/OpenNest.IO.Tests/CommonLineUsagePersistenceTests.cs b/OpenNest.IO.Tests/CommonLineUsagePersistenceTests.cs new file mode 100644 index 00000000..db525074 --- /dev/null +++ b/OpenNest.IO.Tests/CommonLineUsagePersistenceTests.cs @@ -0,0 +1,105 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json.Nodes; + +namespace OpenNest.IO.Tests; + +public class CommonLineUsagePersistenceTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AllowCommonLine_RoundTrips_AndIsOmittedWhenFalse(bool allow) + { + var drawing = DrawingPackageFixture.Build(); + drawing.AllowCommonLine = allow; + var nest = new Nest(drawing.Name); + nest.Drawings.Add(drawing); + + var bytes = Write(nest); + var reader = new NestReader(new MemoryStream(bytes)); + var copy = Assert.Single(reader.Read().Drawings); + + Assert.Empty(reader.Warnings); + Assert.Equal(allow, copy.AllowCommonLine); + var json = NestJson(bytes)["drawings"]!.AsArray().Single()!.AsObject(); + Assert.Equal(allow, json.ContainsKey("allowCommonLine")); + } + + [Theory] + [InlineData(CommonLineUsage.WhenItAddsParts, "whenItAddsParts")] + [InlineData(CommonLineUsage.Whenever, "whenever")] + public void CommonLineUsage_RoundTrips(CommonLineUsage usage, string saved) + { + var nest = new Nest("usage") { CommonLineUsage = usage }; + + var bytes = Write(nest); + + Assert.Equal(saved, NestJson(bytes)["commonLineUsage"]!.GetValue()); + Assert.Equal(usage, new NestReader(new MemoryStream(bytes)).Read().CommonLineUsage); + } + + [Fact] + public void CommonLineUsage_Off_IsOmitted() + { + var bytes = Write(new Nest("off")); + + Assert.False(NestJson(bytes).ContainsKey("commonLineUsage")); + Assert.Equal(CommonLineUsage.Off, new NestReader(new MemoryStream(bytes)).Read().CommonLineUsage); + } + + [Theory] + [InlineData("\"sometimes\"")] + [InlineData("\"\"")] + [InlineData("null")] + public void CommonLineUsage_Unknown_ReadsOff(string value) + { + var bytes = Write(new Nest("unknown") { CommonLineUsage = CommonLineUsage.Whenever }); + var edited = ReplaceNestJson(bytes, json => json.Replace("\"commonLineUsage\":\"whenever\"", + $"\"commonLineUsage\":{value}")); + + Assert.Equal(CommonLineUsage.Off, new NestReader(new MemoryStream(edited)).Read().CommonLineUsage); + } + + private static byte[] Write(Nest nest) + { + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + return stream.ToArray(); + } + + private static JsonObject NestJson(byte[] archive) + { + using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read); + using var reader = new StreamReader(zip.GetEntry("nest.json")!.Open(), Encoding.UTF8); + return JsonNode.Parse(reader.ReadToEnd())!.AsObject(); + } + + private static byte[] ReplaceNestJson(byte[] archive, Func edit) + { + using var output = new MemoryStream(); + using (var source = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read)) + using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var entry in source.Entries) + { + using var input = entry.Open(); + using var buffer = new MemoryStream(); + input.CopyTo(buffer); + var bytes = buffer.ToArray(); + if (entry.FullName == "nest.json") + { + var text = new StreamReader(new MemoryStream(bytes), Encoding.UTF8).ReadToEnd(); + var compact = JsonNode.Parse(text)!.ToJsonString(); + var changed = edit(compact); + Assert.NotEqual(compact, changed); + bytes = Encoding.UTF8.GetBytes(changed); + } + var copy = target.CreateEntry(entry.FullName); + using var write = copy.Open(); + write.Write(bytes); + } + } + return output.ToArray(); + } +} diff --git a/OpenNest.IO.Tests/DrawingPackageTests.cs b/OpenNest.IO.Tests/DrawingPackageTests.cs index dcaa54d5..90104a03 100644 --- a/OpenNest.IO.Tests/DrawingPackageTests.cs +++ b/OpenNest.IO.Tests/DrawingPackageTests.cs @@ -90,6 +90,7 @@ public class DrawingPackageTests drawing.HasCustomColor = true; drawing.Priority = 9; drawing.CutFirst = true; + drawing.AllowCommonLine = true; drawing.Constraints = new NestConstraints { StepAngle = 0.5, StartAngle = 0.25, EndAngle = 3, Allow180Equivalent = true }; Assert.Equal(baseline, Sha(DrawingPackage.Write(drawing, Units.Inches))); diff --git a/OpenNest.IO/DrawingPackage.cs b/OpenNest.IO/DrawingPackage.cs index 54a3afb4..711d29fb 100644 --- a/OpenNest.IO/DrawingPackage.cs +++ b/OpenNest.IO/DrawingPackage.cs @@ -33,7 +33,7 @@ namespace OpenNest.IO private const string FixedDate = "2000-01-01T00:00:00.0000000"; // Removed so readers fall back to their defaults; the library record owns these values. - private static readonly string[] RecordOwnedDrawingFields = { "customer", "color", "customColor", "priority", "nestPriority", "cutFirst", "constraints" }; + private static readonly string[] RecordOwnedDrawingFields = { "customer", "color", "customColor", "priority", "nestPriority", "cutFirst", "allowCommonLine", "constraints" }; /// Packages without changing it. public static byte[] Write(Drawing drawing, Units units) diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index 6d713cf0..9d7f53d4 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -45,6 +45,13 @@ namespace OpenNest.IO public List PlateOptions { get; init; } = new(); public double SalvageRate { get; init; } = 0.5; + /// + /// Nest.CommonLineUsage: "whenItAddsParts" or "whenever". Omitted + /// when off; an unknown value reads as off. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string CommonLineUsage { get; init; } + /// /// Smallest remnant side in model units. Missing in older files, which load the Auto /// Nest default. @@ -459,6 +466,11 @@ namespace OpenNest.IO [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool CutFirst { get; init; } + /// Automatic nesting may place this drawing's parts on common lines + /// (Drawing.AllowCommonLine). Omitted when false. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool AllowCommonLine { get; init; } + public ConstraintsDto Constraints { get; init; } = new(); public MaterialDto Material { get; init; } = new(); public SourceDto Source { get; init; } = new(); diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 635a15fb..e9d25b6d 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -280,6 +280,7 @@ namespace OpenNest.IO drawing.Quantity.Required = d.Quantity.Required; drawing.Priority = d.NestPriority ?? LegacyPriority.Convert(d.Priority); drawing.CutFirst = d.CutFirst; + drawing.AllowCommonLine = d.AllowCommonLine; drawing.Constraints.StepAngle = d.Constraints.StepAngle; drawing.Constraints.StartAngle = d.Constraints.StartAngle; drawing.Constraints.EndAngle = d.Constraints.EndAngle; @@ -717,6 +718,7 @@ namespace OpenNest.IO // Stock options: sheet sizes and salvage rate nest.SalvageRate = dto.SalvageRate; + nest.CommonLineUsage = CommonLineUsageNames.Parse(dto.CommonLineUsage); nest.MinimumRemnantDimension = double.IsFinite(dto.MinimumRemnantDimension) && dto.MinimumRemnantDimension > 0 ? dto.MinimumRemnantDimension diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 19b06fa7..1da1b558 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -159,6 +159,7 @@ namespace OpenNest.IO .ToList() ?? new(), SalvageRate = nest.SalvageRate, + CommonLineUsage = CommonLineUsageNames.Format(nest.CommonLineUsage), MinimumRemnantDimension = nest.MinimumRemnantDimension, }; } @@ -239,6 +240,7 @@ namespace OpenNest.IO Priority = LegacyPriority.Convert(d.Priority), NestPriority = d.Priority == 0 ? null : d.Priority, CutFirst = d.CutFirst, + AllowCommonLine = d.AllowCommonLine, Constraints = new ConstraintsDto { StepAngle = d.Constraints.StepAngle, diff --git a/OpenNest.Tests/CommonLines/CommonLineAutoPlacementTests.cs b/OpenNest.Tests/CommonLines/CommonLineAutoPlacementTests.cs new file mode 100644 index 00000000..ec1908c7 --- /dev/null +++ b/OpenNest.Tests/CommonLines/CommonLineAutoPlacementTests.cs @@ -0,0 +1,59 @@ +using OpenNest.Engine; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.CommonLines; + +/// +/// N26-295's last plate (3/16 stainless, 48 x 120): 1 PT03, 3 PT14 and 34 PT42 at the nest's +/// 0.25 spacing. Without common lines Auto Nest fits 37 and leaves a PT42 for another sheet; +/// pairing PT42s one kerf apart fits all 38. The customer nest stays out of the repository: +/// set CommonLineLastPlateNestPath in test-config.json to run it. +/// +public class CommonLineAutoPlacementTests +{ + private const double Kerf = 0.012; + + private static NestPipelineRequest LastPlate(string path, CommonLineUsage usage) + { + var nest = new NestReader(path).Read(); + var plate = nest.Plates[4]; + var drawings = plate.Parts.Select(p => p.BaseDrawing).Distinct().ToList(); + var pt42 = drawings.Single(d => d.Name.EndsWith("PT42")); + pt42.AllowCommonLine = true; + var items = drawings.Select(d => new NestItem + { + Drawing = d, + Quantity = d == pt42 ? 34 : plate.Parts.Count(p => p.BaseDrawing == d), + }).ToList(); + var stock = new NestPlateStock("48x120", new Size(48, 120), 1, 0.25, new Spacing(0.25, 0.25, 0.25, 0.25), 1); + return new NestPipelineRequest("Default", items, [stock], + new NestJobOptions { CommonLineUsage = usage, CommonLineKerf = Kerf }); + } + + [SkippableFact] + public void LastPlate_FitsTheLastPt42OnlyWithCommonLines() + { + var path = TestConfig.GetExistingPath("CommonLineLastPlateNestPath"); + Skip.If(path == null, "N26-295 nest not configured in test-config.json"); + + var off = NestPipeline.Run(LastPlate(path!, CommonLineUsage.Off)); + var on = NestPipeline.Run(LastPlate(path!, CommonLineUsage.WhenItAddsParts)); + + Assert.True(off.IsValid, string.Join("; ", off.Violations)); + Assert.Equal(37, off.Raw.Plates.Sum(p => p.Placements.Count)); + Assert.All(off.Plates, plate => Assert.Empty(plate.CommonLines)); + + Assert.True(on.IsValid, string.Join("; ", on.Violations)); + Assert.Equal(NestJobStatus.Complete, on.Status); + var plate = Assert.Single(on.Plates); + Assert.Equal(38, plate.Parts.Count); + Assert.NotEmpty(plate.CommonLines); + Assert.All(plate.CommonLines, group => + { + Assert.True(CommonLineGeometry.Check(group).IsIntact); + Assert.All(group.Members, member => Assert.EndsWith("PT42", member.BaseDrawing.Name)); + }); + } +} diff --git a/OpenNest.Tests/Data/NestDefaultsTests.cs b/OpenNest.Tests/Data/NestDefaultsTests.cs index ae14e3d0..5bd37685 100644 --- a/OpenNest.Tests/Data/NestDefaultsTests.cs +++ b/OpenNest.Tests/Data/NestDefaultsTests.cs @@ -352,4 +352,66 @@ public class NestDefaultsTests : IDisposable Assert.Equal(id, defaults.MachineId); Assert.Equal(id, defaults.ConvertedTo(Units.Millimeters).MachineId); } + + [Theory] + [InlineData(CommonLineUsage.WhenItAddsParts, "whenItAddsParts")] + [InlineData(CommonLineUsage.Whenever, "whenever")] + public void CommonLineUsage_SavesAndLoads(CommonLineUsage usage, string saved) + { + new NestDefaults { CommonLineUsage = usage }.Save(_path); + + Assert.Contains($"\"commonLineUsage\": \"{saved}\"", File.ReadAllText(_path)); + Assert.Equal(usage, NestDefaults.Load(_path, out var status).CommonLineUsage); + Assert.Equal(NestDefaultsStatus.Ok, status); + } + + [Fact] + public void CommonLineUsage_Off_IsOmitted_AndMissingReadsOff() + { + var json = new NestDefaults().ToJson(); + + Assert.DoesNotContain("commonLineUsage", json); + Assert.Equal(CommonLineUsage.Off, NestDefaults.Parse(json, Units.Inches, out _).CommonLineUsage); + } + + [Theory] + [InlineData("\"sometimes\"")] + [InlineData("3")] + [InlineData("null")] + public void CommonLineUsage_Invalid_FallsBackToOff_WithoutLosingOtherFields(string value) + { + var json = $"{{ \"partSpacing\": 0.3, \"commonLineUsage\": {value} }}"; + + var defaults = NestDefaults.Parse(json, Units.Inches, out var status); + + Assert.Equal(NestDefaultsStatus.Ok, status); + Assert.Equal(CommonLineUsage.Off, defaults.CommonLineUsage); + Assert.Equal(0.3, defaults.PartSpacing); + } + + [Fact] + public void CommonLineUsage_FromNest_ApplyTo_AndConvertedTo_CarryIt() + { + var source = new Nest { CommonLineUsage = CommonLineUsage.Whenever }; + var defaults = NestDefaults.FromNest(source); + var target = new Nest(); + + defaults.ConvertedTo(Units.Millimeters).ApplyTo(target); + + Assert.Equal(CommonLineUsage.Whenever, defaults.CommonLineUsage); + Assert.Equal(CommonLineUsage.Whenever, target.CommonLineUsage); + } + + [Fact] + public void FromPlate_WithSavedDefaults_KeepsTheirCommonLineUsage() + { + var plate = new Plate(new Size(48, 120)) { PartSpacing = 0.25 }; + var saved = new NestDefaults { CommonLineUsage = CommonLineUsage.WhenItAddsParts, PartSpacing = 1 }; + + var defaults = NestDefaults.FromPlate(Units.Inches, plate, saved); + + Assert.Equal(CommonLineUsage.WhenItAddsParts, defaults.CommonLineUsage); + Assert.Equal(0.25, defaults.PartSpacing); + Assert.Equal(CommonLineUsage.Off, NestDefaults.FromPlate(Units.Inches, plate).CommonLineUsage); + } } diff --git a/OpenNest.WinForms.Tests/Forms/CommonLineUsageFormTests.cs b/OpenNest.WinForms.Tests/Forms/CommonLineUsageFormTests.cs new file mode 100644 index 00000000..e87126e5 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/CommonLineUsageFormTests.cs @@ -0,0 +1,46 @@ +using System.Windows.Forms; +using OpenNest.CNC; +using OpenNest.Forms; + +namespace OpenNest.WinForms.Tests.Forms; + +public class CommonLineUsageFormTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DrawingProperties_LoadsAndSavesAllowCommonLine(bool allow) => RunSta(() => + { + var drawing = new Drawing("pair", new Program()) { AllowCommonLine = allow }; + using var form = new EditDrawingForm(); + + form.LoadDrawing(drawing); + var box = Assert.IsType(form.Controls.Find("allowCommonLineBox", true).Single()); + Assert.Equal(allow, box.Checked); + + box.Checked = !allow; + form.SaveDrawing(drawing); + Assert.Equal(!allow, drawing.AllowCommonLine); + }); + + [Theory] + [InlineData(CommonLineUsage.Off, 0)] + [InlineData(CommonLineUsage.WhenItAddsParts, 1)] + [InlineData(CommonLineUsage.Whenever, 2)] + public void AutoNest_StartsAtTheNestsUsage_AndReadsTheChoice(CommonLineUsage usage, int index) => + RunSta(() => + { + var nest = new Nest { CommonLineUsage = usage }; + using var form = new AutoNestForm(nest); + var combo = Assert.IsType(form.Controls.Find("commonLineComboBox", true).Single()); + + Assert.Equal(index, combo.SelectedIndex); + Assert.Equal(usage, form.CommonLineUsage); + + combo.SelectedIndex = (index + 1) % 3; + Assert.Equal((CommonLineUsage)((index + 1) % 3), form.CommonLineUsage); + }); + + private static void RunSta(Action action) => + StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete."); +} diff --git a/OpenNest.WinForms.Tests/Forms/NestDefaultsFormTests.cs b/OpenNest.WinForms.Tests/Forms/NestDefaultsFormTests.cs index f350fdf2..3a52ebab 100644 --- a/OpenNest.WinForms.Tests/Forms/NestDefaultsFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/NestDefaultsFormTests.cs @@ -53,6 +53,26 @@ public class NestDefaultsFormTests }); } + [Fact] + public void CommonLineUsage_LoadsAndSaves_AndSurvivesAUnitSwitch() + { + RunSta(() => + { + using var form = new NestDefaultsForm(new NestDefaults { CommonLineUsage = CommonLineUsage.Whenever }, + Array.Empty(), ("Saved on this PC.", false)); + var combo = Control(form, "commonLineCombo"); + Assert.Equal(2, combo.SelectedIndex); + + Control(form, "millimetersRadio").Checked = true; + Assert.Equal(CommonLineUsage.Whenever, form.GetDefaults().CommonLineUsage); + + combo.SelectedIndex = 1; + Assert.Equal(CommonLineUsage.WhenItAddsParts, form.GetDefaults().CommonLineUsage); + combo.SelectedIndex = 0; + Assert.Equal(CommonLineUsage.Off, form.GetDefaults().CommonLineUsage); + }); + } + private static T Control(NestDefaultsForm form, string name) where T : Control => Assert.IsType(form.Controls.Find(name, true).Single()); diff --git a/OpenNest/Actions/ActionFillArea.cs b/OpenNest/Actions/ActionFillArea.cs index 050fe587..4dd02fe6 100644 --- a/OpenNest/Actions/ActionFillArea.cs +++ b/OpenNest/Actions/ActionFillArea.cs @@ -57,22 +57,28 @@ namespace OpenNest.Actions } var strategy = EngineSelection.FillStrategy; + // The nest's common-line level and the plate's kerf (see docs/common-line-cutting.md). + var nest = (plateView.FindForm() as EditNestForm)?.Nest; + var usage = nest?.CommonLineUsage ?? CommonLineUsage.Off; + var kerf = nest == null ? 0 : MachineSettings.CuttingParametersFor(plateView.Plate, nest)?.Kerf ?? 0; if (progress != null && cts != null) { try { - var parts = await Task.Run(() => + var proposal = await Task.Run(() => PlateFillService.FillItem( strategy, plateView.Plate, new NestItem { Drawing = drawing }, SelectedArea, + usage, + kerf, progress, cts.Token ) ); - onFillComplete?.Invoke(parts); + onFillComplete?.Invoke(proposal.Parts); } catch (Exception) { @@ -82,17 +88,21 @@ namespace OpenNest.Actions else { using var change = plateView.BeginChange("Fill Area"); - var parts = PlateFillService.FillItem( + var proposal = PlateFillService.FillItem( strategy, plateView.Plate, new NestItem { Drawing = drawing }, SelectedArea, + usage, + kerf, null, CancellationToken.None ); - if (parts.Count > 0) + if (proposal.Parts.Count > 0) change.MarkChanged(); - plateView.Plate.Parts.AddRange(parts); + plateView.Plate.Parts.AddRange(proposal.Parts); + foreach (var group in proposal.CommonLines) + plateView.Plate.CommonLines.Add(group); plateView.Invalidate(); change.Complete(); } diff --git a/OpenNest/Forms/AutoNestForm.Designer.cs b/OpenNest/Forms/AutoNestForm.Designer.cs index 6eb102b0..3c4bc054 100644 --- a/OpenNest/Forms/AutoNestForm.Designer.cs +++ b/OpenNest/Forms/AutoNestForm.Designer.cs @@ -25,6 +25,8 @@ namespace OpenNest.Forms this.engineLabel = new System.Windows.Forms.Label(); this.engineComboBox = new System.Windows.Forms.ComboBox(); this.nestOnServerBox = new System.Windows.Forms.CheckBox(); + this.commonLineLabel = new System.Windows.Forms.Label(); + this.commonLineComboBox = new System.Windows.Forms.ComboBox(); this.minRemnantLabel = new System.Windows.Forms.Label(); this.minRemnantBox = new System.Windows.Forms.TextBox(); this.plateOptimizerGroup = new System.Windows.Forms.GroupBox(); @@ -78,6 +80,8 @@ namespace OpenNest.Forms this.platesTab.Controls.Add(this.engineComboBox); this.platesTab.Controls.Add(this.plateOptimizerGroup); this.platesTab.Controls.Add(this.nestOnServerBox); + this.platesTab.Controls.Add(this.commonLineLabel); + this.platesTab.Controls.Add(this.commonLineComboBox); this.platesTab.Location = new System.Drawing.Point(4, 25); this.platesTab.Name = "platesTab"; this.platesTab.Padding = new System.Windows.Forms.Padding(6); @@ -136,6 +140,27 @@ namespace OpenNest.Forms this.nestOnServerBox.UseVisualStyleBackColor = true; this.nestOnServerBox.Visible = false; // + // commonLineLabel + // + this.commonLineLabel.AutoSize = true; + this.commonLineLabel.Location = new System.Drawing.Point(10, 427); + this.commonLineLabel.Name = "commonLineLabel"; + this.commonLineLabel.Size = new System.Drawing.Size(92, 16); + this.commonLineLabel.TabIndex = 5; + this.commonLineLabel.Text = "Common Lines:"; + // + // commonLineComboBox + // + this.commonLineComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.commonLineComboBox.Items.AddRange(new object[] { + "Off", + "When it adds parts", + "Whenever available"}); + this.commonLineComboBox.Location = new System.Drawing.Point(110, 424); + this.commonLineComboBox.Name = "commonLineComboBox"; + this.commonLineComboBox.Size = new System.Drawing.Size(188, 24); + this.commonLineComboBox.TabIndex = 6; + // // minRemnantLabel // this.minRemnantLabel.AutoSize = true; @@ -318,6 +343,8 @@ namespace OpenNest.Forms private System.Windows.Forms.Button ledgerButton; private System.Windows.Forms.Label ledgerSummaryLabel; private System.Windows.Forms.ToolTip ledgerToolTip; + private System.Windows.Forms.Label commonLineLabel; + private System.Windows.Forms.ComboBox commonLineComboBox; private System.Windows.Forms.Label salvageRateLabel; private System.Windows.Forms.TextBox salvageRateBox; private System.Windows.Forms.Label salvageRatePercentLabel; diff --git a/OpenNest/Forms/AutoNestForm.cs b/OpenNest/Forms/AutoNestForm.cs index 71376bc9..a9fd331c 100644 --- a/OpenNest/Forms/AutoNestForm.cs +++ b/OpenNest/Forms/AutoNestForm.cs @@ -45,10 +45,33 @@ namespace OpenNest.Forms LoadDefaultPlateOptions(); SetPlateOptimizerVisible(false); UpdateLedgerButton(); + CommonLineUsage = nest?.CommonLineUsage ?? CommonLineUsage.Off; + ledgerToolTip.SetToolTip(commonLineComboBox, + "Place parts one kerf apart so they share a cut. Only drawings with \"Allow common line\" " + + "set in Drawing Properties take part. \"When it adds parts\" keeps a common-line layout only " + + "when it places more parts or uses fewer sheets; \"Whenever available\" also takes one that " + + "shortens the cut or uses less material, never at the cost of a part."); partsGrid.DataError += PartsGrid_DataError; } + /// When the run may place parts on common lines; saved to the nest when accepted. + public CommonLineUsage CommonLineUsage + { + get => commonLineComboBox.SelectedIndex switch + { + 1 => CommonLineUsage.WhenItAddsParts, + 2 => CommonLineUsage.Whenever, + _ => CommonLineUsage.Off, + }; + set => commonLineComboBox.SelectedIndex = value switch + { + CommonLineUsage.WhenItAddsParts => 1, + CommonLineUsage.Whenever => 2, + _ => 0, + }; + } + public string EngineName { get { return engineComboBox.SelectedItem as string; } diff --git a/OpenNest/Forms/Commands/AutoNestCommand.cs b/OpenNest/Forms/Commands/AutoNestCommand.cs index ee5297d6..c02c4680 100644 --- a/OpenNest/Forms/Commands/AutoNestCommand.cs +++ b/OpenNest/Forms/Commands/AutoNestCommand.cs @@ -32,6 +32,7 @@ namespace OpenNest.Forms.Commands private List savedOptions; private IReadOnlyList ledgerRows = Array.Empty(); private bool nestOnServer; + private CommonLineUsage commonLineUsage; private AutoNestCommand(IWin32Window owner, EditNestForm target, List items, List plateOptions, string engineName, double salvageRate, double minRemnantSize) @@ -45,6 +46,17 @@ namespace OpenNest.Forms.Commands this.minRemnantSize = minRemnantSize; } + /// + /// The cutting kerf common-line groups are made at: the current plate's for the nest's + /// material and thickness (the machine's cutting defaults). Zero, which makes no groups, + /// when it is unknown. + /// + internal static double CommonLineKerf(EditNestForm target) + { + var kerf = MachineSettings.CuttingParametersFor(target.PlateView.Plate, target.Nest)?.Kerf ?? 0; + return double.IsFinite(kerf) && kerf > 0 ? kerf : 0; + } + /// The engine chosen in the dialog, or the current selection when it named none. public string EngineName { get; } @@ -84,6 +96,7 @@ namespace OpenNest.Forms.Commands savedOptions = savedOptions, ledgerRows = form.OptimizePlateSize ? form.LedgerRows : Array.Empty(), nestOnServer = form.NestOnServer, + commonLineUsage = form.CommonLineUsage, }; } @@ -99,7 +112,11 @@ namespace OpenNest.Forms.Commands NestStockBuilder.FromTemplate(target.PlateView.Plate, plateOptions), new NestJobOptions(maxPlates: 100, salvageRate: plateOptions?.Count > 0 ? salvageRate : 0, - minimumSalvageDimension: minRemnantSize)); + minimumSalvageDimension: minRemnantSize) + { + CommonLineUsage = commonLineUsage, + CommonLineKerf = CommonLineKerf(target), + }); NestPipelineResult result; ServerNestDecision server = null; if (nestOnServer) @@ -117,6 +134,7 @@ namespace OpenNest.Forms.Commands await ReviewAndCommitAsync(owner, target, result, server, applied => { AssignLedgerStock(result, applied); + target.Nest.CommonLineUsage = commonLineUsage; if (plateOptions?.Count > 0) { // Ledger rows and quantities are request-local inventory; the nest keeps diff --git a/OpenNest/Forms/EditDrawingForm.Designer.cs b/OpenNest/Forms/EditDrawingForm.Designer.cs index e5f30354..cbddc3ac 100644 --- a/OpenNest/Forms/EditDrawingForm.Designer.cs +++ b/OpenNest/Forms/EditDrawingForm.Designer.cs @@ -65,6 +65,8 @@ namespace OpenNest.Forms priorityHint = new Label(); cutFirstBox = new CheckBox(); cutFirstHint = new Label(); + allowCommonLineBox = new CheckBox(); + allowCommonLineHint = new Label(); bottomPanel1 = new OpenNest.Controls.BottomPanel(); buttonsPanel = new FlowLayoutPanel(); cancelButton = new Button(); @@ -361,9 +363,15 @@ namespace OpenNest.Forms nestingFields.Controls.Add(cutFirstHint, 1, 3); nestingFields.SetColumnSpan(cutFirstBox, 2); nestingFields.SetColumnSpan(cutFirstHint, 2); + nestingFields.Controls.Add(allowCommonLineBox, 1, 4); + nestingFields.Controls.Add(allowCommonLineHint, 1, 5); + nestingFields.SetColumnSpan(allowCommonLineBox, 2); + nestingFields.SetColumnSpan(allowCommonLineHint, 2); nestingFields.Dock = DockStyle.Fill; nestingFields.Name = "nestingFields"; - nestingFields.RowCount = 4; + nestingFields.RowCount = 6; + nestingFields.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + nestingFields.RowStyles.Add(new RowStyle(SizeType.AutoSize)); nestingFields.RowStyles.Add(new RowStyle(SizeType.AutoSize)); nestingFields.RowStyles.Add(new RowStyle(SizeType.AutoSize)); nestingFields.RowStyles.Add(new RowStyle(SizeType.AutoSize)); @@ -436,6 +444,23 @@ namespace OpenNest.Forms cutFirstHint.Name = "cutFirstHint"; cutFirstHint.Text = "Cut before other parts on the plate"; // + // allowCommonLineBox + // + allowCommonLineBox.AutoSize = true; + allowCommonLineBox.Margin = new Padding(3, 6, 3, 0); + allowCommonLineBox.Name = "allowCommonLineBox"; + allowCommonLineBox.TabIndex = 3; + allowCommonLineBox.Text = "Allow &common line"; + allowCommonLineBox.UseVisualStyleBackColor = true; + // + // allowCommonLineHint + // + allowCommonLineHint.AutoSize = true; + allowCommonLineHint.ForeColor = SystemColors.GrayText; + allowCommonLineHint.Margin = new Padding(20, 0, 3, 4); + allowCommonLineHint.Name = "allowCommonLineHint"; + allowCommonLineHint.Text = "Auto Nest may place parts one kerf apart to share a cut"; + // // bottomPanel1 // bottomPanel1.Controls.Add(buttonsPanel); @@ -487,7 +512,7 @@ namespace OpenNest.Forms AutoScaleDimensions = new SizeF(7F, 15F); AutoScaleMode = AutoScaleMode.Font; CancelButton = cancelButton; - ClientSize = new Size(640, 430); + ClientSize = new Size(640, 470); Controls.Add(layout); Controls.Add(bottomPanel1); Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); @@ -558,6 +583,8 @@ namespace OpenNest.Forms private Label priorityHint; private CheckBox cutFirstBox; private Label cutFirstHint; + private CheckBox allowCommonLineBox; + private Label allowCommonLineHint; private Controls.BottomPanel bottomPanel1; private FlowLayoutPanel buttonsPanel; private Button cancelButton; diff --git a/OpenNest/Forms/EditDrawingForm.cs b/OpenNest/Forms/EditDrawingForm.cs index fc27a6aa..e2f301df 100644 --- a/OpenNest/Forms/EditDrawingForm.cs +++ b/OpenNest/Forms/EditDrawingForm.cs @@ -48,6 +48,12 @@ namespace OpenNest.Forms set { cutFirstBox.Checked = value; } } + private bool AllowCommonLine + { + get { return allowCommonLineBox.Checked; } + set { allowCommonLineBox.Checked = value; } + } + private Image DrawingImage { get { return pictureBox1.Image; } @@ -151,6 +157,7 @@ namespace OpenNest.Forms Quantity = drawing.Quantity.Required; Priority = drawing.Priority; CutFirst = drawing.CutFirst; + AllowCommonLine = drawing.AllowCommonLine; Text = string.IsNullOrEmpty(drawing.Name) ? "Drawing Properties" : $"Drawing Properties - {drawing.Name}"; @@ -166,6 +173,7 @@ namespace OpenNest.Forms drawing.Quantity.Required = Quantity; drawing.Priority = Priority; drawing.CutFirst = CutFirst; + drawing.AllowCommonLine = AllowCommonLine; if (IsAutomaticColorRequested) { if (!drawing.HasCustomColor) diff --git a/OpenNest/Forms/MainForm.Tools.cs b/OpenNest/Forms/MainForm.Tools.cs index 4ca09313..f35756bc 100644 --- a/OpenNest/Forms/MainForm.Tools.cs +++ b/OpenNest/Forms/MainForm.Tools.cs @@ -243,8 +243,9 @@ namespace OpenNest.Forms if (activeForm == null) return; - MachineSettings.SaveNestDefaults(this, NestDefaults.FromPlate(activeForm.Nest.Units, activeForm.PlateView.Plate), - activeForm.Nest.Machine); + // A plate holds no common-line usage, so the saved defaults keep theirs. + MachineSettings.SaveNestDefaults(this, NestDefaults.FromPlate(activeForm.Nest.Units, activeForm.PlateView.Plate, + MachineSettings.LoadSavedNestDefaults(out _)), activeForm.Nest.Machine); } private void MachineConfig_Click(object sender, EventArgs e) diff --git a/OpenNest/Forms/NestDefaultsForm.Designer.cs b/OpenNest/Forms/NestDefaultsForm.Designer.cs index fb3fb3ae..6135893a 100644 --- a/OpenNest/Forms/NestDefaultsForm.Designer.cs +++ b/OpenNest/Forms/NestDefaultsForm.Designer.cs @@ -52,6 +52,9 @@ namespace OpenNest.Forms this.edgePlateLabel = new System.Windows.Forms.Label(); this.edgeRightBox = new OpenNest.Controls.NumericUpDown(); this.edgeBottomBox = new OpenNest.Controls.NumericUpDown(); + this.nestingGroup = new System.Windows.Forms.GroupBox(); + this.commonLineLabel = new System.Windows.Forms.Label(); + this.commonLineCombo = new System.Windows.Forms.ComboBox(); this.sourceLabel = new System.Windows.Forms.Label(); this.saveButton = new System.Windows.Forms.Button(); this.cancelButton = new System.Windows.Forms.Button(); @@ -64,6 +67,7 @@ namespace OpenNest.Forms ((System.ComponentModel.ISupportInitialize)(this.edgeRightBox)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.edgeBottomBox)).BeginInit(); this.generalGroup.SuspendLayout(); + this.nestingGroup.SuspendLayout(); this.plateGroup.SuspendLayout(); this.quadrantGroup.SuspendLayout(); this.spacingGroup.SuspendLayout(); @@ -232,6 +236,39 @@ namespace OpenNest.Forms this.spacingGroup.TabStop = false; this.spacingGroup.Text = "Spacing"; // + // nestingGroup + // + this.nestingGroup.Controls.Add(this.commonLineLabel); + this.nestingGroup.Controls.Add(this.commonLineCombo); + this.nestingGroup.Location = new System.Drawing.Point(12, 352); + this.nestingGroup.Name = "nestingGroup"; + this.nestingGroup.Size = new System.Drawing.Size(576, 58); + this.nestingGroup.TabIndex = 4; + this.nestingGroup.TabStop = false; + this.nestingGroup.Text = "Auto Nest"; + // + // commonLineLabel + // + this.commonLineLabel.AutoSize = true; + this.commonLineLabel.Location = new System.Drawing.Point(12, 27); + this.commonLineLabel.Name = "commonLineLabel"; + this.commonLineLabel.Size = new System.Drawing.Size(88, 15); + this.commonLineLabel.TabIndex = 0; + this.commonLineLabel.Text = "Common lines:"; + // + // commonLineCombo + // + this.commonLineCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.commonLineCombo.Items.AddRange(new object[] { + "Off", + "When it adds parts", + "Whenever available"}); + this.commonLineCombo.Location = new System.Drawing.Point(110, 23); + this.commonLineCombo.Name = "commonLineCombo"; + this.commonLineCombo.Size = new System.Drawing.Size(180, 23); + this.commonLineCombo.TabIndex = 1; + this.toolTip.SetToolTip(this.commonLineCombo, "The common-line usage new nests start with. Only drawings with \"Allow common line\" set in Drawing Properties take part."); + // // partSpacingLabel // this.partSpacingLabel.AutoSize = true; @@ -319,29 +356,29 @@ namespace OpenNest.Forms // sourceLabel // this.sourceLabel.ForeColor = System.Drawing.SystemColors.GrayText; - this.sourceLabel.Location = new System.Drawing.Point(12, 356); + this.sourceLabel.Location = new System.Drawing.Point(12, 422); this.sourceLabel.Name = "sourceLabel"; this.sourceLabel.Size = new System.Drawing.Size(380, 28); - this.sourceLabel.TabIndex = 4; + this.sourceLabel.TabIndex = 5; this.sourceLabel.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; // // saveButton // this.saveButton.DialogResult = System.Windows.Forms.DialogResult.OK; - this.saveButton.Location = new System.Drawing.Point(402, 356); + this.saveButton.Location = new System.Drawing.Point(402, 422); this.saveButton.Name = "saveButton"; this.saveButton.Size = new System.Drawing.Size(90, 28); - this.saveButton.TabIndex = 5; + this.saveButton.TabIndex = 6; this.saveButton.Text = "Save"; this.saveButton.UseVisualStyleBackColor = true; // // cancelButton // this.cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel; - this.cancelButton.Location = new System.Drawing.Point(498, 356); + this.cancelButton.Location = new System.Drawing.Point(498, 422); this.cancelButton.Name = "cancelButton"; this.cancelButton.Size = new System.Drawing.Size(90, 28); - this.cancelButton.TabIndex = 6; + this.cancelButton.TabIndex = 7; this.cancelButton.Text = "Cancel"; this.cancelButton.UseVisualStyleBackColor = true; // @@ -350,11 +387,12 @@ namespace OpenNest.Forms this.AcceptButton = this.saveButton; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None; this.CancelButton = this.cancelButton; - this.ClientSize = new System.Drawing.Size(600, 396); + this.ClientSize = new System.Drawing.Size(600, 462); this.Controls.Add(this.generalGroup); this.Controls.Add(this.plateGroup); this.Controls.Add(this.quadrantGroup); this.Controls.Add(this.spacingGroup); + this.Controls.Add(this.nestingGroup); this.Controls.Add(this.sourceLabel); this.Controls.Add(this.saveButton); this.Controls.Add(this.cancelButton); @@ -380,11 +418,16 @@ namespace OpenNest.Forms this.quadrantGroup.ResumeLayout(false); this.spacingGroup.ResumeLayout(false); this.spacingGroup.PerformLayout(); + this.nestingGroup.ResumeLayout(false); + this.nestingGroup.PerformLayout(); this.ResumeLayout(false); } #endregion + private System.Windows.Forms.GroupBox nestingGroup; + private System.Windows.Forms.Label commonLineLabel; + private System.Windows.Forms.ComboBox commonLineCombo; private System.Windows.Forms.GroupBox generalGroup; private System.Windows.Forms.Label machineLabel; private System.Windows.Forms.ComboBox machineCombo; diff --git a/OpenNest/Forms/NestDefaultsForm.cs b/OpenNest/Forms/NestDefaultsForm.cs index 4f8d91a0..8c9c4156 100644 --- a/OpenNest/Forms/NestDefaultsForm.cs +++ b/OpenNest/Forms/NestDefaultsForm.cs @@ -38,11 +38,12 @@ namespace OpenNest.Forms shownUnits = defaults.Units; (defaults.Units == Units.Millimeters ? millimetersRadio : inchesRadio).Checked = true; ShowValues(defaults); + CommonLineUsage = defaults.CommonLineUsage; sourceLabel.Text = source.Text; saveButton.Enabled = !source.ReadOnly; // Nothing typed here could be saved, so show the values without inviting edits. - foreach (var group in new Control[] { generalGroup, plateGroup, quadrantGroup, spacingGroup }) + foreach (var group in new Control[] { generalGroup, plateGroup, quadrantGroup, spacingGroup, nestingGroup }) group.Enabled = !source.ReadOnly; } @@ -50,6 +51,23 @@ namespace OpenNest.Forms public Units Units => millimetersRadio.Checked ? Units.Millimeters : Units.Inches; + /// The common-line usage new nests start with. + public CommonLineUsage CommonLineUsage + { + get => commonLineCombo.SelectedIndex switch + { + 1 => CommonLineUsage.WhenItAddsParts, + 2 => CommonLineUsage.Whenever, + _ => CommonLineUsage.Off, + }; + set => commonLineCombo.SelectedIndex = value switch + { + CommonLineUsage.WhenItAddsParts => 1, + CommonLineUsage.Whenever => 2, + _ => 0, + }; + } + public NestDefaults GetDefaults() => ReadValues(Units); private NestDefaults ReadValues(Units units) @@ -68,6 +86,7 @@ namespace OpenNest.Forms ), // With no machines on file there is nothing to choose, so keep the saved choice. MachineId = (machineCombo.SelectedItem as MachineChoice)?.Machine.Id ?? savedMachineId, + CommonLineUsage = CommonLineUsage, }; } diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index bcec6233..29f53c6e 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -6,6 +6,7 @@ 2. Resolve the selected name through `NestingEngineRegistry` (built-ins and plug-ins use the same path). 3. Bundle parts that fit other parts' enclosed cutouts into their frames ([filling cutouts](nesting-engines.md#filling-cutouts)); a refused or less complete bundled trial is replaced by the ordinary solve. 4. Run the engine without mutating caller drawings or plates. + When the job's common-line level is on, solve once more with eligible parts offered as kerf-gap pairs and keep that layout only by the level's ranking ([automatic common-line placement](common-line-cutting.md#automatic-placement)). 5. Check returned placements with the independent `NestLayoutCheck` used by the benchmark (for bundles, again after expanding them to physical frames and inserts). 6. Consolidate exact ordered nonempty layouts only when the entire result is valid, then bind each layout to the caller's drawing references; the caller decides whether to commit it. diff --git a/docs/common-line-cutting.md b/docs/common-line-cutting.md index 37d8e88b..09f24861 100644 --- a/docs/common-line-cutting.md +++ b/docs/common-line-cutting.md @@ -97,13 +97,62 @@ head on the second. Changing the setting after planning makes the plan stale. - Moving one member moves only that part and breaks the group. Rejoin, or remove the group. - Join, Remove and Cut Common Lines Twice are undoable like any other edit. +## Automatic placement + +Auto Nest, the server's nesting worker and Fill Area can place parts on common lines themselves. + +- **Who may pair.** Only drawings with **Allow common line** set in Drawing Properties + (`Drawing.AllowCommonLine`, off by default: a shared edge is cut from the centerline, which some + customers' edge-quality requirements do not accept). A drawing pairs only with copies of itself. +- **The level** is the nest's `Nest.CommonLineUsage`, chosen in the Auto Nest dialog (saved with + the nest) and seeded for new nests from the nest defaults (`defaults.json`, Tools > Nest + Defaults): + - **Off** (the default): engines and fills run exactly as without common lines; the pipeline + asks the engine nothing more. + - **When it adds parts:** a common-line layout is kept only when it places more parts than the + best layout without common lines, or (whole jobs) the same parts on fewer sheets. A tie keeps + the layout without common lines. + - **Whenever available:** part count still ranks first and sheets second, so common lines never + cost a part or a sheet. With both equal, the common-line layout is kept when it is better on + one of material (a lower salvage-credited cost, then a larger compactness offcut: a shorter + used length or a larger remnant) and cut length (perimeters less each shared span once), and + no worse on the other. +- **The kerf** is the plate's cutting kerf for the nest's material and thickness (the machine's, + see [machine cutting defaults](machine-cutting-defaults.md)). With no kerf, or a kerf not + smaller than the part spacing, nothing pairs. +- **How.** `CommonLinePair` builds a compound of two copies sharing the part's longest straight + edge (the second turned half a turn, so that edge faces its own copy one kerf apart; shorter + edges are tried when the turned copy would come closer than the kerf elsewhere). The compound's + outline is both perimeters (arcs circumscribed) and the kerf between them, without holes. The + pair needs a part that may turn half a turn: automatic rotation, or a rotation limit that allows + the 180° equivalent. +- **Whole jobs** (`NestPipeline`, every engine, built-in or plug-in): after the ordinary solve, + `CommonLinePipelinePrepass` offers each eligible part as pairs plus its odd copy (all copies + paired, then about half, so rows that fit an odd count are tried too) and solves again. Placed + pairs expand into their two parts and one `NestJobCommonLine` group each on the result sheet + (`NestJobPlateResult.CommonLines`: placement positions and kerf). The expanded layout is checked + by `NestLayoutCheck` again, and `CommonLineRanking` chooses at the job's level + (`NestJobOptions.CommonLineUsage`, `CommonLineKerf`, `NestJobPart.AllowCommonLine`). Committing + adds the groups to `Plate.CommonLines`. Engines themselves are unchanged, and `FillScore` and + every engine comparer still rank their own layouts. +- **Fill Area** (`PlateFillService.FillItem` with a level and kerf) also fills the area with + pairs and keeps that fill when it places more parts, or, at "Whenever available", as many and + the strategy's own ranking does not prefer the plain fill. Other interactive fills, the console + and MCP fills do not pair. +- **Benchmark:** `--common-lines off|when-it-adds-parts|whenever --kerf ` runs each job through + the pipeline with every part allowed to pair; `off` is the baseline to compare the levels with + (see [engine benchmarking](performance/engine-benchmarking.md)). + ## Checks - **Part spacing** (the pre-post review and the desktop's live label): any two members of one group are held to the group's kerf instead of the plate's part spacing. Everything else, - including spacing to non-members and the plate edge, is unchanged. The engines' layout - certification (`NestLayoutCheck`) knows nothing of groups. Engines never make them, and a plate - with groups is not an engine result. + including spacing to non-members and the plate edge, is unchanged. +- **Engine results** (`NestLayoutCheck`, the benchmark's and the pipeline's validation): the + exemption comes only from a result sheet's explicit group list, never from proximity. A group + must be allowed by the job (a level on, the job's kerf, members whose parts allow common lines, + each placement in one group at most), smaller than the part spacing, and intact + (`CommonLineGeometry.Check`). - **Common lines** (pre-post, a warning that needs consent like any other): - a member that no longer shares a span one kerf from another member, or a group split in two; - a group whose kerf differs from the plate's cutting parameters' kerf (when the plate has @@ -133,6 +182,3 @@ back to cutting the line twice. Set the plate to cut its lines twice and plan ag See [nest file format](nest-file-format.md#common-lines). -## Planned next - -Automatic common-line placement in fills, after the machine trial above. diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 4c391678..16e43ae0 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -119,6 +119,15 @@ nonfinite or nonpositive kerf. Spans are not saved; they are derived from the pl Versions before it ignore the array, so they load the parts one kerf apart and report the pairs as part-spacing findings: never a silently different cut. +The nest's optional `commonLineUsage` (`Nest.CommonLineUsage`) is `"whenItAddsParts"` or +`"whenever"`: when Auto Nest and Fill Area may place parts on common lines themselves (see +[automatic placement](common-line-cutting.md#automatic-placement)). It is omitted when off, the +default; an unknown value reads as off. A drawing's optional `allowCommonLine: true` +(`Drawing.AllowCommonLine`) lets its parts take part; it is omitted when false. Older readers +ignore both, so they nest without common lines. Drawing packages leave `allowCommonLine` out of +their canonical content, like the other nesting settings, so it never changes a package hash +(and a library drawing added to a nest starts with it off). + A plate's optional `cutCommonLinesTwice` (`Plate.CutCommonLinesTwice`) is `true` when its common lines are cut twice; it is omitted when false, the default, in which Plan Cutting cuts each shared span once. A program planned that way saves its shared cuts as motions tagged @@ -342,7 +351,8 @@ the server's library record owns are written as their defaults, so editing them changes the hash: - nest and drawing `name` (blank), drawing `customer`, material `name` and `grade` (blank); -- `color`, `customColor`, `priority`, `nestPriority`, `cutFirst` and `constraints` (omitted); +- `color`, `customColor`, `priority`, `nestPriority`, `cutFirst`, `allowCommonLine` and + `constraints` (omitted); - `quantity.required` (zero) and `library` (omitted). Adding a library drawing to a nest ignores whatever a package holds for these fields diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 55f231fd..36abf8c5 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -540,6 +540,18 @@ mixed sheet sizes. Every whole-job engine chooses among the offered stock sizes. Gpt6Astra and Qwen38FlashNext are no longer shipped and have no alias. A saved selection of either falls back to the default engine with the usual status-bar warning. +## Common lines + +Engines do not know about common lines. When the job's `NestJobOptions.CommonLineUsage` is on, +`NestPipeline` solves once more with each part that allows it offered as kerf-gap pairs (a +compound part), expands the placed pairs into parts plus explicit `NestJobCommonLine` groups and +keeps the trial only by the level's ranking; see +[automatic placement](common-line-cutting.md#automatic-placement). Off asks the engine exactly what +it asked before. An engine or plug-in may return groups itself on `NestJobPlateResult.CommonLines`; +`NestLayoutCheck` holds members to the kerf only through that list and only when the job allows +common lines. Benchmark a level with `--common-lines --kerf ` against +`--common-lines off`, reporting placed parts and absolute nested area. + ## Changing an engine Use the [engine benchmarking guide](performance/engine-benchmarking.md) for the command, candidate-sheet controls, and comparison procedure. diff --git a/docs/performance/engine-benchmarking.md b/docs/performance/engine-benchmarking.md index a3876539..d119cb36 100644 --- a/docs/performance/engine-benchmarking.md +++ b/docs/performance/engine-benchmarking.md @@ -9,4 +9,6 @@ dotnet run --project OpenNest.Benchmark -- ./benchmark-jobs \ Supply `--sheet-sizes` for an independent candidate pool; otherwise a saved nest offers only its original sheet sizes, which can bias comparisons. A saved nest's first plate also supplies spacing, quadrant and clamps: its machine clamp rail is resolved for every candidate size, as desktop Auto Nest does, and its baseline layout is checked against the clamps it was made with. Manifests have no clamps. A manifest part rotates freely (automatic rotation: any angle) unless `"allowRotation": false` locks it at 0°, or `"rotations"` lists its only angles in degrees: `[0]`, `[0, 180]` or `[0, 90, 180, 270]`. A manifest's `"offcutCut": "horizontal"` (or `"vertical"`) sets `NestJobOptions.OffcutCut`, so only full-width (or full-length) edge strips count as offcut; use it to match a benchmark that scores used sheet height (or length). A saved nest records its salvage rate, but salvage credit applies only with a positive `--min-salvage-dimension`. Without it, every complete one-sheet layout costs the same, as in desktop Auto Nest on a single plate size. The default `--parallel 3` runs solves concurrently and confounds per-engine timing; use `--parallel 1` for timing comparisons. `--progress` logs each solve's start, engine progress, and finish. `--time-limit ` stops each solve at that wall-clock limit and scores the best valid plan the engine had reported (see [time limits](../nesting-engines.md#time-limits)); a solve with none counts as timed out. Without a limit the harness abandons a solve after five minutes; with one it waits for the limit plus a minute, so the engine's limit always fires first. Retain input hashes, exact commands, validity, fulfillment, costs, and raw results outside source control when comparing revisions; see [fill performance verification](fill-verification.md). +`--common-lines off|when-it-adds-parts|whenever --kerf ` solves each job through `NestPipeline` (as desktop Auto Nest does) with every part allowed to share a [common line](../common-line-cutting.md#automatic-placement) at that level; compare a level with `--common-lines off`, not with a run without the option, which calls the engine directly. Report placed parts and absolute nested area, not only utilization. + Custom engines are DLLs implementing `INestingEngine` with a public parameterless constructor, loaded from `Engines/` beside the benchmark executable. See [nesting engines](../nesting-engines.md) for built-ins and engine-change rules. Run `dotnet run --project OpenNest.Benchmark -- --help` for all options. diff --git a/docs/server-nesting.md b/docs/server-nesting.md index 2552ea3c..f45d657d 100644 --- a/docs/server-nesting.md +++ b/docs/server-nesting.md @@ -104,7 +104,9 @@ package and result on the server. geometry fingerprint (a SHA-256 of the exact motions the freshness check compares, which a later Open checks the nest's drawings against; workers ignore it), every stock entry as `NestStockJson` (size, quantity, spacing, quadrant, cost, clamp zones in plate - coordinates, clamp rail, irregular outline), and the job options. A server job never sets + coordinates, clamp rail, irregular outline), and the job options, including the common-line + level and kerf. A job that uses common lines is written as version 3, so a worker without them + refuses it instead of solving it differently from the desktop's replay. A server job never sets `SearchUntilCancelled` and never runs under a `DeadlineNestingEngine`: it has no time limit, so a throttled worker finds the same nest a fast one does, later. - `drawings.nest`: a throwaway nest of exactly the items' drawings, written by `NestWriter`. @@ -115,7 +117,10 @@ package and result on the server. **The result** is a `NestSolveTranscript`: every engine solve the pipeline made on the worker, in order, as what was asked (part and stock ids with quantities) and the raw `NestJobResult` returned. Poses keep their IEEE values, nonfinite ones included. No bound plates or -validation travel back. +validation travel back. A [common-line](common-line-cutting.md#automatic-placement) trial is one +more recorded solve whose answer places pair proxies; the desktop's replay expands them into the +same parts and groups the worker made, so the groups travel as those recorded answers (a sheet's +own `commonLines`, written only when an engine returns groups itself, travel too). **Live progress** travels separately while the job runs, both as versioned JSON the server never opens: `RemoteNestProgress` (the stage of the solve in progress: `NestJobStage`, stock, @@ -124,14 +129,14 @@ raw placements of `NestJobStage.PlanImproved`). The desktop rebuilds the request its own drawings (the same part ids the worker solved) and draws the plan on detached drawings, exactly as a local run's `PlanSheets`; a bundled cutout trial's frame proxies are bundled once on the desktop the first time a plan names one. Like a local plan it is -unvalidated and never kept. +unvalidated and never kept. A common-line trial's pair proxies are rebuilt the same way. ## Replay on the desktop `RemoteNestSubmission.Complete` first checks that each item's drawing still has the geometry it had when the job was sent; a changed drawing refuses the result. It then runs `NestPipeline.Run` with a `ReplayNestingEngine` that answers each solve with the next recorded -result, after checking it is asked the same question. Cutout bundling, the ordinary-solve +result, after checking it is asked the same question. Cutout bundling, common-line trials, the ordinary-solve comparison and clamp-removal recovery therefore happen here exactly as on the worker, and the result is validated by `NestLayoutCheck` and bound to the caller's own drawings like a local run. A recorded sheet that matches one of the request's stock entries uses that entry itself. diff --git a/docs/shared-settings.md b/docs/shared-settings.md index e313ae0a..3c584e79 100644 --- a/docs/shared-settings.md +++ b/docs/shared-settings.md @@ -9,7 +9,7 @@ building nests and posts the same way: | `machine` | machine id | A Tools > Machine Configuration entry: materials, grades, thicknesses, leads, kerf, lead-in rounding, tabs, pierce, cut-offs, motion limits, plate sizes, support grid, clamps | `%APPDATA%\OpenNest\Machines\.json` | | `post` | post type name, e.g. `OpenNest.Posts.CincinnatiCIFiber.CIFiberPostProcessor` | A post's settings, as its JSON file holds them | `%APPDATA%\OpenNest\Posts\.json`, else the shipped `.json` beside the post DLL in `Posts\` until the first save (an update replaces the install folder, so edits are never saved there) | | `clamps` | `default` | Legacy clamp settings, read only to copy them onto a machine once (machines now hold their clamps) | `%APPDATA%\OpenNest\clamps.json` | -| `nest-defaults` | `default` | Tools > Nest Defaults: units, sheet size, quadrant, spacing, default machine | `%APPDATA%\OpenNest\defaults.json` | +| `nest-defaults` | `default` | Tools > Nest Defaults: units, sheet size, quadrant, spacing, default machine, common-line usage | `%APPDATA%\OpenNest\defaults.json` | File mode, the console, MCP and API front ends use only this PC's copies.