Author SHA1 Message Date
aj 4954bdf515 ci: run cross-platform suites on pull requests and master 2026-10-01 09:55:16 -04:00
28 changed files with 100 additions and 2201 deletions
-5
View File
@@ -23,10 +23,5 @@ jobs:
run: dotnet test OpenNest.Tests/OpenNest.Tests.csproj run: dotnet test OpenNest.Tests/OpenNest.Tests.csproj
- name: Run OpenNest.Engine.Tests - name: Run OpenNest.Engine.Tests
run: dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj run: dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
- name: Check six synthetic Irregular nests
timeout-minutes: 6
run: |
python3 scripts/test_check_synthetic_nests.py -v
python3 scripts/check-synthetic-nests.py --parallel 2
- name: Run OpenNest.IO.Tests - name: Run OpenNest.IO.Tests
run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
@@ -32,20 +32,6 @@ public sealed class OverlapGeometryStamp
return true; return true;
} }
/// <summary>
/// Conservative display reuse: only unchanged ordered slots on the same plate survive.
/// Index-changing edits may hide extra pairs, but cannot attach old geometry to new parts.
/// </summary>
internal bool[] UnchangedSlots(Plate current)
{
var matches = new bool[entries.Length];
if (!ReferenceEquals(plate, current))
return matches;
for (var i = 0; i < entries.Length && i < current.Parts.Count; i++)
matches[i] = entries[i].Matches(current.Parts[i]);
return matches;
}
private readonly struct Entry private readonly struct Entry
{ {
private readonly Part part; private readonly Part part;
@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Diagnostics; namespace OpenNest.Diagnostics;
@@ -11,16 +9,12 @@ public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Fail
public sealed class OverlapReportState public sealed class OverlapReportState
{ {
private OverlapGeometryStamp stamp; private OverlapGeometryStamp stamp;
private OverlapGeometryStamp displayStamp;
private OverlapGeometryStamp observedDisplayStamp;
private int uncheckedPartCount; private int uncheckedPartCount;
public long Generation { get; private set; } public long Generation { get; private set; }
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked; public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas; public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
public PlateOverlapReport Report { get; private set; } public PlateOverlapReport Report { get; private set; }
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
public bool IsRunning => Status == OverlapCheckStatus.Checking; public bool IsRunning => Status == OverlapCheckStatus.Checking;
public string Message => Status switch public string Message => Status switch
@@ -42,8 +36,7 @@ public sealed class OverlapReportState
/// </summary> /// </summary>
public long Begin(Plate plate, bool automatic = false) public long Begin(Plate plate, bool automatic = false)
{ {
RefreshDisplayPairs(plate); Clear(OverlapCheckStatus.Checking);
Clear(OverlapCheckStatus.Checking, preserveDisplay: true);
stamp = OverlapGeometryStamp.Capture(plate); stamp = OverlapGeometryStamp.Capture(plate);
if (!automatic && DisplayMode == OverlapDisplayMode.Off) if (!automatic && DisplayMode == OverlapDisplayMode.Off)
DisplayMode = OverlapDisplayMode.Areas; DisplayMode = OverlapDisplayMode.Areas;
@@ -55,9 +48,6 @@ public sealed class OverlapReportState
if (!CanComplete(generation, plate)) if (!CanComplete(generation, plate))
return false; return false;
Report = report; Report = report;
DisplayPairs = report.Pairs;
displayStamp = stamp;
observedDisplayStamp = stamp;
uncheckedPartCount = CountUncheckedParts(report.Issues); uncheckedPartCount = CountUncheckedParts(report.Issues);
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete; Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
return true; return true;
@@ -76,52 +66,20 @@ public sealed class OverlapReportState
public bool EnsureFresh(Plate plate) public bool EnsureFresh(Plate plate)
{ {
RefreshDisplayPairs(plate);
if (stamp == null) if (stamp == null)
return false; return false;
if (stamp.Matches(plate)) if (stamp.Matches(plate))
return true; return true;
Invalidate(plate); Invalidate();
return false; return false;
} }
/// <summary>Layout edit: retain only pairs whose two ordered slots still match exactly.</summary>
public void Invalidate(Plate plate)
{
RefreshDisplayPairs(plate);
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
Clear(OverlapCheckStatus.Stale, preserveDisplay: true);
}
/// <summary>In-place geometry edits and teardown must forget every cached display pair.</summary>
public void Invalidate() public void Invalidate()
{ {
ClearDisplayPairs();
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete) if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
Clear(OverlapCheckStatus.Stale); Clear(OverlapCheckStatus.Stale);
} }
private void RefreshDisplayPairs(Plate plate)
{
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
return;
var unchanged = displayStamp.UnchangedSlots(plate);
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
if (retained.Count != DisplayPairs.Count)
DisplayPairs = retained.AsReadOnly();
if (DisplayPairs.Count == 0)
ClearDisplayPairs();
else
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
}
private void ClearDisplayPairs()
{
DisplayPairs = Array.Empty<PlateOverlapPair>();
displayStamp = null;
observedDisplayStamp = null;
}
public void Cancel() public void Cancel()
{ {
if (IsRunning) if (IsRunning)
@@ -130,10 +88,8 @@ public sealed class OverlapReportState
public void Reset() => Clear(OverlapCheckStatus.NotChecked); public void Reset() => Clear(OverlapCheckStatus.NotChecked);
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false) private void Clear(OverlapCheckStatus status)
{ {
if (!preserveDisplay)
ClearDisplayPairs();
Generation++; Generation++;
Report = null; Report = null;
uncheckedPartCount = 0; uncheckedPartCount = 0;
@@ -1,45 +0,0 @@
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.Tests.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests;
public class CutOffBoundedFillTests
{
public static IEnumerable<object[]> Cases()
{
foreach (var strategy in PlateFillService.BuiltInStrategies)
foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal })
foreach (var quadrant in new[] { 1, 3 })
foreach (var farSide in new[] { false, true })
yield return new object[] { strategy, axis, quadrant, farSide };
}
[Theory]
[MemberData(nameof(Cases))]
public void FillItemCannotCrossSelectedCutOffBoundary(
string strategy, CutOffAxis axis, int quadrant, bool farSide)
{
var plate = new Plate(new Size(20, 20)) { Quadrant = quadrant, PartSpacing = 0.5 };
var bounds = plate.WorkArea();
var position = new Vector(bounds.Left + 10, bounds.Bottom + 10);
plate.CutOffs.Add(new CutOff(position, axis));
// Independent expected selection box: one side of the cutoff, with spacing.
var area = axis == CutOffAxis.Vertical
? new Box(farSide ? position.X + 0.5 : bounds.Left, bounds.Bottom, 9.5, bounds.Width)
: new Box(bounds.Left, farSide ? position.Y + 0.5 : bounds.Bottom, bounds.Length, 9.5);
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(3, 3));
var parts = PlateFillService.FillItem(strategy, plate,
new NestItem { Drawing = drawing }, area, null, CancellationToken.None);
Assert.NotEmpty(parts);
Assert.Empty(plate.Parts);
Assert.All(parts, part =>
{
Assert.Same(drawing, part.BaseDrawing);
var box = part.BoundingBox;
Assert.True(box.Left >= area.Left - 1e-6 && box.Right <= area.Right + 1e-6);
Assert.True(box.Bottom >= area.Bottom - 1e-6 && box.Top <= area.Top + 1e-6);
});
}
}
@@ -1,165 +0,0 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularBlockTests
{
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
new Dictionary<int, IReadOnlyList<PairPose>>();
[Theory]
[InlineData(1)]
[InlineData(2)]
public void SmallDemandNeverPreparesAFill(int quantity)
{
var job = Job(new[] { Part("ell", LShape(9, 7, 3), quantity, RotationPolicy.Automatic) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
Assert.Empty(catalog.Get(types[0], quantity, new Box(0, 0, 60, 40), CancellationToken.None));
Assert.Equal(0, catalog.PreparationCount);
}
[Fact]
public void FillProposalIsTrimmedAndCertifiedWithoutChangingSingleRotations()
{
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 7, RotationPolicy.Automatic) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
var original = types[0].Orientations.ToArray();
using var catalog = new BlockCatalog(0.25, types, NoPairs);
var block = catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None);
Assert.Equal(7, block.Count);
Assert.Equal(original, types[0].Orientations);
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
for (var i = 0; i < block.Count; i++)
for (var j = i + 1; j < block.Count; j++)
Assert.True(NestLayoutCheck.Clears(geometry,
new NestJobPlacement("ell", i, block[i].X, block[i].Y, block[i].Orientation.Rotation), geometry,
new NestJobPlacement("ell", j, block[j].X, block[j].Y, block[j].Orientation.Rotation), 0.25));
Assert.Same(block, catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None));
}
[Theory]
[InlineData(0)]
[InlineData(2.1)]
public void InvalidInternalSpacingRejectsTheWholeProposal(double offset)
{
var program = Shapes.Rectangle(2, 2);
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("box", program);
var members = Enumerable.Range(0, 3).Select(i => new OpenNest.Part(drawing)
{ Location = new Vector(i * offset, 0) }).ToArray();
var orientations = type.Orientations.ToList();
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
Assert.Equal(type.Orientations, orientations);
}
[Fact]
public void PhysicalFreeRectanglesExcludeOccupiedMaterialAndClearance()
{
var job = Job(new[] { Rectangle("box", 4, 10, 1) }, new[] { Stock("sheet", 10, 20) });
var type = PartCatalog.Build(job)[0];
var part = new Placed(type.Orientations[0], 8, 0);
var rectangles = BlockCatalog.Rectangles(new Box(0, 0, 20, 10), new[] { part }, 0.25);
Assert.NotEmpty(rectangles);
Assert.All(rectangles, box => Assert.True(box.Right <= 7.75 || box.Left >= 12.25));
}
[Fact]
public void RepeatedEllBlockCompetesWithPairsOnAnOpenSheet()
{
var stock = Stock("sheet", 30, 40, spacing: 0.25);
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 44, RotationPolicy.Automatic) }, new[] { stock });
var types = PartCatalog.Build(job);
var pairs = PairCatalog.Build(types, 0.25, 40, 30, CancellationToken.None);
using var blocks = new BlockCatalog(0.25, types, pairs);
var before = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
PackAxis.X, 1, new WorkCounter()).Fill(new[] { 44 }, CancellationToken.None);
var after = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 44 }, CancellationToken.None);
Assert.True(after.Parts.Count > before.Parts.Count,
$"Before {before.Parts.Count}; after {after.Parts.Count}");
var result = new NestJobResultBuilder(job);
result.AddSheet(stock, after.Parts.Select(p => ("ell", p.X, p.Y, p.Orientation.Rotation)));
LayoutAssert.Valid(job, result.Build(NestJobStopReason.StockExhausted));
}
[Fact]
public void UndersizedRectangleKeepsSinglesFallback()
{
var stock = Stock("sheet", 3, 3, spacing: 0.25);
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { stock });
var types = PartCatalog.Build(job);
using var blocks = new BlockCatalog(0.25, types, NoPairs);
Assert.Empty(blocks.Get(types[0], 3, new Box(0, 0, 3, 3), CancellationToken.None));
Assert.Equal(0, blocks.PreparationCount);
var fill = new FrontierPacker(types, new NoFitCache(0.25), NoPairs, stock,
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 3 }, CancellationToken.None);
Assert.Single(fill.Parts);
}
[Fact]
public void ForbiddenRotationsAreRejectedBeforeAddingGroupOrientations()
{
var program = LShape(9, 7, 3);
var job = Job(new[] { Part("ell", program, 3, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("ell", program);
var members = Enumerable.Range(0, 3).Select(i =>
{
var part = OpenNest.Part.CreateAtOrigin(drawing, System.Math.PI / 2);
part.Offset(new Vector(i * 12, 0));
return part;
}).ToArray();
var orientations = type.Orientations.ToList();
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
Assert.Equal(type.Orientations, orientations);
}
[Fact]
public void CancellationDuringCertificationPropagates()
{
var program = Shapes.Rectangle(2, 2);
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("box", program);
using var cancellation = new CancellationTokenSource();
var members = new CancellingMembers(Enumerable.Range(0, 3)
.Select(i => new OpenNest.Part(drawing) { Location = new Vector(i * 3, 0) }).ToArray(), cancellation);
Assert.Throws<OperationCanceledException>(() => BlockCatalog.Resolve(type, members,
type.Orientations.ToList(), 0.25, cancellation.Token));
}
private sealed class CancellingMembers(OpenNest.Part[] parts, CancellationTokenSource cancellation)
: IReadOnlyList<OpenNest.Part>
{
public int Count => parts.Length;
public OpenNest.Part this[int index] => parts[index];
public IEnumerator<OpenNest.Part> GetEnumerator()
{
foreach (var part in parts)
yield return part;
cancellation.Cancel();
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[Fact]
public void CancellationIsNotConvertedIntoAnEmptyProposal()
{
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { Stock("sheet", 10, 20) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
Assert.Throws<OperationCanceledException>(() =>
catalog.Get(types[0], 3, new Box(0, 0, 20, 10), new CancellationToken(true)));
}
}
@@ -1,229 +0,0 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularPairTests
{
// A 20-long wedge, 8 wide at one end and 6 at the other. Lying down, two copies need
// 8 + 0.25 + 8 = 16.25 across, but nested slant-to-slant they need only about 14.25.
// The 30 x 15 sheet holds the pair only when the two copies interlock.
private static OpenNest.CNC.Program Wedge() => Polyline((0, 0), (8, 0), (6, 20), (0, 20));
private static OpenNest.CNC.Program MirroredWedge() => Polyline((0, 0), (8, 0), (8, 20), (2, 20));
public static TheoryData<string> Wedges => new() { "wedge", "mirrored" };
private static OpenNest.CNC.Program NativeU()
{
// Same native semicircular U as the shared curve-contact regression; no DXF dependency.
var p = new OpenNest.CNC.Program();
p.MoveTo(2.5, 0.625);
p.LineTo(2.5, 0);
p.LineTo(1.5, 0);
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 3, 1.5, 1.5, OpenNest.RotationType.CW));
p.LineTo(2.5, 3);
p.LineTo(2.5, 2.375);
p.LineTo(1.5, 2.375);
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 0.625, 1.5, 1.5, OpenNest.RotationType.CCW));
p.LineTo(2.5, 0.625);
return p;
}
[Fact]
public void NativeUQuantityTwoOffersAndPlacesAnInterlockedClearPair()
{
var job = Job(new[] { Part("native-u", NativeU(), 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 6, 6, spacing: 0.25) });
var types = PartCatalog.Build(job);
var pairs = PairCatalog.Build(types, 0.25, 6, 6, CancellationToken.None);
Assert.NotEmpty(pairs[0]);
var pair = pairs[0][0];
// Bounding boxes overlap on both axes: this is an interlock, not adjacent boxes.
Assert.True(System.Math.Min(pair.A.MaxX, pair.Dx + pair.B.MaxX)
> System.Math.Max(pair.A.MinX, pair.Dx + pair.B.MinX));
Assert.True(System.Math.Min(pair.A.MaxY, pair.Dy + pair.B.MaxY)
> System.Math.Max(pair.A.MinY, pair.Dy + pair.B.MinY));
var tightJob = Job(job.Parts.ToArray(),
new[] { Stock("tight", pair.Height + 0.01, pair.Width + 0.01, spacing: 0.25) });
var result = new IrregularNestingEngine().Solve(tightJob);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
LayoutAssert.Valid(tightJob, result);
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
Assert.Empty(geometry.Cutouts);
Assert.Equal(2.5, geometry.Bounds.Length, 9);
Assert.Equal(3, geometry.Bounds.Width, 9);
var outlines = result.Plates[0].Placements.Select(placement =>
{
var shape = (OpenNest.Geometry.Shape)geometry.Perimeter.Clone();
shape.Rotate(placement.Rotation);
shape.Offset(placement.X, placement.Y);
return shape.ToPolygonWithTolerance(1e-6);
}).ToArray();
// Raw fine boundaries, independent of BestFit's offsets and the NFP free regions.
Assert.True(OpenNest.Geometry.Clearance.Between(outlines[0], outlines[1]).Distance >= 0.25 - 2e-6);
Assert.True(outlines[0].BoundingBox.Right > outlines[1].BoundingBox.Left
&& outlines[1].BoundingBox.Right > outlines[0].BoundingBox.Left);
Assert.True(outlines[0].BoundingBox.Top > outlines[1].BoundingBox.Bottom
&& outlines[1].BoundingBox.Top > outlines[0].BoundingBox.Bottom);
}
[Theory]
[InlineData(0.0)] // Coincident material.
[InlineData(3.1)] // Disjoint outlines, but only 0.1 clearance rather than 0.25.
public void PairResolutionRejectsOverlapAndInsufficientSpacing(double offset)
{
var program = NativeU();
var job = Job(new[] { Part("native-u", program, 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 10, 10, spacing: 0.25) });
var type = Assert.Single(PartCatalog.Build(job));
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
var drawing = new Drawing("native-u", program);
var members = new List<OpenNest.Part>
{
new(drawing),
new(drawing) { Location = new OpenNest.Geometry.Vector(0, offset) },
};
var extra = new List<Orientation>();
Assert.Null(PairCatalog.Resolve(type, extra, geometry, members, 0, 0.25));
Assert.Empty(extra);
}
[Fact]
public void PairOnlyOrientationRemainsEligibleWhenSampledSinglesDoNotFit()
{
var wedge = MirroredWedge();
wedge.Rotate(System.Math.PI / 4);
// A many-type job samples only two single orientations. The pair adds its own
// legal rotations; neither sampled single fits this narrow stock.
var parts = Enumerable.Range(0, 24).Select(i => Rectangle($"oversized-{i}", 100, 100, 1)).ToList();
parts.Insert(0, Part("wedge", wedge, 2, RotationPolicy.Automatic));
var stock = Stock("sheet", 15, 30, spacing: 0.25);
var job = Job(parts.ToArray(), new[] { stock });
var types = PartCatalog.Build(job);
Assert.DoesNotContain(types[0].Orientations, o => stock.Fits(o.Width, o.Height));
var pairs = PairCatalog.Build(types, 0.25, 30, 15, CancellationToken.None);
Assert.Contains(pairs[0], p => stock.Fits(p.Width, p.Height));
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Theory]
[MemberData(nameof(Wedges))]
public void QuantityTwoInterlocksWhenOnlyAPairFits(string shape)
{
var program = shape == "wedge" ? Wedge() : MirroredWedge();
var job = Job(
new[] { Part("wedge", program, 2, RotationPolicy.Fixed(System.Math.PI / 2, allow180Equivalent: true)) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Theory]
[MemberData(nameof(Wedges))]
public void AutomaticRotationAlsoFindsThePair(string shape)
{
var program = shape == "wedge" ? Wedge() : MirroredWedge();
var job = Job(
new[] { Part("wedge", program, 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
}
[Fact]
public void PairsNeverUseARotationThePolicyForbids()
{
// Fixed at 0 with no 180-degree twin: every best-fit pair turns its second copy,
// so no pair is allowed and the parts must be placed as singles.
var job = Job(
new[] { Part("wedge", Wedge(), 2, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 25, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.All(result.Plates.SelectMany(p => p.Placements), p => Assert.Equal(0, p.Rotation, 9));
}
[Fact]
public void OddDemandPlacesTheLastCopyAsASingleWithoutOverdrawing()
{
var job = Job(new[] { Part("wedge", MirroredWedge(), 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) });
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(new[] { 1, 2 }, result.Plates.Select(p => p.Placements.Count).OrderBy(n => n));
}
[Fact]
public void PairMembersLeaveTheirEnclosedGapAvailableForLaterParts()
{
var stock = Stock("sheet", 6.1, 6.1, spacing: 0.1);
var job = Job(new[] { Rectangle("bar", 2, 6, 2), Rectangle("insert", 1, 1, 1) }, new[] { stock });
var types = PartCatalog.Build(job);
var bar = Assert.Single(types[0].Orientations);
var pair = new PairPose { A = bar, B = bar, Dx = 4, Dy = 0 };
var pairs = new Dictionary<int, IReadOnlyList<PairPose>> { [0] = new[] { pair } };
// Along Y the pair buys twice the area for the same advance, so it wins first.
var fill = new FrontierPacker(types, new NoFitCache(0.1), pairs, stock,
PackAxis.Y, 1, new WorkCounter()).Fill(new[] { 2, 1 }, CancellationToken.None);
Assert.Equal(3, fill.Parts.Count);
Assert.Equal(0, fill.Parts[0].Orientation.TypeIndex);
Assert.Equal(0, fill.Parts[1].Orientation.TypeIndex);
var insert = fill.Parts[2];
Assert.Equal(1, insert.Orientation.TypeIndex);
Assert.True(insert.Left > fill.Parts[0].Right);
Assert.True(insert.Right < fill.Parts[1].Left);
var geometries = job.Parts.Select(p => JobPartGeometry.Read(p.Geometry)).ToArray();
for (var i = 0; i < fill.Parts.Count; i++)
for (var j = i + 1; j < fill.Parts.Count; j++)
{
var a = fill.Parts[i];
var b = fill.Parts[j];
Assert.True(NestLayoutCheck.Clears(geometries[a.Orientation.TypeIndex],
new NestJobPlacement(job.Parts[a.Orientation.TypeIndex].Id, i, a.X, a.Y, a.Orientation.Rotation),
geometries[b.Orientation.TypeIndex],
new NestJobPlacement(job.Parts[b.Orientation.TypeIndex].Id, j, b.X, b.Y, b.Orientation.Rotation), 0.1));
}
}
[Fact]
public void PairedJobsAreDeterministic()
{
// Best-fit evaluates in parallel; equal-area pairs must still be chosen the same way.
NestJob Build() =>
Job(
new[]
{
Part("wedge", Wedge(), 6, RotationPolicy.Automatic),
Part("ell", LShape(9, 7, 3), 4, RotationPolicy.Automatic),
},
new[] { Stock("sheet", 30, 40, spacing: 0.25) }
);
var first = new IrregularNestingEngine().Solve(Build());
var second = new IrregularNestingEngine().Solve(Build());
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
}
}
@@ -1,156 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>Per-solve, per-spacing Fill proposals. Only the members occupy material.</summary>
internal sealed class BlockCatalog : IDisposable
{
private readonly double spacing;
private readonly Dictionary<int, Drawing> drawings = new();
private readonly Dictionary<int, List<Orientation>> orientations = new();
private readonly Dictionary<int, int> attempts = new();
internal int PreparationCount => attempts.Values.Sum();
private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList<Placed>> cache = new();
public BlockCatalog(double spacing, IReadOnlyList<PartType> types,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs)
{
this.spacing = spacing;
foreach (var type in types)
{
var poses = type.Orientations.ToList();
if (pairs.TryGetValue(type.Index, out var found))
poses.AddRange(found.SelectMany(p => new[] { p.A, p.B }));
orientations[type.Index] = poses.Distinct().ToList();
}
}
internal static IReadOnlyList<Box> Rectangles(Box work, IReadOnlyList<Placed> placed, double spacing)
{
var free = new PathsD { new PathD
{
new(work.Left, work.Bottom), new(work.Right, work.Bottom),
new(work.Right, work.Top), new(work.Left, work.Top),
} };
foreach (var part in placed)
{
// Physical occupied material, not a reference-point region for any moving part.
// The catalog currently prepares solid outlines; future profile preparation owns holes.
var blocked = Clipper.InflatePaths(new PathsD { part.Orientation.Outline },
spacing + part.Orientation.Tolerance + 0.001, JoinType.Miter, EndType.Polygon,
2, NoFitCache.Precision);
free = Clipper.Difference(free, Clipper.TranslatePaths(blocked, part.X, part.Y),
FillRule.NonZero, NoFitCache.Precision);
}
return MaximalRectangles.InRegion(free).OrderByDescending(b => b.Area())
.ThenBy(b => b.Left).ThenBy(b => b.Bottom).ThenBy(b => b.Length).Take(2).ToArray();
}
public IReadOnlyList<Placed> Get(PartType type, int quantity, Box rectangle, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (quantity <= 2 || type.Orientations.Count == 0 || rectangle.Length <= 0 || rectangle.Width <= 0)
return Array.Empty<Placed>();
var key = (type.Index, quantity, rectangle.Length, rectangle.Width);
if (cache.TryGetValue(key, out var cached))
return cached;
if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8)
return Array.Empty<Placed>();
attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1;
var result = Build(type, quantity, rectangle, token);
cache[key] = result;
return result;
}
private IReadOnlyList<Placed> Build(PartType type, int quantity, Box rectangle, CancellationToken token)
{
if (!drawings.TryGetValue(type.Index, out var drawing))
drawings[type.Index] = drawing = DrawingJobMapper.CreateDrawing(type.Part);
var plate = new Plate(new Size(rectangle.Width, rectangle.Length)) { PartSpacing = spacing };
try
{
// The drawing is private: stabilize this cache entry before Fill's candidate pruning.
var fits = BestFitCache.GetOrCompute(drawing, plate.Size.Length, plate.Size.Width, spacing);
var sorted = fits.OrderBy(f => f.RotatedArea).ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation).ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y).ThenBy(f => f.OptimalRotation).ToArray();
fits.Clear();
fits.AddRange(sorted);
var members = PlateFillService.FillItem("Default", plate, new NestItem
{
Drawing = drawing,
Quantity = quantity,
RotationStart = type.Part.Rotation.Start,
RotationEnd = type.Part.Rotation.End,
StepAngle = DrawingJobMapper.LegacyStep(type.Part.Rotation),
}, new Box(0, 0, rectangle.Length, rectangle.Width), null!, token);
token.ThrowIfCancellationRequested();
return Resolve(type, members.Take(quantity).ToArray(), orientations[type.Index], spacing, token);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return Array.Empty<Placed>();
}
}
public void Dispose()
{
foreach (var drawing in drawings.Values)
BestFitCache.Invalidate(drawing);
drawings.Clear();
}
internal static IReadOnlyList<Placed> Resolve(PartType type, IReadOnlyList<Part> members,
List<Orientation> orientations, double spacing, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (members.Count <= 2)
return Array.Empty<Placed>();
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
return Array.Empty<Placed>();
// Canonical rebinding is already performed by FillItem. Quantization removes sub-grid
// arithmetic differences from equivalent Fill proposals; certify the resulting poses.
var poses = members.Select(p => new NestJobPlacement(type.Part.Id, 0,
System.Math.Round(p.Location.X, 8), System.Math.Round(p.Location.Y, 8),
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
.OrderBy(p => p.X).ThenBy(p => p.Y).ThenBy(p => p.Rotation).ToArray();
if (poses.Any(p => !double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Rotation)
|| !type.Part.Rotation.Allows(p.Rotation)))
return Array.Empty<Placed>();
for (var i = 0; i < poses.Length; i++)
for (var j = i + 1; j < poses.Length; j++)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing))
return Array.Empty<Placed>();
}
var result = new List<Placed>();
foreach (var pose in poses)
{
token.ThrowIfCancellationRequested();
var orientation = orientations.FirstOrDefault(o => o.Rotation == pose.Rotation);
if (orientation == null)
{
orientation = PartCatalog.CreateOrientation(type, orientations.Max(o => o.Index) + 1, pose.Rotation);
if (orientation == null)
return Array.Empty<Placed>();
orientations.Add(orientation);
}
result.Add(new Placed(orientation, pose.X - poses[0].X, pose.Y - poses[0].Y));
}
return result;
}
}
@@ -55,30 +55,17 @@ internal sealed class FrontierPacker
private readonly IReadOnlyList<PartType> types; private readonly IReadOnlyList<PartType> types;
private readonly NoFitCache nfps; private readonly NoFitCache nfps;
private readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs;
private readonly NestPlateStock stock; private readonly NestPlateStock stock;
private readonly PackAxis axis; private readonly PackAxis axis;
private readonly double beta; private readonly double beta;
private readonly Box work; private readonly Box work;
private readonly WorkCounter counter; private readonly WorkCounter counter;
private readonly BlockCatalog? blocks;
public FrontierPacker( public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
IReadOnlyList<PartType> types,
NoFitCache nfps,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs,
NestPlateStock stock,
PackAxis axis,
double beta,
WorkCounter counter,
BlockCatalog? blocks = null
)
{ {
this.blocks = blocks;
this.counter = counter; this.counter = counter;
this.types = types; this.types = types;
this.nfps = nfps; this.nfps = nfps;
this.pairs = pairs;
this.stock = stock; this.stock = stock;
this.axis = axis; this.axis = axis;
this.beta = beta; this.beta = beta;
@@ -89,145 +76,74 @@ internal sealed class FrontierPacker
{ {
var left = remaining.ToArray(); var left = remaining.ToArray();
var states = new List<Region>(); var states = new List<Region>();
var byOrientation = new Dictionary<Orientation, Region>(ReferenceEqualityComparer.Instance);
bool Track(Orientation o, bool single)
{
if (byOrientation.ContainsKey(o))
return true;
if (!stock.Fits(o.Width, o.Height))
return false;
var region = new Region(o, work, single);
states.Add(region);
byOrientation[o] = region;
return true;
}
var offered = new List<PairState>();
foreach (var type in types) foreach (var type in types)
{ {
if (left[type.Index] <= 0) if (left[type.Index] <= 0)
continue; continue;
foreach (var o in type.Orientations) foreach (var o in type.Orientations)
Track(o, single: true); if (stock.Fits(o.Width, o.Height))
if (left[type.Index] < 2 || !pairs.TryGetValue(type.Index, out var typePairs)) states.Add(new Region(o, work));
continue;
foreach (var pair in typePairs)
{
// Members missing from the catalog get regions too, but only for pair placement.
if (stock.Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
offered.Add(new PairState(pair, byOrientation[pair.A], byOrientation[pair.B]));
}
} }
var placed = new List<Placed>(); var placed = new List<Placed>();
var blockStates = new List<BlockState>();
var preparations = 0;
void PrepareBlocks()
{
if (blocks == null || preparations++ >= 2)
return;
var rectangles = BlockCatalog.Rectangles(work, placed, stock.PartSpacing);
foreach (var type in types.Where(t => left[t.Index] > 2)
.OrderByDescending(t => t.Area * left[t.Index]).ThenBy(t => t.Index).Take(4))
foreach (var rectangle in rectangles)
{
var members = blocks.Get(type, left[type.Index], rectangle, token);
if (members.Count <= 2)
continue;
var width = members.Max(p => p.Right) - members.Min(p => p.Left);
var height = members.Max(p => p.Top) - members.Min(p => p.Bottom);
if (!stock.Fits(width, height))
continue;
foreach (var member in members)
{
if (byOrientation.ContainsKey(member.Orientation))
continue;
if (!Track(member.Orientation, single: false))
break;
var region = byOrientation[member.Orientation];
foreach (var existing in placed)
region.Subtract(nfps.Get(existing.Orientation, member.Orientation), existing.X, existing.Y);
}
if (members.All(p => byOrientation.ContainsKey(p.Orientation)))
blockStates.Add(new BlockState(members, members.Select(p => byOrientation[p.Orientation]).ToArray()));
}
}
PrepareBlocks();
var partArea = 0.0; var partArea = 0.0;
var front = axis == PackAxis.X ? work.Left : work.Bottom; var front = axis == PackAxis.X ? work.Left : work.Bottom;
while (states.Count > 0) while (states.Count > 0)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var choice = Choose(states, offered, blockStates, front); var choice = Choose(states, front);
if (choice == null) if (choice == null)
break; break;
var typeIndex = choice[0].Orientation.TypeIndex; var (region, point) = choice.Value;
foreach (var part in choice) var part = new Placed(region.Orientation, point.x, point.y);
{ placed.Add(part);
placed.Add(part); var typeIndex = region.Orientation.TypeIndex;
partArea += types[typeIndex].Area; partArea += types[typeIndex].Area;
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top); front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
}
left[typeIndex] -= choice.Count; if (--left[typeIndex] == 0)
blockStates.RemoveAll(b => b.Members.Count > left[b.Members[0].Orientation.TypeIndex]);
if (left[typeIndex] == 0)
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex); states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
if (left[typeIndex] < 2)
{
offered.RemoveAll(p => p.Pose.TypeIndex == typeIndex);
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex && !s.Single);
}
// Each surviving region loses the positions the new parts now block. Regions are // Each surviving region loses the positions the new part now blocks. Regions are
// independent, so they update in parallel without affecting determinism. // independent, so they update in parallel without affecting determinism.
var snapshot = states.ToArray(); var snapshot = states.ToArray();
foreach (var part in choice) counter.Add(snapshot.Length);
{ Parallel.For(
counter.Add(snapshot.Length); 0,
Parallel.For( snapshot.Length,
0, new ParallelOptions { CancellationToken = token },
snapshot.Length, i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
new ParallelOptions { CancellationToken = token }, );
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
);
}
states.RemoveAll(s => s.IsEmpty); states.RemoveAll(s => s.IsEmpty);
offered.RemoveAll(p => p.A.IsEmpty || p.B.IsEmpty);
if (offered.Count == 0 && blocks == null)
states.RemoveAll(s => !s.Single);
blockStates.RemoveAll(b => b.Regions.Any(r => r.IsEmpty));
PrepareBlocks();
} }
return new SheetFill(stock, placed, partArea); return new SheetFill(stock, placed, partArea);
} }
/// <summary> private (Region, PointD)? Choose(List<Region> states, double front)
/// The next placement: one part, or both members of a pair. Singles and pairs compete
/// under the same rule; a pair counts as one piece of twice the part area, and on a tie
/// the single (considered first) is kept.
/// </summary>
private IReadOnlyList<Placed>? Choose(List<Region> states, List<PairState> offered, List<BlockState> blocks, double front)
{ {
IReadOnlyList<Placed>? bestParts = null; Region? bestRegion = null;
var bestPoint = default(PointD);
var bestFills = false; var bestFills = false;
var bestValue = double.PositiveInfinity; var bestValue = double.PositiveInfinity;
var bestSide = double.PositiveInfinity; var bestSide = double.PositiveInfinity;
var bestLead = double.PositiveInfinity; var bestLead = double.PositiveInfinity;
var bestPriority = int.MaxValue; var bestPriority = int.MaxValue;
void Consider(Func<IReadOnlyList<Placed>> build, int typeIndex, double area, double advance, double side, double lead) foreach (var region in states)
{ {
var priority = types[typeIndex].Part.Priority; if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
if (priority > bestPriority) continue;
return; var area = types[region.Orientation.TypeIndex].Area;
var priority = types[region.Orientation.TypeIndex].Part.Priority;
if (priority > bestPriority) continue;
var fills = advance <= Tie; var fills = advance <= Tie;
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area. // Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta); var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
var better = bestParts == null var better = bestRegion == null
|| priority < bestPriority || priority < bestPriority
|| (fills && !bestFills) || (fills && !bestFills)
|| ( || (
@@ -241,83 +157,17 @@ internal sealed class FrontierPacker
) )
); );
if (!better) if (!better)
return; continue;
bestParts = build(); bestRegion = region;
bestPriority = priority; bestPriority = priority;
bestPoint = point;
bestFills = fills; bestFills = fills;
bestValue = value; bestValue = value;
bestSide = side; bestSide = side;
bestLead = lead; bestLead = lead;
} }
foreach (var region in states) return bestRegion == null ? null : (bestRegion, bestPoint);
{
if (!region.Single)
continue;
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
continue;
var o = region.Orientation;
Consider(() => new[] { new Placed(o, point.x, point.y) }, o.TypeIndex, types[o.TypeIndex].Area, advance, side, lead);
}
foreach (var state in offered)
{
var pair = state.Pose;
if (!state.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
continue;
Consider(
() => new[] { new Placed(pair.A, point.x, point.y), new Placed(pair.B, point.x + pair.Dx, point.y + pair.Dy) },
pair.TypeIndex,
2 * types[pair.TypeIndex].Area,
advance,
side,
lead
);
}
foreach (var block in blocks)
{
if (!block.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
continue;
var typeIndex = block.Members[0].Orientation.TypeIndex;
Consider(() => block.Members.Select(p => new Placed(p.Orientation, p.X + point.x, p.Y + point.y)).ToArray(),
typeIndex, block.Members.Count * types[typeIndex].Area, advance, side, lead);
}
return bestParts;
}
private sealed class BlockState(IReadOnlyList<Placed> members, Region[] regions)
{
public IReadOnlyList<Placed> Members { get; } = members;
public Region[] Regions { get; } = regions;
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
out PointD point, out double advance, out double side, out double lead)
{
var free = Clipper.TranslatePaths(Regions[0].Free, -Members[0].X, -Members[0].Y);
var minX = double.NegativeInfinity;
var minY = double.NegativeInfinity;
var maxX = double.PositiveInfinity;
var maxY = double.PositiveInfinity;
for (var i = 0; i < Members.Count; i++)
{
var member = Members[i];
var region = Regions[i];
minX = System.Math.Max(minX, region.MinX - member.X);
minY = System.Math.Max(minY, region.MinY - member.Y);
maxX = System.Math.Min(maxX, region.MaxX - member.X);
maxY = System.Math.Min(maxY, region.MaxY - member.Y);
if (i > 0)
{
counter.Add(1);
free = Clipper.Intersect(free, Clipper.TranslatePaths(region.Free, -member.X, -member.Y),
FillRule.NonZero, NoFitCache.Precision);
}
}
return BestVertex(free, minX, minY, System.Math.Max(minX, maxX), System.Math.Max(minY, maxY),
(Members.Min(p => p.Left), Members.Min(p => p.Bottom), Members.Max(p => p.Right), Members.Max(p => p.Top)),
axis, front, out point, out advance, out side, out lead);
}
} }
/// <summary>Legal reference points for one orientation on this sheet.</summary> /// <summary>Legal reference points for one orientation on this sheet.</summary>
@@ -327,10 +177,9 @@ internal sealed class FrontierPacker
private PathsD free; private PathsD free;
private RectD bounds; private RectD bounds;
public Region(Orientation orientation, Box work, bool single) public Region(Orientation orientation, Box work)
{ {
Orientation = orientation; Orientation = orientation;
Single = single;
minX = work.Left - orientation.MinX; minX = work.Left - orientation.MinX;
maxX = work.Right - orientation.MaxX; maxX = work.Right - orientation.MaxX;
minY = work.Bottom - orientation.MinY; minY = work.Bottom - orientation.MinY;
@@ -354,10 +203,6 @@ internal sealed class FrontierPacker
} }
public Orientation Orientation { get; } public Orientation Orientation { get; }
/// <summary>False for a pair-only orientation: it is never placed on its own.</summary>
public bool Single { get; }
public bool IsEmpty => free.Count == 0; public bool IsEmpty => free.Count == 0;
public void Subtract(Nfp nfp, double dx, double dy) public void Subtract(Nfp nfp, double dx, double dy)
@@ -374,132 +219,49 @@ internal sealed class FrontierPacker
// Drop numerical dust; a sliver thinner than the precision grid is no real room. // Drop numerical dust; a sliver thinner than the precision grid is no real room.
free.RemoveAll(p => p.Count < 3); free.RemoveAll(p => p.Count < 3);
bounds = free.Count == 0 ? default : Clipper.GetBounds(free); bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
Version++;
} }
/// <summary>Changes whenever the free region does.</summary>
public int Version { get; private set; }
public PathsD Free => free;
public RectD Bounds => bounds;
public double MinX => minX;
public double MinY => minY;
public double MaxX => maxX;
public double MaxY => maxY;
/// <summary> /// <summary>
/// Best vertex of the free region: least front advance, then lowest cross-axis position, /// Best vertex of the free region: least front advance, then lowest cross-axis position,
/// then lowest leading edge. Vertices suffice because every score is linear in position. /// then lowest leading edge. Vertices suffice because every score is linear in position.
/// </summary> /// </summary>
public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead) public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead)
{ {
point = default;
advance = side = lead = double.PositiveInfinity;
var found = false;
var o = Orientation; var o = Orientation;
return BestVertex(free, minX, minY, maxX, maxY, (o.MinX, o.MinY, o.MaxX, o.MaxY), foreach (var path in free)
axis, front, out point, out advance, out side, out lead); foreach (var raw in path)
}
}
/// <summary>
/// Scores the vertices of <paramref name="free"/>, each clamped to the inner-fit box
/// [minX, maxX] x [minY, maxY], for a piece occupying <paramref name="box"/> around its
/// reference point.
/// </summary>
private static bool BestVertex(
PathsD free,
double minX,
double minY,
double maxX,
double maxY,
(double MinX, double MinY, double MaxX, double MaxY) box,
PackAxis axis,
double front,
out PointD point,
out double advance,
out double side,
out double lead
)
{
point = default;
advance = side = lead = double.PositiveInfinity;
var found = false;
foreach (var path in free)
foreach (var raw in path)
{
var x = System.Math.Clamp(raw.x, minX, maxX);
var y = System.Math.Clamp(raw.y, minY, maxY);
double reach, across, start;
if (axis == PackAxis.X)
{ {
reach = x + box.MaxX; var x = System.Math.Clamp(raw.x, minX, maxX);
across = y + box.MinY; var y = System.Math.Clamp(raw.y, minY, maxY);
start = x + box.MinX; double reach, across, start;
if (axis == PackAxis.X)
{
reach = x + o.MaxX;
across = y + o.MinY;
start = x + o.MinX;
}
else
{
reach = y + o.MaxY;
across = x + o.MinX;
start = y + o.MinY;
}
var adv = System.Math.Max(0, reach - front);
var better = !found
|| adv < advance - Tie
|| (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie)));
if (!better)
continue;
found = true;
point = new PointD(x, y);
advance = adv;
side = across;
lead = start;
} }
else return found;
{
reach = y + box.MaxY;
across = x + box.MinX;
start = y + box.MinY;
}
var adv = System.Math.Max(0, reach - front);
var better = !found
|| adv < advance - Tie
|| (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie)));
if (!better)
continue;
found = true;
point = new PointD(x, y);
advance = adv;
side = across;
lead = start;
}
return found;
}
/// <summary>
/// Legal reference points for a pair: member A's free region intersected with member B's
/// moved back by the pair offset. Recomputed only after either member's region changes.
/// </summary>
private sealed class PairState(PairPose pose, Region a, Region b)
{
private int versionA = -1, versionB = -1;
private PathsD free = new();
public PairPose Pose { get; } = pose;
public Region A { get; } = a;
public Region B { get; } = b;
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
out PointD point, out double advance, out double side, out double lead)
{
if (versionA != A.Version || versionB != B.Version)
{
versionA = A.Version;
versionB = B.Version;
counter.Add(1);
free = Intersect();
}
// Clamp into both members' inner-fit boxes; they overlap whenever the pair fits.
var minX = System.Math.Max(A.MinX, B.MinX - Pose.Dx);
var maxX = System.Math.Max(minX, System.Math.Min(A.MaxX, B.MaxX - Pose.Dx));
var minY = System.Math.Max(A.MinY, B.MinY - Pose.Dy);
var maxY = System.Math.Max(minY, System.Math.Min(A.MaxY, B.MaxY - Pose.Dy));
return BestVertex(free, minX, minY, maxX, maxY, (Pose.MinX, Pose.MinY, Pose.MaxX, Pose.MaxY),
axis, front, out point, out advance, out side, out lead);
}
private PathsD Intersect()
{
if (A.IsEmpty || B.IsEmpty)
return new PathsD();
var a = A.Bounds;
var b = B.Bounds;
if (b.right - Pose.Dx < a.left || b.left - Pose.Dx > a.right
|| b.bottom - Pose.Dy < a.top || b.top - Pose.Dy > a.bottom)
return new PathsD();
var shifted = Clipper.TranslatePaths(B.Free, -Pose.Dx, -Pose.Dy);
var result = Clipper.Intersect(A.Free, shifted, FillRule.NonZero, NoFitCache.Precision);
result.RemoveAll(p => p.Count < 3);
return result;
} }
} }
} }
@@ -1,7 +1,7 @@
#nullable enable #nullable enable
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System;
using System.Threading; using System.Threading;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
@@ -48,10 +48,9 @@ public sealed class IrregularNestingEngine : INestingEngine
ArgumentNullException.ThrowIfNull(job); ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var types = PartCatalog.Build(job); var types = PartCatalog.Build(job);
using var solver = new Solver(job, types, progress, token); var solver = new Solver(job, types, progress, token);
// Pair-only orientations may fit stock even when the sampled single poses do not. // Demand that no offered stock can hold in any allowed orientation is reported unplaced.
// Demand that neither a single nor a pair can fit is reported unplaced.
var demand = new int[types.Count]; var demand = new int[types.Count];
foreach (var type in types) foreach (var type in types)
{ {
@@ -59,8 +58,7 @@ public sealed class IrregularNestingEngine : INestingEngine
stock.Quantity != 0 stock.Quantity != 0
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height)) && type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
); );
demand[type.Index] = placeable || solver.PairFits(type) demand[type.Index] = placeable ? type.Part.Quantity : 0;
|| (type.Part.Quantity > 2 && type.Orientations.Count > 0) ? type.Part.Quantity : 0;
} }
Plan? best = null; Plan? best = null;
@@ -87,26 +85,10 @@ public sealed class IrregularNestingEngine : INestingEngine
IReadOnlyList<PartType> types, IReadOnlyList<PartType> types,
IProgress<NestJobProgress>? progress, IProgress<NestJobProgress>? progress,
CancellationToken token CancellationToken token
) : IDisposable )
{ {
public void Dispose()
{
foreach (var catalog in blocks.Values)
catalog.Dispose();
}
private const int MaxTail = 3; private const int MaxTail = 3;
private readonly Dictionary<double, NoFitCache> caches = new(); private readonly Dictionary<double, NoFitCache> caches = new();
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
private readonly Dictionary<double, BlockCatalog> blocks = new();
private BlockCatalog BlocksFor(NestPlateStock stock)
{
var spacing = System.Math.Max(0, stock.PartSpacing);
if (!blocks.TryGetValue(spacing, out var found))
blocks[spacing] = found = new BlockCatalog(spacing, types, PairsFor(stock));
return found;
}
public WorkCounter Work { get; } = new(); public WorkCounter Work { get; } = new();
@@ -175,30 +157,6 @@ public sealed class IrregularNestingEngine : INestingEngine
return cache; return cache;
} }
public bool PairFits(PartType type)
{
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
return false;
return job.Plates.Any(stock => stock.Quantity != 0
&& PairsFor(stock).TryGetValue(type.Index, out var candidates)
&& candidates.Any(pair => stock.Fits(pair.Width, pair.Height)));
}
/// <summary>Best-fit pairs for this stock's spacing, built once per solve.</summary>
private IReadOnlyDictionary<int, IReadOnlyList<PairPose>> PairsFor(NestPlateStock stock)
{
var clearance = System.Math.Max(0, stock.PartSpacing);
if (!pairs.TryGetValue(clearance, out var found))
{
// The best-fit plate filter only needs the largest sheet at this spacing;
// each packer still checks the pair against its own work area.
var sheets = job.Plates.Where(p => System.Math.Max(0, p.PartSpacing) == clearance).ToList();
pairs[clearance] = found = PairCatalog.Build(types, clearance,
sheets.Max(p => p.Size.Length), sheets.Max(p => p.Size.Width), token);
}
return found;
}
/// <summary> /// <summary>
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock /// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and /// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
@@ -241,7 +199,7 @@ public sealed class IrregularNestingEngine : INestingEngine
if (stock.Quantity is int available && used[stock.Id] >= available) if (stock.Quantity is int available && used[stock.Id] >= available)
continue; continue;
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0)); progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock)); var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
var fill = packer.Fill(remaining, token); var fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0) if (fill.Parts.Count > 0)
trials.Add((fill, NetArea(job.Options, fill))); trials.Add((fill, NetArea(job.Options, fill)));
@@ -1,207 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>
/// Two copies of one part type placed together: member A at the pair's reference point and
/// member B at <see cref="Dx"/>, <see cref="Dy"/> from it.
/// </summary>
internal sealed class PairPose
{
public required Orientation A { get; init; }
public required Orientation B { get; init; }
public required double Dx { get; init; }
public required double Dy { get; init; }
public int TypeIndex => A.TypeIndex;
/// <summary>Box of both members around the pair's reference point (catalog-padded).</summary>
public double MinX => System.Math.Min(A.MinX, Dx + B.MinX);
public double MinY => System.Math.Min(A.MinY, Dy + B.MinY);
public double MaxX => System.Math.Max(A.MaxX, Dx + B.MaxX);
public double MaxY => System.Math.Max(A.MaxY, Dy + B.MaxY);
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
}
/// <summary>
/// Best-fit pairs offered to the packer for part types with two or more copies.
///
/// A pair is placed through the existing free regions: its reference point must be legal for
/// member A and, shifted by the pair offset, for member B (region A intersected with region B
/// moved back by the offset). No new no-fit polygons are built. The members' clearance from
/// each other comes from the best-fit search and is re-checked here once per pair with the
/// layout check at the job spacing; a pair that fails is never offered.
/// </summary>
internal static class PairCatalog
{
/// <summary>Best-fit results tried per type, in rank order.</summary>
private const int CandidatesPerType = 12;
/// <summary>Pairs kept per type; each one costs a region intersection per placement.</summary>
private const int PairsPerType = 2;
private const double AngleTolerance = 1e-6;
/// <summary>
/// Pairs for every type with quantity two or more, for one part spacing. A member rotation
/// missing from the catalog gets a pair-only orientation (indexed after the catalog's), so
/// both members always own cached footprints; such orientations are never offered as singles.
/// </summary>
/// <param name="sheetLength">Largest sheet X extent offered at this spacing.</param>
/// <param name="sheetWidth">Largest sheet Y extent offered at this spacing.</param>
public static IReadOnlyDictionary<int, IReadOnlyList<PairPose>> Build(
IReadOnlyList<PartType> types,
double spacing,
double sheetLength,
double sheetWidth,
CancellationToken token
)
{
var result = new Dictionary<int, IReadOnlyList<PairPose>>();
foreach (var type in types)
{
token.ThrowIfCancellationRequested();
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
continue;
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
continue;
var pairs = BuildForType(type, geometry, spacing, sheetLength, sheetWidth, token);
if (pairs.Count > 0)
result[type.Index] = pairs;
}
return result;
}
private static List<PairPose> BuildForType(
PartType type,
JobPartGeometry geometry,
double spacing,
double sheetLength,
double sheetWidth,
CancellationToken token
)
{
var drawing = new Drawing(type.Part.Id, DrawingJobMapper.ToProgram(type.Part.Geometry));
List<BestFitResult> fits;
try
{
fits = BestFitCache.GetOrCompute(drawing, sheetLength, sheetWidth, spacing);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return new List<PairPose>();
}
finally
{
// The drawing exists only for this call; release its cache entry now.
BestFitCache.Invalidate(drawing);
}
// Best-fit evaluates in parallel, so equal-area results arrive in any order. Sort on
// the candidate itself as well, so the same job always picks the same pairs.
var ranked = fits
.Where(f => f.Keep)
.OrderBy(f => f.RotatedArea)
.ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation)
.ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y)
.ThenBy(f => f.OptimalRotation)
.Take(CandidatesPerType);
var pairs = new List<PairPose>();
var extra = new List<Orientation>();
foreach (var fit in ranked)
{
token.ThrowIfCancellationRequested();
List<Part> members;
try
{
members = fit.BuildSourceParts(drawing);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
continue;
}
if (members.Count != 2)
continue;
// The whole pair may also turn a quarter: offer it along each sheet axis.
foreach (var turn in new[] { 0.0, System.Math.PI / 2 })
{
var pair = Resolve(type, extra, geometry, members, turn, spacing);
if (pair != null && !pairs.Any(p => SamePair(p, pair)))
pairs.Add(pair);
if (pairs.Count >= PairsPerType)
return pairs;
}
}
return pairs;
}
internal static PairPose? Resolve(
PartType type,
List<Orientation> extra,
JobPartGeometry geometry,
List<Part> members,
double turn,
double spacing
)
{
// Turning a part about the origin turns its program and its location together,
// so the members keep their relative placement.
var poses = members
.Select(member =>
{
var location = member.Location.Rotate(turn);
return (Rotation: Angle.NormalizeRad(member.Rotation + turn), location.X, location.Y);
})
.ToArray();
if (!type.Part.Rotation.Allows(poses[0].Rotation) || !type.Part.Rotation.Allows(poses[1].Rotation))
return null;
var a = new NestJobPlacement(type.Part.Id, 0, poses[0].X, poses[0].Y, poses[0].Rotation);
var b = new NestJobPlacement(type.Part.Id, 1, poses[1].X, poses[1].Y, poses[1].Rotation);
if (!NestLayoutCheck.Clears(geometry, a, geometry, b, spacing))
return null;
var oa = Find(type, extra, poses[0].Rotation);
var ob = Find(type, extra, poses[1].Rotation);
if (oa == null || ob == null)
return null;
return new PairPose { A = oa, B = ob, Dx = poses[1].X - poses[0].X, Dy = poses[1].Y - poses[0].Y };
}
private static Orientation? Find(PartType type, List<Orientation> extra, double rotation)
{
foreach (var orientation in type.Orientations.Concat(extra))
if (SameAngle(orientation.Rotation, rotation))
return orientation;
var added = PartCatalog.CreateOrientation(type, type.Orientations.Count + extra.Count, rotation);
if (added != null)
extra.Add(added);
return added;
}
private static bool SamePair(PairPose first, PairPose second) =>
ReferenceEquals(first.A, second.A)
&& ReferenceEquals(first.B, second.B)
&& System.Math.Abs(first.Dx - second.Dx) < 1e-6
&& System.Math.Abs(first.Dy - second.Dy) < 1e-6;
private static bool SameAngle(double first, double second)
{
var difference = Angle.NormalizeRad(first - second);
return difference < AngleTolerance || Angle.TwoPI - difference < AngleTolerance;
}
}
@@ -41,9 +41,6 @@ internal sealed class PartType
public required NestJobPart Part { get; init; } public required NestJobPart Part { get; init; }
public required double Area { get; init; } public required double Area { get; init; }
public required IReadOnlyList<Orientation> Orientations { get; init; } public required IReadOnlyList<Orientation> Orientations { get; init; }
/// <summary>Analytic perimeter the orientations were flattened from; null when unreadable.</summary>
public Shape? Perimeter { get; init; }
} }
/// <summary> /// <summary>
@@ -100,32 +97,11 @@ internal static class PartCatalog
} }
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline)); var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
types.Add(new PartType types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
{
Index = index,
Part = part,
Area = area,
Orientations = orientations,
Perimeter = perimeter,
});
} }
return types; return types;
} }
/// <summary>
/// An extra pose of <paramref name="type"/> at <paramref name="angle"/>, flattened like the
/// catalog's own. <paramref name="index"/> must be unique within the type, because NFP
/// caches key on it.
/// </summary>
internal static Orientation? CreateOrientation(PartType type, int index, double angle)
{
if (type.Perimeter == null || type.Orientations.Count == 0)
return null;
var tolerance = type.Orientations[0].Tolerance;
var outline = Polygonize(type.Perimeter, angle, tolerance);
return outline.Count < 3 ? null : MakeOrientation(type.Index, index, angle, outline, tolerance);
}
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) => private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
JobPartGeometry.TryRead(geometry)?.Perimeter; JobPartGeometry.TryRead(geometry)?.Perimeter;
@@ -215,145 +215,6 @@ public class OverlapReportStateTests
Assert.Equal("No material overlaps detected", state.Message); Assert.Equal("No material overlaps detected", state.Message);
} }
[Fact]
public void MovingOnePartRetainsOnlyUnaffectedPairsThroughRecheck()
{
var plate = PlateWithTwoPairs();
var state = new OverlapReportState();
var report = Analyze(plate);
Assert.True(state.TryPublish(state.Begin(plate), plate, report));
var untouched = report.Pairs[0];
plate.Parts[3].Offset(1e-10, 0);
Assert.False(state.EnsureFresh(plate));
Assert.Null(state.Report);
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
Assert.Same(untouched, Assert.Single(state.DisplayPairs));
var display = state.DisplayPairs;
Assert.False(state.EnsureFresh(plate));
Assert.Same(display, state.DisplayPairs);
var request = state.Begin(plate, automatic: true);
Assert.Equal(OverlapCheckStatus.Checking, state.Status);
Assert.Null(state.Report);
Assert.Same(display, state.DisplayPairs);
Assert.True(state.TryPublish(request, plate, Analyze(plate)));
Assert.Equal(2, state.DisplayPairs.Count);
Assert.Same(Assert.IsType<PlateOverlapReport>(state.Report).Pairs, state.DisplayPairs);
}
[Theory]
[InlineData("move")]
[InlineData("rotate")]
[InlineData("placed-program")]
[InlineData("clean-program")]
[InlineData("cutoff")]
[InlineData("replace")]
[InlineData("remove")]
[InlineData("reorder")]
public void FurtherEditsDropAffectedPairsEvenBeforeTheNextCompletedCheck(string edit)
{
var plate = PlateWithTwoPairs();
var state = new OverlapReportState();
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
plate.Parts[3].Offset(0.5, 0);
Assert.False(state.EnsureFresh(plate));
Assert.Single(state.DisplayPairs);
var request = state.Begin(plate, automatic: true);
var pending = Analyze(plate);
var part = plate.Parts[0];
switch (edit)
{
case "move": part.Offset(0, 1e-10); break;
case "rotate": part.Rotate(0.01); break;
case "placed-program": part.Update(); break;
case "clean-program": part.BaseDrawing.Program = (Program)part.BaseDrawing.Program.Clone(); break;
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
case "replace": plate.Parts[0] = Rectangle(); break;
case "remove": plate.Parts.RemoveAt(0); break;
case "reorder": (plate.Parts[0], plate.Parts[1]) = (plate.Parts[1], plate.Parts[0]); break;
}
Assert.False(state.EnsureFresh(plate));
Assert.Empty(state.DisplayPairs);
Assert.False(state.TryPublish(request, plate, pending));
Assert.Null(state.Report);
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
Assert.DoesNotContain("No material overlaps", state.Message);
}
[Theory]
[InlineData("invalidate")]
[InlineData("reset")]
[InlineData("plate")]
[InlineData("cancel")]
[InlineData("fail")]
public void HardInvalidationAndRequestFailureClearRetainedPairs(string edit)
{
var plate = PlateWithTwoPairs();
var state = new OverlapReportState();
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
plate.Parts[3].Offset(1, 0);
Assert.False(state.EnsureFresh(plate));
Assert.Single(state.DisplayPairs);
var request = state.Begin(plate);
switch (edit)
{
case "invalidate": state.Invalidate(); break;
case "reset": state.Reset(); break;
case "plate": Assert.False(state.EnsureFresh(new Plate())); break;
case "cancel": state.Cancel(); break;
case "fail": Assert.True(state.TryFail(request, plate)); break;
}
Assert.Empty(state.DisplayPairs);
Assert.Null(state.Report);
}
[Fact]
public void AdditionalUnrelatedMotionDoesNotReplaceTheRetainedDisplayList()
{
var plate = PlateWithTwoPairs();
var state = new OverlapReportState();
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
plate.Parts[3].Offset(1, 0);
Assert.False(state.EnsureFresh(plate));
var display = state.DisplayPairs;
Assert.Single(display);
plate.Parts[3].Offset(1, 0);
Assert.False(state.EnsureFresh(plate));
Assert.Same(display, state.DisplayPairs);
state.Begin(plate);
Assert.Same(display, state.DisplayPairs);
}
[Fact]
public void AppendingPartInvalidatesTheFullReportWithoutReplacingUnchangedDisplayPairs()
{
var plate = PlateWithParts();
var state = new OverlapReportState();
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
var display = state.DisplayPairs;
plate.Parts.Add(Rectangle());
state.Invalidate(plate); // Same entry point as the controller's collection event.
Assert.Null(state.Report);
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
Assert.Same(display, state.DisplayPairs);
Assert.Single(state.DisplayPairs);
Assert.False(state.EnsureFresh(plate));
Assert.Same(display, state.DisplayPairs);
}
private static Plate PlateWithTwoPairs()
{
var plate = PlateWithParts();
var a = Rectangle();
var b = Rectangle();
a.Offset(20, 0);
b.Offset(20, 0);
plate.Parts.Add(a);
plate.Parts.Add(b);
return plate;
}
private static PlateOverlapReport Analyze(Plate plate) => PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray()); private static PlateOverlapReport Analyze(Plate plate) => PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray());
private static Plate PlateWithParts() private static Plate PlateWithParts()
@@ -1,104 +0,0 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.Controls;
using OpenNest.Engine;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests;
public class ActionSelectAreaCutOffTests
{
public static IEnumerable<object[]> Cases()
{
foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal })
foreach (var quadrant in new[] { 1, 3 })
foreach (var farSide in new[] { false, true })
foreach (var alternate in new[] { false, true })
yield return new object[] { axis, quadrant, farSide, alternate };
}
[Theory]
[MemberData(nameof(Cases))]
public void SelectedAreaAndFillStayOnSelectedSideOfCutOff(
CutOffAxis axis, int quadrant, bool farSide, bool alternate) => RunSta(() =>
{
var plate = new Plate(new OpenNest.Geometry.Size(20, 20))
{
Quadrant = quadrant,
PartSpacing = 0.5,
};
var bounds = plate.WorkArea();
var position = new Vector(bounds.Left + 10, bounds.Bottom + 10);
// Definitions alone must constrain selection, before any cutoff parts exist.
plate.CutOffs.Add(new CutOff(position, axis));
using var view = new SelectionView { Plate = plate };
var action = new ActionSelectArea(view);
try
{
var offset = farSide ? 5 : -5;
view.SetPoint(axis == CutOffAxis.Vertical
? new Vector(position.X + offset, position.Y)
: new Vector(position.X, position.Y + offset));
if (alternate)
view.ToggleSelectionDirection();
// Invoke the same update used by mouse movement without pixel rounding.
typeof(ActionSelectArea).GetMethod("UpdateSelectedArea", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(action, null);
var area = action.SelectedArea;
Assert.NotEqual(Box.Empty, area);
var limit = (axis == CutOffAxis.Vertical ? position.X : position.Y)
+ (farSide ? plate.PartSpacing : -plate.PartSpacing);
Assert.Equal(limit, axis == CutOffAxis.Vertical
? (farSide ? area.Left : area.Right)
: (farSide ? area.Bottom : area.Top), 8);
Assert.Equal(axis == CutOffAxis.Vertical ? bounds.Width : bounds.Length,
axis == CutOffAxis.Vertical ? area.Width : area.Length, 8);
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(0, 3);
program.LineTo(3, 3);
program.LineTo(3, 0);
program.LineTo(0, 0);
// ActionFillArea passes SelectedArea directly to this service.
var parts = PlateFillService.FillItem("Default", plate,
new NestItem { Drawing = new Drawing("square", program) }, area, null, CancellationToken.None);
Assert.NotEmpty(parts);
Assert.All(parts, part =>
{
var box = part.BoundingBox;
Assert.True(box.Left >= area.Left - 1e-6 && box.Right <= area.Right + 1e-6);
Assert.True(box.Bottom >= area.Bottom - 1e-6 && box.Top <= area.Top + 1e-6);
});
}
finally
{
action.DisconnectEvents();
}
});
private sealed class SelectionView : PlateView
{
public void SetPoint(Vector point) => CurrentPoint = point;
public void ToggleSelectionDirection() => OnKeyUp(new KeyEventArgs(Keys.Space));
}
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try { action(); }
catch (Exception ex) { failure = ex; }
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The selection/fill test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
}
@@ -97,80 +97,6 @@ public class PlateOverlapOverlayTests
Assert.Null(run.View.OverlapOverlay.CachedPath); Assert.Null(run.View.OverlapOverlay.CachedPath);
}); });
[Fact]
public void UnchangedPairStaysVisibleDuringMotionAndPendingRecheck() => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Plate.Parts.Add(Rectangle(20));
run.View.Plate.Parts.Add(Rectangle(21));
run.Finish();
run.View.OverlapDisplay = OverlapDisplayMode.Both;
run.View.ZoomToPoint(new Vector(), 20);
using var image = new Bitmap(240, 240);
using var graphics = Graphics.FromImage(image);
var overlay = run.View.OverlapOverlay;
var untouchedPoint = run.View.PointWorldToGraph(new Vector(2, 2));
var movedPoint = run.View.PointWorldToGraph(new Vector(22, 2));
overlay.Draw(graphics);
Assert.True(overlay.CachedPath.IsVisible(untouchedPoint));
Assert.True(overlay.CachedPath.IsVisible(movedPoint));
run.View.Plate.Parts[3].Offset(1e-10, 0);
overlay.Draw(graphics);
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
Assert.Null(run.View.OverlapReport);
Assert.True(overlay.CachedPath.IsVisible(untouchedPoint));
Assert.False(overlay.CachedPath.IsVisible(movedPoint));
var retainedPath = overlay.CachedPath;
run.View.Plate.Parts[3].Offset(1, 0);
overlay.Draw(graphics);
Assert.Same(retainedPath, overlay.CachedPath);
Assert.Equal(1, run.Calls); // Paint never analyzes.
var task = run.Start();
var pending = run.Next();
overlay.Draw(graphics); // Both areas and centroid markers must render without a full report.
Assert.Equal(OverlapCheckStatus.Checking, run.View.OverlapStatus);
Assert.Same(retainedPath, overlay.CachedPath);
run.View.Plate.Parts[0].Offset(1e-10, 0);
overlay.Draw(graphics);
Assert.Null(overlay.CachedPath);
pending.Complete();
run.Pump(task);
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
Assert.Null(run.View.OverlapReport);
overlay.Draw(graphics);
Assert.Null(overlay.CachedPath); // Late completion cannot restore the removed pair.
});
[Theory]
[InlineData("editor")]
[InlineData("cancel")]
[InlineData("failure")]
public void RetainedPathClearsOnHardInvalidationOrRequestFailure(string edit) => RunSta(() =>
{
using var run = new OverlayRun();
run.View.Plate.Parts.Add(Rectangle(20));
run.Finish();
run.View.Plate.Parts[2].Offset(1, 0);
run.Paint();
var path = run.View.OverlapOverlay.CachedPath;
Assert.NotNull(path);
var task = run.Start();
var pending = run.Next();
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
switch (edit)
{
case "editor": run.View.InvalidateOverlapCheck(); pending.Complete(); break;
case "cancel": run.View.CancelOverlapCheck(); pending.Complete(); break;
case "failure": pending.Fail(); break;
}
run.Pump(task);
Assert.Null(run.View.OverlapReport);
run.Paint();
Assert.Null(run.View.OverlapOverlay.CachedPath);
});
[Fact] [Fact]
public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() => public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() =>
{ {
@@ -307,10 +233,11 @@ public class PlateOverlapOverlayTests
}); });
[Theory] [Theory]
[InlineData("add")]
[InlineData("remove")] [InlineData("remove")]
[InlineData("replace")] [InlineData("replace")]
[InlineData("clear")] [InlineData("clear")]
public void SlotChangingCollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() => public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
{ {
using var run = new OverlayRun(); using var run = new OverlayRun();
run.Finish(); run.Finish();
@@ -320,6 +247,7 @@ public class PlateOverlapOverlayTests
Assert.NotNull(run.View.OverlapOverlay.CachedPath); Assert.NotNull(run.View.OverlapOverlay.CachedPath);
switch (edit) switch (edit)
{ {
case "add": run.View.Plate.Parts.Add(Rectangle()); break;
case "remove": run.View.Plate.Parts.RemoveAt(0); break; case "remove": run.View.Plate.Parts.RemoveAt(0); break;
case "replace": run.View.Plate.Parts[0] = Rectangle(); break; case "replace": run.View.Plate.Parts[0] = Rectangle(); break;
case "clear": run.View.Plate.Parts.Clear(); break; case "clear": run.View.Plate.Parts.Clear(); break;
@@ -329,23 +257,6 @@ public class PlateOverlapOverlayTests
Assert.Null(run.View.OverlapOverlay.CachedPath); Assert.Null(run.View.OverlapOverlay.CachedPath);
}); });
[Fact]
public void AppendingPartRetainsTheExistingPairsPathButInvalidatesTheFullReport() => RunSta(() =>
{
using var run = new OverlayRun();
run.Finish();
run.Paint();
var path = run.View.OverlapOverlay.CachedPath;
Assert.NotNull(path);
run.View.Plate.Parts.Add(Rectangle());
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
Assert.Null(run.View.OverlapReport);
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
run.Paint();
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
Assert.Equal(1, run.Calls);
});
[Fact] [Fact]
public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() => public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() =>
{ {
+12 -26
View File
@@ -33,7 +33,7 @@ internal sealed class OverlapOverlayController : IDisposable
private ObservableList<Part> observedParts; private ObservableList<Part> observedParts;
private CancellationTokenSource cancellation; private CancellationTokenSource cancellation;
private GraphicsPath path; private GraphicsPath path;
private IReadOnlyList<PlateOverlapPair> pathPairs; private PlateOverlapReport pathReport;
private float pathScale; private float pathScale;
private Units capturedUnits; private Units capturedUnits;
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>(); private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
@@ -139,6 +139,7 @@ internal sealed class OverlapOverlayController : IDisposable
var source = new CancellationTokenSource(); var source = new CancellationTokenSource();
cancellation = source; cancellation = source;
var token = source.Token; var token = source.Token;
ReleasePath();
NotifyChanged(toStatusBar: !automatic); NotifyChanged(toStatusBar: !automatic);
try try
{ {
@@ -203,22 +204,13 @@ internal sealed class OverlapOverlayController : IDisposable
{ {
materialCache.Clear(); materialCache.Clear();
baseline = null; baseline = null;
state.Invalidate(); Invalidate();
CancelWorker();
ReleasePath();
NotifyChanged();
} }
public void Invalidate() public void Invalidate()
{ {
var generation = state.Generation; var generation = state.Generation;
var display = state.DisplayPairs; state.Invalidate();
state.Invalidate(view.Plate);
if (!ReferenceEquals(display, state.DisplayPairs))
{
ReleasePath();
ClearHover();
}
if (generation == state.Generation) if (generation == state.Generation)
{ {
// Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one // Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one
@@ -231,6 +223,7 @@ internal sealed class OverlapOverlayController : IDisposable
return; return;
} }
CancelWorker(); CancelWorker();
ReleasePath();
NotifyChanged(); NotifyChanged();
} }
@@ -257,8 +250,6 @@ internal sealed class OverlapOverlayController : IDisposable
private void NotifyChanged(bool toStatusBar = false) private void NotifyChanged(bool toStatusBar = false)
{ {
if (!ReferenceEquals(pathPairs, state.DisplayPairs))
ReleasePath();
ClearHover(); ClearHover();
if (disposed || view.IsDisposed || view.Disposing) if (disposed || view.IsDisposed || view.Disposing)
return; return;
@@ -352,7 +343,7 @@ internal sealed class OverlapOverlayController : IDisposable
UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below. UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below.
ValidateHoverTransform(); ValidateHoverTransform();
if (state.DisplayPairs.Count > 0) if (state.Report != null)
{ {
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both) if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
{ {
@@ -375,16 +366,11 @@ internal sealed class OverlapOverlayController : IDisposable
if (disposed) if (disposed)
return false; return false;
var generation = state.Generation; var generation = state.Generation;
var display = state.DisplayPairs;
var fresh = state.EnsureFresh(view.Plate); var fresh = state.EnsureFresh(view.Plate);
if (!ReferenceEquals(display, state.DisplayPairs))
{
ReleasePath();
ClearHover();
}
if (generation != state.Generation) if (generation != state.Generation)
{ {
CancelWorker(); CancelWorker();
ReleasePath();
NotifyChanged(); NotifyChanged();
} }
return fresh; return fresh;
@@ -471,7 +457,7 @@ internal sealed class OverlapOverlayController : IDisposable
using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale); using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale);
// Stack coincident pair labels instead of replacing them with a fragment count. // Stack coincident pair labels instead of replacing them with a fragment count.
var labelRows = new Dictionary<PointF, int>(); var labelRows = new Dictionary<PointF, int>();
foreach (var pair in state.DisplayPairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId)) foreach (var pair in state.Report.Pairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
{ {
var center = view.PointWorldToGraph(pair.Centroid); var center = view.PointWorldToGraph(pair.Centroid);
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y)) if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
@@ -546,13 +532,13 @@ internal sealed class OverlapOverlayController : IDisposable
private void EnsurePath() private void EnsurePath()
{ {
if (ReferenceEquals(pathPairs, state.DisplayPairs) && pathScale == view.ViewScale && path != null) if (ReferenceEquals(pathReport, state.Report) && pathScale == view.ViewScale && path != null)
return; return;
ReleasePath(); ReleasePath();
var next = new GraphicsPath(FillMode.Winding); var next = new GraphicsPath(FillMode.Winding);
try try
{ {
foreach (var pair in state.DisplayPairs) foreach (var pair in state.Report.Pairs)
{ {
foreach (var region in pair.Regions) foreach (var region in pair.Regions)
{ {
@@ -574,7 +560,7 @@ internal sealed class OverlapOverlayController : IDisposable
} }
} }
path = next; path = next;
pathPairs = state.DisplayPairs; pathReport = state.Report;
pathScale = view.ViewScale; pathScale = view.ViewScale;
} }
catch catch
@@ -606,7 +592,7 @@ internal sealed class OverlapOverlayController : IDisposable
{ {
path?.Dispose(); path?.Dispose();
path = null; path = null;
pathPairs = null; pathReport = null;
} }
public void Dispose() public void Dispose()
+7 -24
View File
@@ -34,10 +34,8 @@ fragment counts. This diagnostic checks shared material, not minimum spacing,
plate edges, or cutting-path crossings. plate edges, or cutting-path crossings.
In a nest window the active plate is checked automatically. Any layout edit In a nest window the active plate is checked automatically. Any layout edit
(add, remove, reorder, move, rotate, fill) invalidates the full report and shows (add, remove, reorder, move, rotate, fill) clears the overlay and shows
**Overlaps: check pending…**, but highlights for unchanged pairs remain visible. **Overlaps: check pending…**; once the layout has been unchanged for 0.5 s and no
Only highlights involving changed ordered input slots are removed; once the
layout has been unchanged for 0.5 s and no
mouse button, modal dialog, or fill progress window is active, the check reruns. mouse button, modal dialog, or fill progress window is active, the check reruns.
Automatic results appear only in the canvas label, so the status bar keeps the Automatic results appear only in the canvas label, so the status bar keeps the
last command's message, and an automatic check keeps Display > Off rather than last command's message, and an automatic check keeps Display > Off rather than
@@ -213,21 +211,8 @@ changed code count or rotation is detected). Preparing material dominates
first-check time for drawings with many holes. Incremental analysis reuses a pair first-check time for drawings with many holes. Incremental analysis reuses a pair
only when both parts have the same cached source, bit-identical pose, and the same only when both parts have the same cached source, bit-identical pose, and the same
relative input order (clipping is operand-order sensitive), then renumbers it. relative input order (clipping is operand-order sensitive), then renumbers it.
`InvalidateOverlapCheck()` clears the cache, baseline and every highlight, so `InvalidateOverlapCheck()` clears the cache and baseline, so in-place program
in-place program editors must keep calling it before loading. editors must keep calling it before loading.
Moving one part hides only highlights involving changed parts. Unchanged pairs
remain visible throughout the quiet period and background recheck, sharing their
existing immutable regions rather than recalculating them. `OverlapReportState`
keeps these `DisplayPairs` separate from its full `Report`: the full report is
unavailable and the label stays pending/out-of-date/checking until a fresh result
is published. Retained highlights are known overlaps, never an all-clear for the
edited layout. Each paint checks exact poses and references again, including
further edits while an earlier check is pending. Hover details remain disabled
until the full report is current. Collection edits conservatively discard pairs
whose ordered input slots changed; this display-only path does not renumber them.
Explicit geometry invalidation, plate switch, handle loss, cancellation and
failure clear all retained highlights.
Measured on 501 real PEP-converted plates with 2 to 384 parts, a from-scratch check Measured on 501 real PEP-converted plates with 2 to 384 parts, a from-scratch check
takes median 1 ms, p99 368 ms and max 6.5 s (a 299-part plate); an incremental takes median 1 ms, p99 368 ms and max 6.5 s (a 299-part plate); an incremental
@@ -242,7 +227,7 @@ PlateView draws the controller overlay after work-area/debug-remnant drawing and
before action paint subscribers and hover tooltips. One consistently wound path before action paint subscribers and hover tooltips. One consistently wound path
is filled once, avoiding fragment outlines, internal triangulation seams, and is filled once, avoiding fragment outlines, internal triangulation seams, and
darker triple coverage. World-to-graph conversion excludes pan, because PlateView darker triple coverage. World-to-graph conversion excludes pan, because PlateView
already applies origin translation. Paths are rebuilt for displayed-pair/scale changes, already applies origin translation. Paths are rebuilt for report/scale changes,
not ordinary repaints or panning. The state label saves/restores graphics state. not ordinary repaints or panning. The state label saves/restores graphics state.
Centroid hit tests use only cached report coordinates and DPI-scaled screen Centroid hit tests use only cached report coordinates and DPI-scaled screen
radii. Hover clears on edits, mode/request/view changes, leave, and teardown. radii. Hover clears on edits, mode/request/view changes, leave, and teardown.
@@ -273,8 +258,7 @@ coincident duplicates, and covers pair reuse, issue renumbering, cache clearing
cancellation. `OverlapAutoCheckSchedulerTests` covers the quiet period, interaction cancellation. `OverlapAutoCheckSchedulerTests` covers the quiet period, interaction
waits, and the no-retry rule for canceled or failed layouts. waits, and the no-retry rule for canceled or failed layouts.
`OverlapReportStateTests` verifies request supersession, exact pose/reference `OverlapReportStateTests` verifies request supersession, exact pose/reference
freshness, per-pair display retention through repeated edits and pending checks, freshness, stale clearing, cancellation, and incomplete-versus-clear messaging.
hard invalidation, cancellation, and incomplete-versus-clear messaging.
`PolygonAreaMomentsTests` covers analytic `PolygonAreaMomentsTests` covers analytic
centers, unequal/disconnected fragments, winding, closure, large translations, centers, unequal/disconnected fragments, winding, closure, large translations,
and invalid/overflow cases. `OverlapPairPresentationTests` checks adaptive unit and invalid/overflow cases. `OverlapPairPresentationTests` checks adaptive unit
@@ -293,8 +277,7 @@ dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
Linux can cross-build with `-p:EnableWindowsTargeting=true`, but that does not Linux can cross-build with `-p:EnableWindowsTargeting=true`, but that does not
execute Windows tests or verify appearance, DPI, or interaction. On Windows, execute Windows tests or verify appearance, DPI, or interaction. On Windows,
check partial overlap, containment, inside-hole placement, pan/zoom and quadrant check partial overlap, containment, inside-hole placement, pan/zoom and quadrant
alignment, changed-pair clearing and unchanged-pair retention during edits and alignment, stale clearing during edits/plate switches, converter cancellation,
pending rechecks, full clearing on plate switches and converter cancellation,
the pending label and automatic recheck after dragging a part onto another, the pending label and automatic recheck after dragging a part onto another,
crowded-marker PageUp/PageDown access to the last pair, and repeated crowded-marker PageUp/PageDown access to the last pair, and repeated
check/toggle/close cycles without GDI/disposed-control errors. check/toggle/close cycles without GDI/disposed-control errors.
+2 -25
View File
@@ -12,7 +12,7 @@ in `OpenNest.Engine/NestingEngines/<Name>/`, its tests in `OpenNest.Engine.Tests
| Engine | Best for | Method | | Engine | Best for | Method |
|---|---|---| |---|---|---|
| Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost | | Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost |
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan | | Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling, six whole-job strategy variants and a tail re-plan |
| StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking | | StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking |
| Default, Strip, Vertical Remnant, Horizontal Remnant | Single-strategy fills | The fixed placement strategies behind interactive fill | | Default, Strip, Vertical Remnant, Horizontal Remnant | Single-strategy fills | The fixed placement strategies behind interactive fill |
@@ -26,33 +26,10 @@ coincides with the plate work-area boundary. Internal leftover edges keep the st
tolerance, and actual part dimensions still determine spacing away from the plate boundary. tolerance, and actual part dimensions still determine spacing away from the plate boundary.
Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside
enclosed cutouts. For a part with two or more copies it also offers its best-fit pairs (two copies enclosed cutouts. Concave no-fit polygons are prepared with a single boundary/containment union.
interlocked, as the Best Fit viewer shows them) alongside the single copies, and places a pair
where both members' free regions allow it. Each pair's internal spacing is re-checked with the
layout check before it is offered, and only rotations the part's policy allows are used. A pair
may introduce legal rotations beyond the sampled single poses; these remain eligible even when
none of the sampled singles fits the stock. Both members block space separately, leaving their
notches and intervening gaps available for later parts. Concave no-fit polygons are prepared
with a single boundary/containment union.
Any remaining numerical hole is filled only when its entire ring is certified to lie in forbidden Any remaining numerical hole is filled only when its entire ring is certified to lie in forbidden
space, preserving genuine enclosed placement pockets without changing spacing tolerances. space, preserving genuine enclosed placement pockets without changing spacing tolerances.
When remaining demand exceeds two, Irregular also offers Default Fill patterns as optional
multi-member candidates, not as solid bounding boxes or a whole-job Default fallback. It searches
the empty work area and physical leftover space for up to two high-area rectangles. Occupied
outlines are expanded by part spacing before rectangle search. Each sheet prepares blocks initially
and after its first placement, for up to four high-demand-area types; each type has at most eight
new Fill preparations per spacing per solve. Repeated rectangles reuse private drawing/candidate
caches. Quantity-one and quantity-two requests never run block Fill.
Block members are trimmed to remaining demand, mapped back to source-frame rotations, and checked
for legal rotations and internal material clearance before competing with singles and pairs.
Group-only rotations do not expand the single-part rotation choices. Placement intersects all
member free regions and subtracts each placed member separately, preserving usable gaps. A failed
or invalid Fill proposal leaves singles and pairs available. Large enclosed-pocket blocks remain
pending the hole-geometry integration; containment cutting order and shop-use safety acceptance
remain separate sequencer/verification work.
## Renamed engines ## Renamed engines
Earlier releases shipped these as plug-ins under other names. The registry maps the old names so Earlier releases shipped these as plug-ins under other names. The registry maps the old names so
@@ -1,86 +0,0 @@
# Small synthetic-nest validity gate
From the repository root, with .NET 8 and Python 3.9 or later:
```sh
python3 scripts/check-synthetic-nests.py
```
The runner builds the existing `OpenNest.Benchmark` in Release and invokes it with
`--engines Irregular --parallel 2 --progress`. It prints the evidence directory;
`build.log`, `benchmark.log`, `results.csv` and (only on success) `accepted.json`
are retained there. Use `--output <directory>` to choose a durable location.
Progress is written to `benchmark.log`, not streamed to the terminal.
The entire build + benchmark has a 300-second watchdog. Nonzero subprocess exits,
cancellation and timeout fail the gate. On Unix the runner kills its owned process
session, including descendants; Windows uses `taskkill /T /F`. Build servers are
disabled for this check. `--timeout <seconds>` may shorten, never extend, the budget.
Default concurrency is two; `--parallel 1` is serial and `--parallel 4` is the upper
bound for a machine with adequate resources. Do not use the override to oversubscribe
CI or a busy host.
## The six committed fixtures
These are wholly synthetic, generated from CNC polylines through `Drawing`, `Nest`
and `NestWriter`. No drawing archives or customer jobs are involved. All use inches,
quadrant 1, part spacing 0.25, edge spacing 0.25 on every side, and no salvage credit.
Sheet dimensions below are X length by Y width (the `Size` API takes width first).
| File stem | Synthetic demand | Sheet X × Y | Expected sheets |
| --- | --- | --- | --- |
| `single-triangle` | one 6 × 5 triangle | 20 × 20 | 1 |
| `paired-wedges` | two wedges, vertices (0,0), (8,0), (6,20), (0,20) | 30 × 15 | 1 |
| `repeated-ell-fill` | seven 9 × 7 L shapes, arm thickness 3 | 60 × 40 | 1 |
| `mixed-irregular` | one triangle, two L shapes, three pentagons | 30 × 25 | 1 |
| `rotation-edge-fit` | one 8 × 3 rectangle that must turn to fit | 3.52 × 8.52 | 1 |
| `multi-sheet` | three 8 × 8 squares, only one fits each sheet | 10 × 10 | 3 |
All drawings permit automatic rotation. The edge-fit fixture deliberately leaves
0.02 extra work-area room: it is a tight rotated fit, not an exact-contact test.
The qty-two fixture checks one-sheet interlocking fulfillment; the qty-greater-than-two
fixture exercises a job eligible for Fill-block candidates. This smoke gate does not
assert which internal candidate won; the existing pair/block unit tests retain those
contracts. These are ordinary solid contours, not acceptance of future hole nesting,
containment sequencing or shop-use safety.
To regenerate the six archives through the repository's IO APIs:
```sh
dotnet run --project test-data/synthetic-nests/Generator/Generator.csproj -- test-data/synthetic-nests
```
The generator fixes dates/ZIP timestamps and leaves customer, maker and source-path
metadata empty. Inspect the generated diff before committing. It never reads external
DXFs. Fixtures contain demand and plate defaults, not prepacked layouts.
## Fail-closed result checking
The runner requires exactly the six expected input files, known positive demand and
exactly one Irregular result for each. `Baseline` rows are ignored, not accepted as
engine results. Unknown engines/jobs, duplicates, missing results, empty output,
invalid layouts, crashes, timeout notes, incomplete fulfillment, changed inputs and
unexpected sheet counts all fail. Benchmark's exit code zero alone is not acceptance.
CSV flags must be the exact emitted `True`/`False` values; placed/requested demand
must match the fixture contract. A saved CSV can be checked without solving:
```sh
python3 scripts/check-synthetic-nests.py --check-csv <results.csv>
python3 scripts/test_check_synthetic_nests.py -v
```
The unit checks use labeled synthetic CSV rows and a real watchdog process-tree
probe. The end-to-end command uses actual benchmark results and the production
independent layout validator. This gate supplements, never replaces, the existing
geometry, contract, cancellation, sequencing, posting and cross-platform unit suites.
GitHub's cross-platform workflow runs both the checker tests and the six-fixture gate.
Windows runtime acceptance is separate; a Linux pass does not certify Windows tree
termination or desktop/shop interactions.
## Validity is not speed
Parallel elapsed times are diagnostic only. Serial and parallel runs must account for
the same fixtures and fulfill the same demands; they do not promise identical poses.
Even `--parallel 1` here is a smoke gate, not a controlled performance comparison.
Actual speed claims need serial quiet, interleaved comparisons with frozen inputs and
source provenance. Full customer-corpus audits remain opt-in and uncommitted.
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""Bounded validity/fulfillment gate; parallel elapsed times are not speed evidence."""
import argparse
import csv
import hashlib
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
import zipfile
ROOT = Path(__file__).resolve().parents[1]
# Exact demand and sheet count: empty, skipped, and partial jobs must never pass.
EXPECTED = {
"single-triangle": (1, 1),
"paired-wedges": (2, 1),
"repeated-ell-fill": (7, 1),
"mixed-irregular": (6, 1),
"rotation-edge-fit": (1, 1),
"multi-sheet": (3, 3),
}
class GateError(Exception):
pass
def check_fixtures(directory):
files = list(directory.rglob("*.nest"))
names = [path.stem for path in files]
if len(names) != len(EXPECTED) or set(names) != set(EXPECTED):
raise GateError(f"Expected exactly six fixtures {sorted(EXPECTED)}; found {sorted(names)}")
if list(directory.rglob("*.manifest.json")):
raise GateError("Unexpected manifest in synthetic fixture directory")
hashes = {}
for path in sorted(files):
with zipfile.ZipFile(path) as archive:
metadata = json.loads(archive.read("nest.json").decode("utf-8-sig"))
drawings = metadata["drawings"]
demand = sum(drawing["quantity"]["required"] for drawing in drawings)
if demand != EXPECTED[path.stem][0] or not drawings:
raise GateError(f"Unexpected demand in {path.name}: {demand}")
if any(metadata.get(key) for key in ("customer", "madeBy")):
raise GateError(f"Non-synthetic identity metadata in {path.name}")
for drawing in drawings:
if drawing.get("customer") or drawing.get("source", {}).get("path"):
raise GateError(f"Non-synthetic drawing metadata in {path.name}")
if archive.comment or any(entry.comment for entry in archive.infolist()):
raise GateError(f"Unexpected ZIP comments in {path.name}")
hashes[path.name] = hashlib.sha256(path.read_bytes()).hexdigest()
return hashes
def check_csv(path):
seen = set()
outcomes = []
with path.open(newline="", encoding="utf-8-sig") as stream:
reader = csv.DictReader(stream)
required = {"Job", "Engine", "Valid", "Crashed", "FullyPlaced", "PartsPlaced",
"PartsRequested", "PlatesUsed", "Notes"}
if not required.issubset(reader.fieldnames or []):
raise GateError("Missing benchmark CSV columns")
for row in reader:
if row["Engine"] == "Baseline":
continue
if row["Engine"] != "Irregular":
raise GateError(f"Unexpected engine: {row['Engine']}")
name = row["Job"]
if name not in EXPECTED or name in seen:
raise GateError(f"Unknown or duplicate fixture result: {name}")
seen.add(name)
demand, sheets = EXPECTED[name]
if (row["Valid"] != "True" or row["Crashed"] != "False"
or row["FullyPlaced"] != "True" or row["Notes"].strip()):
raise GateError(f"Invalid, crashed, timed-out or incomplete result: {name}: {row}")
if (int(row["PartsRequested"]) != demand or int(row["PartsPlaced"]) != demand
or int(row["PlatesUsed"]) != sheets):
raise GateError(f"Unexpected fulfillment or sheet count: {name}: {row}")
outcomes.append(row)
if seen != set(EXPECTED):
raise GateError(f"Missing fixture results: {sorted(set(EXPECTED) - seen)}")
return outcomes
def terminate_tree(process):
if os.name == "nt":
if process.poll() is None:
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT,
timeout=10)
else:
# Also remove any descendants left after their parent exits. The session is ours alone.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=10)
def run_process(command, deadline, log):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise GateError("Whole-check watchdog expired")
print("Running: " + " ".join(map(str, command)), flush=True)
with log.open("w", encoding="utf-8") as output:
process = subprocess.Popen(command, cwd=ROOT, stdout=output, stderr=subprocess.STDOUT,
start_new_session=os.name != "nt",
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0)
try:
code = process.wait(timeout=remaining)
if code:
raise GateError(f"Subprocess exited {code}; see {log}")
except subprocess.TimeoutExpired as error:
raise GateError(f"Whole-check watchdog expired; see {log}") from error
finally:
terminate_tree(process)
def bounded_int(low, high):
def parse(value):
number = int(value)
if not low <= number <= high:
raise argparse.ArgumentTypeError(f"must be between {low} and {high}")
return number
return parse
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--parallel", type=bounded_int(1, 4), default=2)
parser.add_argument("--timeout", type=bounded_int(1, 300), default=300,
help="whole build + solve budget, seconds (maximum 300)")
parser.add_argument("--fixtures", type=Path, default=ROOT / "test-data/synthetic-nests")
parser.add_argument("--output", type=Path, help="retain logs/CSV/provenance in this directory")
parser.add_argument("--check-csv", type=Path, help="check an existing CSV without solving")
args = parser.parse_args(argv)
try:
if args.check_csv:
outcomes = check_csv(args.check_csv)
else:
deadline = time.monotonic() + args.timeout
hashes = check_fixtures(args.fixtures)
output = args.output or Path(tempfile.mkdtemp(prefix="opennest-synthetic-"))
output = output.resolve()
output.mkdir(parents=True, exist_ok=True)
(output / "accepted.json").unlink(missing_ok=True)
print(f"Evidence directory: {output}", flush=True)
project = ROOT / "OpenNest.Benchmark/OpenNest.Benchmark.csproj"
run_process(["dotnet", "build", str(project), "-c", "Release",
"--disable-build-servers"], deadline, output / "build.log")
assembly = ROOT / "OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll"
command = ["dotnet", str(assembly), str(args.fixtures.resolve()),
"--engines", "Irregular", "--parallel", str(args.parallel),
"--progress", "--csv", str(output / "results.csv")]
run_process(command, deadline, output / "benchmark.log")
outcomes = check_csv(output / "results.csv")
if check_fixtures(args.fixtures) != hashes:
raise GateError("Fixture inputs changed during the check")
(output / "accepted.json").write_text(json.dumps({
"fixtures": hashes, "parallel": args.parallel,
"benchmark_command": list(map(str, command)), "outcomes": outcomes,
"benchmark_assembly_sha256": hashlib.sha256(assembly.read_bytes()).hexdigest(),
"checker_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"timing": "diagnostic only; not speed evidence",
}, indent=2) + "\n", encoding="utf-8")
for row in sorted(outcomes, key=lambda row: row["Job"]):
print(f"PASS {row['Job']}: {row['PartsPlaced']}/{row['PartsRequested']} parts, "
f"{row['PlatesUsed']} sheet(s)")
print("PASS: exactly six Irregular results, all valid and fully placed")
return 0
except KeyboardInterrupt:
print("FAIL: cancelled; owned subprocess tree terminated", file=sys.stderr)
return 130
except (GateError, OSError, ValueError, KeyError, zipfile.BadZipFile,
subprocess.SubprocessError) as error:
print(f"FAIL: {error}", file=sys.stderr)
return 1
def cancel(_signum, _frame):
raise KeyboardInterrupt()
if __name__ == "__main__":
# Treat CI/terminal termination the same as Ctrl-C; run_process's finally owns cleanup.
signal.signal(signal.SIGTERM, cancel)
sys.exit(main())
-171
View File
@@ -1,171 +0,0 @@
"""Unit data below is intentionally synthetic CSV, not recorded benchmark evidence."""
import csv
import importlib.util
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
import unittest
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("synthetic_gate", Path(__file__).with_name("check-synthetic-nests.py"))
assert spec is not None and spec.loader is not None
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
class SyntheticGateTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory(prefix="opennest-gate-test-")
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.csv = self.root / "results.csv"
self.rows = [dict(Job=name, Engine="Irregular", Valid="True", Crashed="False",
FullyPlaced="True", PartsPlaced=str(demand), PartsRequested=str(demand),
PlatesUsed=str(sheets), Notes="")
for name, (demand, sheets) in gate.EXPECTED.items()]
def write_csv(self, rows):
with self.csv.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=list(self.rows[0]))
writer.writeheader()
writer.writerows(rows)
def test_exact_six_and_baseline_filter(self):
self.write_csv(self.rows + [dict(self.rows[0], Engine="Baseline", Valid="False")])
self.assertEqual(6, len(gate.check_csv(self.csv)))
def test_fail_closed_result_cases(self):
cases = {
"invalid": dict(Valid="False"),
"crashed": dict(Crashed="True", Notes="exception"),
"timeout": dict(Valid="False", Notes="Timed out after 5 minutes"),
"incomplete": dict(FullyPlaced="False", PartsPlaced="0"),
"empty": dict(PartsPlaced="0", PartsRequested="0", PlatesUsed="0"),
"unknown": dict(Job="unknown"),
"engine": dict(Engine="Unknown"),
"demand": dict(PartsRequested="99"),
"sheets": dict(PlatesUsed="0"),
"notes": dict(Notes="validation warning"),
}
for label, change in cases.items():
with self.subTest(label=label):
self.write_csv([dict(self.rows[0], **change)] + self.rows[1:])
with self.assertRaises(gate.GateError):
gate.check_csv(self.csv)
for rows in ([], self.rows[:-1], self.rows + [self.rows[0]]):
self.write_csv(rows)
with self.assertRaises(gate.GateError):
gate.check_csv(self.csv)
def test_malformed_csv_returns_nonzero(self):
self.write_csv([dict(self.rows[0], PartsPlaced="not-a-number")] + self.rows[1:])
self.assertEqual(1, gate.main(["--check-csv", str(self.csv)]))
self.csv.write_text("Job,Engine\n", encoding="utf-8")
self.assertEqual(1, gate.main(["--check-csv", str(self.csv)]))
def test_missing_fixture_actual_entrypoint(self):
self.assertEqual(1, gate.main(["--fixtures", str(self.root)]))
def test_committed_fixtures_are_complete_and_synthetic(self):
self.assertEqual(6, len(gate.check_fixtures(gate.ROOT / "test-data/synthetic-nests")))
def test_concurrency_and_watchdog_are_bounded(self):
for args in (["--parallel", "0"], ["--parallel", "5"], ["--timeout", "301"]):
with self.assertRaises(SystemExit) as error:
gate.main(args)
self.assertEqual(2, error.exception.code)
def test_nonzero_child_fails(self):
script = self.root / "exit.py"
script.write_text("raise SystemExit(7)\n", encoding="utf-8")
with self.assertRaisesRegex(gate.GateError, "exited 7"):
gate.run_process([sys.executable, str(script)], time.monotonic() + 10,
self.root / "exit.log")
@unittest.skipIf(os.name == "nt", "POSIX SIGTERM probe; Windows requires runtime acceptance")
def test_cancellation_kills_ready_owned_tree(self):
parent = self.root / "cancel-parent.py"
pidfile = self.root / "cancel-pids.txt"
ready = self.root / "cancel-ready"
parent.write_text(
"import os, subprocess, sys, time\n"
"from pathlib import Path\n"
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
f"Path({str(pidfile)!r}).write_text(str(os.getpid()) + ' ' + str(child.pid))\n"
f"Path({str(ready)!r}).touch()\n"
"time.sleep(60)\n", encoding="utf-8")
wrapper = self.root / "cancel-wrapper.py"
wrapper.write_text(
"import importlib.util, signal, sys, time\n"
"from pathlib import Path\n"
"sys.dont_write_bytecode = True\n"
f"spec = importlib.util.spec_from_file_location('gate', {str(Path(__file__).with_name('check-synthetic-nests.py'))!r})\n"
"gate = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(gate)\n"
"signal.signal(signal.SIGTERM, gate.cancel)\n"
"try:\n"
f" gate.run_process([sys.executable, {str(parent)!r}], time.monotonic() + 15, "
f"Path({str(self.root / 'cancel.log')!r}))\n"
"except KeyboardInterrupt:\n sys.exit(130)\n", encoding="utf-8")
process = subprocess.Popen([sys.executable, str(wrapper)], start_new_session=True)
try:
end = time.monotonic() + 10
while not ready.exists() and time.monotonic() < end:
time.sleep(0.01)
self.assertTrue(ready.exists(), "cancellation tree never became ready")
pids = [int(value) for value in pidfile.read_text().split()]
process.send_signal(signal.SIGTERM)
self.assertEqual(130, process.wait(timeout=10))
self.assert_quiescent(pids)
finally:
gate.terminate_tree(process)
def assert_quiescent(self, pids):
for pid in pids:
end = time.monotonic() + 5
while True:
status = Path(f"/proc/{pid}/stat")
if status.exists() and status.read_text().split()[2] == "Z":
break # Linux init may not yet have reaped a killed orphan.
try:
os.kill(pid, 0)
except ProcessLookupError:
break
self.assertLess(time.monotonic(), end, f"owned process {pid} remains alive")
time.sleep(0.01)
@unittest.skipIf(os.name == "nt", "POSIX session-tree probe; Windows uses taskkill /T /F")
def test_watchdog_kills_ready_parent_and_grandchild(self):
# A real owned process tree, synchronized before the watchdog. No benchmark results mocked.
child = self.root / "child.py"
child.write_text("import time\ntime.sleep(60)\n", encoding="utf-8")
parent = self.root / "parent.py"
pidfile = self.root / "pids.txt"
ready = self.root / "ready"
parent.write_text(
"import os, subprocess, sys, time\n"
"from pathlib import Path\n"
f"child = subprocess.Popen([sys.executable, {str(child)!r}])\n"
f"Path({str(pidfile)!r}).write_text(str(os.getpid()) + ' ' + str(child.pid))\n"
f"Path({str(ready)!r}).touch()\n"
"time.sleep(60)\n", encoding="utf-8")
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(gate.run_process, [sys.executable, str(parent)],
time.monotonic() + 5, self.root / "timeout.log")
end = time.monotonic() + 4
while not ready.exists() and time.monotonic() < end:
time.sleep(0.01)
self.assertTrue(ready.exists(), "child tree never became ready")
pids = [int(value) for value in pidfile.read_text().split()]
with self.assertRaisesRegex(gate.GateError, "watchdog expired"):
future.result(timeout=10)
self.assert_quiescent(pids)
if __name__ == "__main__":
unittest.main()
@@ -1,11 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>OpenNest.SyntheticNests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../OpenNest.IO/OpenNest.IO.csproj" />
</ItemGroup>
</Project>
@@ -1,86 +0,0 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
using OpenNest;
using OpenNest.Geometry;
using OpenNest.IO;
using CncProgram = OpenNest.CNC.Program;
namespace OpenNest.SyntheticNests;
internal static class Program
{
private static void Main(string[] args)
{
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var output = args.Length == 1 ? args[0] : "test-data/synthetic-nests";
Directory.CreateDirectory(output);
Write(output, "single-triangle", new Size(20, 20),
Drawing("triangle", Polyline((0, 0), (6, 0), (2, 5)), 1));
Write(output, "paired-wedges", new Size(15, 30),
Drawing("wedge", Polyline((0, 0), (8, 0), (6, 20), (0, 20)), 2));
Write(output, "repeated-ell-fill", new Size(40, 60), Drawing("ell", Ell(), 7));
Write(output, "mixed-irregular", new Size(25, 30),
Drawing("triangle", Polyline((0, 0), (6, 0), (2, 5)), 1),
Drawing("ell", Ell(), 2),
Drawing("pentagon", Polyline((0, 0), (4, 0), (5, 3), (2, 5), (0, 3)), 3));
Write(output, "rotation-edge-fit", new Size(8.52, 3.52),
Drawing("long-bar", Rectangle(8, 3), 1));
Write(output, "multi-sheet", new Size(10, 10), Drawing("square", Rectangle(8, 8), 3));
}
private static CncProgram Ell() =>
Polyline((0, 0), (9, 0), (9, 3), (3, 3), (3, 7), (0, 7));
private static CncProgram Rectangle(double x, double y) =>
Polyline((0, 0), (x, 0), (x, y), (0, y));
private static CncProgram Polyline(params (double X, double Y)[] points)
{
var program = new CncProgram();
program.MoveTo(points[0].X, points[0].Y);
foreach (var point in points.Skip(1))
program.LineTo(point.X, point.Y);
program.LineTo(points[0].X, points[0].Y);
return program;
}
private static Drawing Drawing(string name, CncProgram program, int quantity)
{
var drawing = new Drawing(name, program);
drawing.Quantity.Required = quantity;
drawing.Constraints.AllowAnyRotation();
return drawing;
}
private static void Write(string output, string name, Size size, params Drawing[] drawings)
{
var nest = new Nest(name) { SalvageRate = 0, Units = Units.Inches };
nest.PlateDefaults.Size = size;
nest.PlateDefaults.Quadrant = 1;
nest.PlateDefaults.PartSpacing = 0.25;
nest.PlateDefaults.EdgeSpacing = new Spacing(0.25, 0.25);
foreach (var drawing in drawings)
nest.Drawings.Add(drawing);
var path = Path.Combine(output, name + ".nest");
if (!new NestWriter(nest).Write(path))
throw new IOException("Fixture write failed: " + name);
// Remove wall-clock metadata so regeneration is byte-stable. No customer data is read.
using var archive = ZipFile.Open(path, ZipArchiveMode.Update);
var entry = archive.GetEntry("nest.json");
JsonNode metadata;
using (var reader = new StreamReader(entry.Open()))
metadata = JsonNode.Parse(reader.ReadToEnd());
metadata["dateCreated"] = "2026-01-01T00:00:00";
metadata["dateLastModified"] = "2026-01-01T00:00:00";
entry.Delete();
entry = archive.CreateEntry("nest.json");
using (var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)))
writer.Write(metadata.ToJsonString());
foreach (var item in archive.Entries)
item.LastWriteTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
Console.WriteLine(name);
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.