mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-06 12:25:43 -05:00
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 <level> --kerf <k>. 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9b2713420
commit
b026bd19d2
61 files changed
+2155
-118
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>--common-lines</c>: solve every job through <see cref="NestPipeline"/> at this usage
|
||||
/// level and kerf, with every part allowed to share a common line.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <level> 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 <value> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// When automatic nesting and fills may place parts one kerf apart on a common line (see
|
||||
/// docs/common-line-cutting.md). Only drawings with <see cref="Drawing.AllowCommonLine"/> take part.
|
||||
/// </summary>
|
||||
public enum CommonLineUsage
|
||||
{
|
||||
/// <summary>Never: engines and fills behave exactly as without common lines.</summary>
|
||||
Off,
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
WhenItAddsParts,
|
||||
|
||||
/// <summary>
|
||||
/// Whenever it shortens the cut or uses less material, never at the cost of a part:
|
||||
/// part count still ranks first.
|
||||
/// </summary>
|
||||
Whenever,
|
||||
}
|
||||
}
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>The names <see cref="CommonLineUsage"/> is saved under (nest files and defaults.json).</summary>
|
||||
public static class CommonLineUsageNames
|
||||
{
|
||||
/// <summary>The saved name, or null for <see cref="CommonLineUsage.Off"/> (omitted).</summary>
|
||||
public static string Format(CommonLineUsage usage) => usage switch
|
||||
{
|
||||
CommonLineUsage.WhenItAddsParts => "whenItAddsParts",
|
||||
CommonLineUsage.Whenever => "whenever",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Reads a saved name case-insensitively; anything else is off.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,14 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public bool CutFirst { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool AllowCommonLine { get; set; }
|
||||
|
||||
public NestConstraints Constraints { get; set; }
|
||||
|
||||
public SourceInfo Source { get; set; }
|
||||
|
||||
@@ -112,6 +112,12 @@ namespace OpenNest
|
||||
|
||||
public double SalvageRate { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// When Auto Nest and the fills may place parts on common lines (only drawings with
|
||||
/// <see cref="Drawing.AllowCommonLine"/>). Off unless the nest or the new-nest defaults say so.
|
||||
/// </summary>
|
||||
public CommonLineUsage CommonLineUsage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The smallest side, in model units, of an offcut credited as salvage or reported as the
|
||||
/// plate's expected remnant (<see cref="PlateRemnant.Expected"/>). Auto Nest records the
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -52,6 +52,12 @@ public sealed class NestDefaults
|
||||
/// </summary>
|
||||
public Guid? MachineId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The common-line usage new nests start with (<see cref="Nest.CommonLineUsage"/>). Off by
|
||||
/// default; saved as <c>commonLineUsage</c> and omitted when off.
|
||||
/// </summary>
|
||||
public CommonLineUsage CommonLineUsage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="FromPlate(Units, Plate)"/>, keeping the settings a plate does not hold (the
|
||||
/// common-line usage) from <paramref name="saved"/>, the defaults being replaced, so saving a
|
||||
/// plate as the defaults never resets them.
|
||||
/// </summary>
|
||||
public static NestDefaults FromPlate(Units units, Plate plate, NestDefaults saved)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(saved);
|
||||
var defaults = FromPlate(units, plate);
|
||||
defaults.CommonLineUsage = saved.CommonLineUsage;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// These defaults in <paramref name="units"/>: 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static double CommonLineKerf(Plate template)
|
||||
{
|
||||
var kerf = template.CuttingParameters?.Kerf ?? 0;
|
||||
return double.IsFinite(kerf) && kerf > 0 ? kerf : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class CommonLineAutoPlacementTests
|
||||
{
|
||||
private const double Kerf = 0.012;
|
||||
|
||||
/// <summary>A 6 x 12.44 plate with 0.38 chamfers on all four corners, like N26-295's PT42.</summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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, (string, int)> { [drawing] = ("pt", int.MaxValue) });
|
||||
Assert.Empty(violations);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public class NestJobDocumentPriorityTests
|
||||
[Fact]
|
||||
public void UnknownVersionIsRefused()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => Document(NestJobDocument.CurrentVersion + 1, 0).ToRequest(Drawings()));
|
||||
Assert.Throws<FormatException>(() => Document(NestJobDocument.CommonLineVersion + 1, 0).ToRequest(Drawings()));
|
||||
}
|
||||
|
||||
private static Drawing[] Drawings() => [new Drawing("a", TestDrawingFactory.Rectangle(10, 20))];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/// <summary>Available stock is explicit; the legacy plate repeat count is not inventory.</summary>
|
||||
@@ -98,7 +100,11 @@ public static class DrawingJobMapper
|
||||
/// geometry in a later job, engine instance or Auto Nest run does not search again.</summary>
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>The sheet's common-line groups over <paramref name="parts"/>, which
|
||||
/// <see cref="Bind"/> returned for that sheet (same order).</summary>
|
||||
public static IReadOnlyList<CommonLineGroup> BindCommonLines(NestJobPlateResult sheet, IReadOnlyList<Part> parts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sheet);
|
||||
ArgumentNullException.ThrowIfNull(parts);
|
||||
var groups = new List<CommonLineGroup>(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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a sheet's explicit common-line groups to <paramref name="plate"/>; members are the
|
||||
/// sheet's placements by position in <paramref name="parts"/>. 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.
|
||||
/// </summary>
|
||||
internal static void AddCommonLines(Plate plate, IReadOnlyList<Part> parts,
|
||||
IReadOnlyList<NestJobCommonLine> groups)
|
||||
{
|
||||
if (groups.Count == 0)
|
||||
return;
|
||||
var taken = new HashSet<int>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<see cref="Geometry"/>), and the pipeline expands it back into the two copies
|
||||
/// and their common-line group.
|
||||
/// </summary>
|
||||
internal sealed class CommonLinePair
|
||||
{
|
||||
/// <summary>Chord tolerance of the compound outline; arcs are circumscribed, so it only
|
||||
/// controls vertex count, never makes the compound smaller than its copies.</summary>
|
||||
private const double FlattenTolerance = 0.002;
|
||||
|
||||
/// <summary>How far the bridge over the kerf reaches into each copy's material, so the union
|
||||
/// joins the copies into one outline.</summary>
|
||||
private const double BridgeOverlap = 1e-4;
|
||||
|
||||
private CommonLinePair(PartGeometrySnapshot geometry, double secondX, double secondY, double sharedLength)
|
||||
{
|
||||
Geometry = geometry;
|
||||
SecondX = secondX;
|
||||
SecondY = secondY;
|
||||
SharedLength = sharedLength;
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public PartGeometrySnapshot Geometry { get; }
|
||||
|
||||
/// <summary>Where the second copy sits in the compound's frame; it is turned half a turn.</summary>
|
||||
public double SecondX { get; }
|
||||
|
||||
public double SecondY { get; }
|
||||
|
||||
/// <summary>Length of the shared span (the cut saved once per pair when cut once).</summary>
|
||||
public double SharedLength { get; }
|
||||
|
||||
/// <summary>The second copy's rotation relative to the compound.</summary>
|
||||
public const double SecondRotation = System.Math.PI;
|
||||
|
||||
/// <summary>
|
||||
/// The pair for <paramref name="geometry"/> at <paramref name="kerf"/>, or null when the part
|
||||
/// has no straight edge of at least <see cref="CommonLineGeometry.MinSharedLength"/>, or when
|
||||
/// the half-turned copy would come closer than the kerf anywhere else.
|
||||
/// </summary>
|
||||
public static CommonLinePair? Build(PartGeometrySnapshot geometry, double kerf)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(geometry);
|
||||
if (!double.IsFinite(kerf) || kerf <= 0)
|
||||
return null;
|
||||
Drawing drawing;
|
||||
Part first;
|
||||
IReadOnlyList<CommonLineGeometry.Edge> 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;
|
||||
}
|
||||
|
||||
/// <summary>The union of both perimeters (arcs circumscribed) and the kerf between them, or
|
||||
/// null when it is not one outline.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.CommonLines;
|
||||
|
||||
/// <summary>
|
||||
/// The common-line trial <see cref="NestPipeline"/> runs beside the ordinary solve when the
|
||||
/// job's <see cref="NestJobOptions.CommonLineUsage"/> is on: every part that allows common lines
|
||||
/// is offered as kerf-gap pairs (<see cref="CommonLinePair"/>) plus its odd copy, so any engine
|
||||
/// places them without knowing about common lines. Placed pairs expand into their two copies and
|
||||
/// one explicit <see cref="NestJobCommonLine"/> group each; the expanded layout is validated again.
|
||||
/// </summary>
|
||||
internal sealed class CommonLinePipelinePrepass : IPipelinePrepass
|
||||
{
|
||||
/// <summary>Every pair proxy's part id starts with this; ordinary parts never do.</summary>
|
||||
internal const string ProxyPrefix = "__common-line-pair-";
|
||||
|
||||
/// <summary>How many of a part's copies a trial offers as pairs.</summary>
|
||||
internal enum Share
|
||||
{
|
||||
/// <summary>As many pairs as the quantity allows.</summary>
|
||||
All,
|
||||
|
||||
/// <summary>About half the copies paired, the rest single: rows that fit an odd count.</summary>
|
||||
Half,
|
||||
}
|
||||
|
||||
private sealed record Proxy(string PartId, CommonLinePair Pair);
|
||||
|
||||
private readonly IReadOnlyDictionary<string, Proxy> proxies;
|
||||
private readonly double kerf;
|
||||
|
||||
private CommonLinePipelinePrepass(NestJob engineJob, IReadOnlyDictionary<string, Proxy> proxies, double kerf)
|
||||
{
|
||||
EngineJob = engineJob;
|
||||
this.proxies = proxies;
|
||||
this.kerf = kerf;
|
||||
}
|
||||
|
||||
public NestJob EngineJob { get; }
|
||||
|
||||
public string ReconciliationFailure => "Common-line engine output failed reconciliation";
|
||||
|
||||
/// <summary>True for a job holding pair proxies: a common-line trial's engine job.</summary>
|
||||
internal static bool HasProxies(NestJob job) =>
|
||||
job.Parts.Any(p => p.Id.StartsWith(ProxyPrefix, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<string, Proxy>(StringComparer.Ordinal);
|
||||
var parts = new List<NestJobPart>();
|
||||
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<string, int> ReleasedInserts(NestJobResult engineResult) =>
|
||||
new Dictionary<string, int>(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<NestJobCommonLine>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.CommonLines;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a common-line layout replaces the best layout without common lines (or an
|
||||
/// earlier common-line trial), by the job's <see cref="CommonLineUsage"/>. Both must be valid,
|
||||
/// keepable proposals of the same job; an invalid incumbent always loses to a valid candidate.
|
||||
/// The engines' own ranking (and <c>FillScore</c>) is untouched: this only chooses between the
|
||||
/// layouts they returned.
|
||||
/// </summary>
|
||||
internal static class CommonLineRanking
|
||||
{
|
||||
/// <summary>Differences below this are rounding, never a reason to prefer either layout.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Positive when <paramref name="a"/> uses less material than <paramref name="b"/>:
|
||||
/// a lower net cost (a larger salvage-credited remnant), then the larger compactness offcut
|
||||
/// (a shorter used length).</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Total cut length: every part's cut contours, less each shared span once (a common line is
|
||||
/// cut once). Repeated sheets count each copy.
|
||||
/// </summary>
|
||||
internal static double CutLength(NestPipelineResult result)
|
||||
{
|
||||
var lengths = new Dictionary<Drawing, double>(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<Drawing, double> 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);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
/// Those go only through <see cref="CutoutCirclePacker"/>, 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.</remarks>
|
||||
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
|
||||
|
||||
/// <summary>Reserved insert copies per original requirement whose frame proxy the engine
|
||||
/// did not place; call only on engine output that <see cref="TryExpand"/> accepted.</summary>
|
||||
internal IReadOnlyDictionary<string, int> ReleasedInserts(NestJobResult engineResult)
|
||||
public IReadOnlyDictionary<string, int> 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
|
||||
/// <summary>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.</summary>
|
||||
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<int>());
|
||||
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<string>(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<string>(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);
|
||||
|
||||
@@ -53,7 +53,35 @@ public sealed class NestJobOptions
|
||||
|
||||
/// <summary>A copy of these options with <see cref="SearchUntilCancelled"/> set.</summary>
|
||||
public NestJobOptions WithSearchUntilCancelled(bool value) =>
|
||||
new(PlacementStrategy, MaxPlates, SalvageRate, MinimumSalvageDimension, value, OffcutCut);
|
||||
new(PlacementStrategy, MaxPlates, SalvageRate, MinimumSalvageDimension, value, OffcutCut)
|
||||
{
|
||||
CommonLineUsage = CommonLineUsage,
|
||||
CommonLineKerf = CommonLineKerf,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// When the pipeline may place parts that allow it on common lines, one
|
||||
/// <see cref="CommonLineKerf"/> apart (see docs/common-line-cutting.md). Off by default;
|
||||
/// without a positive kerf it acts as off.
|
||||
/// </summary>
|
||||
public CommonLineUsage CommonLineUsage
|
||||
{
|
||||
get => commonLineUsage;
|
||||
init => commonLineUsage = Enum.IsDefined(value) ? value : throw new ArgumentOutOfRangeException(nameof(CommonLineUsage));
|
||||
}
|
||||
|
||||
private readonly CommonLineUsage commonLineUsage;
|
||||
private readonly double commonLineKerf;
|
||||
|
||||
/// <summary>The cutting kerf for the job's material and thickness, in job units; zero when unknown.</summary>
|
||||
public double CommonLineKerf
|
||||
{
|
||||
get => commonLineKerf;
|
||||
init => commonLineKerf = double.IsFinite(value) && value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(CommonLineKerf));
|
||||
}
|
||||
|
||||
/// <summary>True when common lines may be made: a level is on and the kerf is positive.</summary>
|
||||
public bool UsesCommonLines => CommonLineUsage != CommonLineUsage.Off && CommonLineKerf > 0;
|
||||
|
||||
/// <summary>Fraction of eligible edge-offcut area credited by <see cref="NestJobCost.NetSheetCost"/> (0..1).</summary>
|
||||
public double SalvageRate { get; }
|
||||
|
||||
@@ -42,4 +42,10 @@ public sealed class NestJobPart
|
||||
/// </summary>
|
||||
public int Priority { get; }
|
||||
public RotationPolicy Rotation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The part may share a common line with copies of itself when the job's
|
||||
/// <see cref="NestJobOptions.CommonLineUsage"/> allows it (<c>Drawing.AllowCommonLine</c>).
|
||||
/// </summary>
|
||||
public bool AllowCommonLine { get; init; }
|
||||
}
|
||||
@@ -36,25 +36,69 @@ public sealed record PartFulfillment(string PartId, int Requested, int Placed, i
|
||||
/// <summary>Used counts physical sheets; Remaining is null only for unlimited stock.</summary>
|
||||
public sealed record StockUsage(string StockId, int Used, int? Remaining);
|
||||
|
||||
/// <summary>
|
||||
/// One common-line group on a result sheet: the zero-based positions of its members in the
|
||||
/// sheet's <see cref="NestJobPlateResult.Placements"/> 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).
|
||||
/// </summary>
|
||||
public sealed record NestJobCommonLine
|
||||
{
|
||||
public NestJobCommonLine(double kerf, IEnumerable<int> placements)
|
||||
{
|
||||
Kerf = kerf;
|
||||
Placements = NestJob.Own(placements ?? throw new ArgumentNullException(nameof(placements)));
|
||||
}
|
||||
|
||||
public double Kerf { get; }
|
||||
public IReadOnlyList<int> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One physical sheet, with owned ordered placements and immutable stock/settings snapshot.</summary>
|
||||
public sealed class NestJobPlateResult
|
||||
{
|
||||
public NestJobPlateResult(
|
||||
int plateIndex,
|
||||
NestPlateStock stock,
|
||||
IEnumerable<NestJobPlacement> placements
|
||||
IEnumerable<NestJobPlacement> placements,
|
||||
IEnumerable<NestJobCommonLine> commonLines = null
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stock);
|
||||
PlateIndex = plateIndex;
|
||||
Stock = stock;
|
||||
Placements = NestJob.Own(placements);
|
||||
CommonLines = commonLines == null ? Array.Empty<NestJobCommonLine>() : NestJob.Own(commonLines);
|
||||
}
|
||||
|
||||
public int PlateIndex { get; }
|
||||
public string StockId => Stock.Id;
|
||||
public NestPlateStock Stock { get; }
|
||||
public IReadOnlyList<NestJobPlacement> Placements { get; }
|
||||
|
||||
/// <summary>Common-line groups among <see cref="Placements"/>; empty when there are none.</summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public IReadOnlyList<NestJobCommonLine> CommonLines { get; }
|
||||
|
||||
/// <summary><see cref="CommonLines"/> when serialized: omitted when empty, so a sheet without
|
||||
/// groups serializes exactly as before they existed.</summary>
|
||||
[System.Text.Json.Serialization.JsonPropertyName("CommonLines")]
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<NestJobCommonLine> SerializedCommonLines => CommonLines.Count == 0 ? null : CommonLines;
|
||||
}
|
||||
|
||||
/// <summary>Detached result values in commit/input order; no mutable Drawing, Plate, or NestItem escapes.</summary>
|
||||
|
||||
@@ -35,7 +35,8 @@ public sealed class NestJobResultBuilder
|
||||
/// </summary>
|
||||
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<NestJobCommonLine> 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]++;
|
||||
|
||||
@@ -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<int>();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static void ValidateCommonLinesAgainstJob(NestJob job, NestJobPlateResult sheet,
|
||||
IReadOnlyDictionary<string, NestJobPart> partsById, IReadOnlyDictionary<string, string> displayNames,
|
||||
List<string> 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<int>();
|
||||
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<Part> parts,
|
||||
double spacing,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements,
|
||||
List<string> result
|
||||
List<string> result,
|
||||
IEnumerable<CommonLineGroup> commonLines = null
|
||||
)
|
||||
{
|
||||
// Members of one explicit common-line group are held to the group's kerf instead.
|
||||
var groupOf = new Dictionary<Part, CommonLineGroup>(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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// (<see cref="CommonLineGeometry.Check"/>). A group that does not share is not a common line,
|
||||
/// so its spacing exemption would hide a real violation.
|
||||
/// </summary>
|
||||
private static void ValidateCommonLines(Plate plate, List<Part> parts,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, List<string> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Analytic world-space material bounds; surface marks never bound material.</summary>
|
||||
internal static Box MaterialBounds(Part part) => PlateRemnant.MaterialBounds(part);
|
||||
|
||||
|
||||
@@ -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.</summary>
|
||||
public sealed record ProposedPlate(int PlateIndex, NestPlateStock Stock, IReadOnlyList<Part> Parts)
|
||||
{
|
||||
/// <summary>Common-line groups among <see cref="Parts"/>; committing the plate adds them to
|
||||
/// <see cref="Plate.CommonLines"/>. Empty when the layout has none.</summary>
|
||||
public IReadOnlyList<CommonLineGroup> CommonLines { get; init; } = Array.Empty<CommonLineGroup>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pipeline for an already built job whose part ids map to <paramref name="drawingsByPartId"/>
|
||||
/// (the benchmark builds its own jobs); identical to <see cref="Run(INestingEngine, string, NestPipelineRequest, IProgress{NestJobProgress}, CancellationToken)"/> otherwise.
|
||||
/// </summary>
|
||||
internal static NestPipelineResult Run(INestingEngine engine, string engineName, NestJob job,
|
||||
IReadOnlyDictionary<string, Drawing> drawingsByPartId, IProgress<NestJobProgress> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The common-line trials (see <see cref="CommonLinePipelinePrepass"/>), each solved after
|
||||
/// the ordinary solve and kept only when <see cref="CommonLineRanking"/> prefers it at the
|
||||
/// job's usage level; otherwise the ordinary result stands as it was.
|
||||
/// </summary>
|
||||
private static NestPipelineResult WithCommonLines(INestingEngine engine, string engineName, NestJob job,
|
||||
IReadOnlyDictionary<string, Drawing> drawingsByPartId, NestPipelineResult ordinary,
|
||||
IProgress<NestJobProgress> progress, CancellationToken token)
|
||||
{
|
||||
var best = ordinary;
|
||||
var asked = new HashSet<string>(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<string, Drawing> drawingsByPartId, IProgress<NestJobProgress> 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<string, Drawing> drawingsByPartId,
|
||||
IProgress<NestJobProgress> 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal interface IPipelinePrepass
|
||||
{
|
||||
/// <summary>The job the engine solves.</summary>
|
||||
NestJob EngineJob { get; }
|
||||
|
||||
/// <summary>The violation reported when the engine's answer cannot be expanded.</summary>
|
||||
string ReconciliationFailure { get; }
|
||||
|
||||
/// <summary>Expands an answer to <see cref="EngineJob"/> into one for <paramref name="original"/>.</summary>
|
||||
bool TryExpand(NestJob original, NestJobResult result, CancellationToken token,
|
||||
out NestJobResult? expanded, out string failure);
|
||||
|
||||
/// <summary>Copies per original requirement that an unplaced proxy left unplaced as a frame's insert.</summary>
|
||||
IReadOnlyDictionary<string, int> ReleasedInserts(NestJobResult engineResult);
|
||||
}
|
||||
|
||||
/// <summary>The accounting every pre-pass checks before trusting an engine's answer.</summary>
|
||||
internal static class PipelinePrepassAccounting
|
||||
{
|
||||
/// <summary>
|
||||
/// True when <paramref name="result"/> is self-consistent for <paramref name="engineJob"/>:
|
||||
/// consecutive sheet indices, known stock and parts, unique instance indices within quantity,
|
||||
/// and fulfillment, stock usage and status rows that match the placements.
|
||||
/// </summary>
|
||||
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<int>());
|
||||
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<string>(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<string>(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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A proposed fill: new parts and the common-line groups among them (empty when none).</summary>
|
||||
public sealed record PlateFillProposal(List<Part> Parts, IReadOnlyList<CommonLineGroup> CommonLines);
|
||||
|
||||
public static partial class PlateFillService
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="FillItem(string, Plate, NestItem, Box, IProgress{NestProgress}, CancellationToken)"/>
|
||||
/// 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 (<see cref="CommonLinePair"/>), each expanded into its two parts and a
|
||||
/// group, and the pair fill replaces the plain one when it places more parts, or (at
|
||||
/// <see cref="CommonLineUsage.Whenever"/>) the same number and the strategy's own ranking does
|
||||
/// not prefer the plain fill. Ties at <see cref="CommonLineUsage.WhenItAddsParts"/> keep the
|
||||
/// plain fill. The proposal is not committed: the caller adds the parts, then the groups.
|
||||
/// </summary>
|
||||
public static PlateFillProposal FillItem(
|
||||
string strategy,
|
||||
Plate plate,
|
||||
NestItem item,
|
||||
Box workArea,
|
||||
CommonLineUsage usage,
|
||||
double kerf,
|
||||
IProgress<NestProgress> progress,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
var plain = FillItem(strategy, plate, item, workArea, progress, token);
|
||||
var none = new PlateFillProposal(plain, Array.Empty<CommonLineGroup>());
|
||||
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<Part>(2 * pairs.Count);
|
||||
var groups = new List<CommonLineGroup>(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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
public static class PlateFillService
|
||||
public static partial class PlateFillService
|
||||
{
|
||||
/// <summary>Strategy used when a caller selection names none.</summary>
|
||||
public const string DefaultStrategy = "Lattice";
|
||||
|
||||
@@ -24,6 +24,13 @@ public sealed class NestJobDocument
|
||||
|
||||
private const int LowerFirstPriorityVersion = 1;
|
||||
|
||||
/// <summary>
|
||||
/// A version 2 job that also uses common lines (<see cref="OptionsJson.CommonLineUsage"/> 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.
|
||||
/// </summary>
|
||||
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<Drawing> 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; }
|
||||
|
||||
/// <summary><see cref="NestJobOptions.CommonLineUsage"/>; absent (off) before version 3.</summary>
|
||||
public CommonLineUsage CommonLineUsage { get; set; }
|
||||
|
||||
/// <summary><see cref="NestJobOptions.CommonLineKerf"/>; absent (zero) before version 3.</summary>
|
||||
public double CommonLineKerf { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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<NestJobPlacement> Placements { get; set; } = new();
|
||||
|
||||
/// <summary>Common-line groups an engine returned itself (none of the built-in engines
|
||||
/// do: the pipeline makes pairs around them). Omitted when empty.</summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public List<CommonLineJson> CommonLines { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CommonLineJson
|
||||
{
|
||||
public double Kerf { get; set; }
|
||||
public List<int> Placements { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++)
|
||||
{
|
||||
|
||||
@@ -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<string>());
|
||||
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<string, string> 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();
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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" };
|
||||
|
||||
/// <summary>Packages <paramref name="drawing"/> without changing it.</summary>
|
||||
public static byte[] Write(Drawing drawing, Units units)
|
||||
|
||||
@@ -45,6 +45,13 @@ namespace OpenNest.IO
|
||||
public List<PlateOptionDto> PlateOptions { get; init; } = new();
|
||||
public double SalvageRate { get; init; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// <c>Nest.CommonLineUsage</c>: <c>"whenItAddsParts"</c> or <c>"whenever"</c>. Omitted
|
||||
/// when off; an unknown value reads as off.
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string CommonLineUsage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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; }
|
||||
|
||||
/// <summary>Automatic nesting may place this drawing's parts on common lines
|
||||
/// (<c>Drawing.AllowCommonLine</c>). Omitted when false.</summary>
|
||||
[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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Tests.CommonLines;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>CommonLineLastPlateNestPath</c> in test-config.json to run it.
|
||||
/// </summary>
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<CheckBox>(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<ComboBox>(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.");
|
||||
}
|
||||
@@ -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<MachineConfig>(), ("Saved on this PC.", false));
|
||||
var combo = Control<ComboBox>(form, "commonLineCombo");
|
||||
Assert.Equal(2, combo.SelectedIndex);
|
||||
|
||||
Control<RadioButton>(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<T>(NestDefaultsForm form, string name) where T : Control =>
|
||||
Assert.IsType<T>(form.Controls.Find(name, true).Single());
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Generated
+27
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>When the run may place parts on common lines; saved to the nest when accepted.</summary>
|
||||
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; }
|
||||
|
||||
@@ -32,6 +32,7 @@ namespace OpenNest.Forms.Commands
|
||||
private List<PlateOption> savedOptions;
|
||||
private IReadOnlyList<LedgerStockRow> ledgerRows = Array.Empty<LedgerStockRow>();
|
||||
private bool nestOnServer;
|
||||
private CommonLineUsage commonLineUsage;
|
||||
|
||||
private AutoNestCommand(IWin32Window owner, EditNestForm target, List<NestItem> items,
|
||||
List<PlateOption> plateOptions, string engineName, double salvageRate, double minRemnantSize)
|
||||
@@ -45,6 +46,17 @@ namespace OpenNest.Forms.Commands
|
||||
this.minRemnantSize = minRemnantSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The engine chosen in the dialog, or the current selection when it named none.</summary>
|
||||
public string EngineName { get; }
|
||||
|
||||
@@ -84,6 +96,7 @@ namespace OpenNest.Forms.Commands
|
||||
savedOptions = savedOptions,
|
||||
ledgerRows = form.OptimizePlateSize ? form.LedgerRows : Array.Empty<LedgerStockRow>(),
|
||||
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
|
||||
|
||||
+29
-2
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+50
-7
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>The common-line usage new nests start with.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <k>` 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 <level> --kerf <k>` 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.
|
||||
|
||||
@@ -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 <seconds>` 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 <k>` 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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\<id>.json` |
|
||||
| `post` | post type name, e.g. `OpenNest.Posts.CincinnatiCIFiber.CIFiberPostProcessor` | A post's settings, as its JSON file holds them | `%APPDATA%\OpenNest\Posts\<post>.json`, else the shipped `<post>.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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user