Files
OpenNest/OpenNest.Engine.Tests/Jobs/CommonLineAutoPlacementTests.cs
T
ajandClaude Opus 5.5 b026bd19d2 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>
2026-11-05 19:38:00 -05:00

358 lines
16 KiB
C#

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);
}
}