mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-02 12:28:47 -04:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14a7ce43a7 | ||
|
|
d8b763da13 | ||
|
|
c01a348243 | ||
|
|
e886bcfa35 | ||
|
|
4fa0f636ce | ||
|
|
94e23e4b7a |
@@ -0,0 +1,32 @@
|
|||||||
|
name: Cross-platform tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
- name: Run OpenNest.Tests
|
||||||
|
run: dotnet test OpenNest.Tests/OpenNest.Tests.csproj
|
||||||
|
- name: Run OpenNest.Engine.Tests
|
||||||
|
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
|
||||||
|
run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
||||||
@@ -32,6 +32,20 @@ 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,4 +1,6 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Diagnostics;
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
@@ -9,12 +11,16 @@ 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
|
||||||
@@ -36,7 +42,8 @@ public sealed class OverlapReportState
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long Begin(Plate plate, bool automatic = false)
|
public long Begin(Plate plate, bool automatic = false)
|
||||||
{
|
{
|
||||||
Clear(OverlapCheckStatus.Checking);
|
RefreshDisplayPairs(plate);
|
||||||
|
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;
|
||||||
@@ -48,6 +55,9 @@ 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;
|
||||||
@@ -66,20 +76,52 @@ 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();
|
Invalidate(plate);
|
||||||
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)
|
||||||
@@ -88,8 +130,10 @@ public sealed class OverlapReportState
|
|||||||
|
|
||||||
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
||||||
|
|
||||||
private void Clear(OverlapCheckStatus status)
|
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
|
||||||
{
|
{
|
||||||
|
if (!preserveDisplay)
|
||||||
|
ClearDisplayPairs();
|
||||||
Generation++;
|
Generation++;
|
||||||
Report = null;
|
Report = null;
|
||||||
uncheckedPartCount = 0;
|
uncheckedPartCount = 0;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#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,17 +55,30 @@ 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(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
|
public FrontierPacker(
|
||||||
|
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;
|
||||||
@@ -76,39 +89,102 @@ 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)
|
||||||
if (stock.Fits(o.Width, o.Height))
|
Track(o, single: true);
|
||||||
states.Add(new Region(o, work));
|
if (left[type.Index] < 2 || !pairs.TryGetValue(type.Index, out var typePairs))
|
||||||
|
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, front);
|
var choice = Choose(states, offered, blockStates, front);
|
||||||
if (choice == null)
|
if (choice == null)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
var (region, point) = choice.Value;
|
var typeIndex = choice[0].Orientation.TypeIndex;
|
||||||
var part = new Placed(region.Orientation, point.x, point.y);
|
foreach (var part in choice)
|
||||||
|
{
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
if (--left[typeIndex] == 0)
|
left[typeIndex] -= choice.Count;
|
||||||
|
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 part now blocks. Regions are
|
// Each surviving region loses the positions the new parts now block. 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);
|
counter.Add(snapshot.Length);
|
||||||
Parallel.For(
|
Parallel.For(
|
||||||
0,
|
0,
|
||||||
@@ -116,34 +192,42 @@ internal sealed class FrontierPacker
|
|||||||
new ParallelOptions { CancellationToken = token },
|
new ParallelOptions { CancellationToken = token },
|
||||||
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private (Region, PointD)? Choose(List<Region> states, double front)
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
{
|
{
|
||||||
Region? bestRegion = null;
|
IReadOnlyList<Placed>? bestParts = 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;
|
||||||
|
|
||||||
foreach (var region in states)
|
void Consider(Func<IReadOnlyList<Placed>> build, int typeIndex, double area, double advance, double side, double lead)
|
||||||
{
|
{
|
||||||
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
|
var priority = types[typeIndex].Part.Priority;
|
||||||
continue;
|
if (priority > bestPriority)
|
||||||
var area = types[region.Orientation.TypeIndex].Area;
|
return;
|
||||||
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 = bestRegion == null
|
var better = bestParts == null
|
||||||
|| priority < bestPriority
|
|| priority < bestPriority
|
||||||
|| (fills && !bestFills)
|
|| (fills && !bestFills)
|
||||||
|| (
|
|| (
|
||||||
@@ -157,17 +241,83 @@ internal sealed class FrontierPacker
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (!better)
|
if (!better)
|
||||||
continue;
|
return;
|
||||||
bestRegion = region;
|
bestParts = build();
|
||||||
bestPriority = priority;
|
bestPriority = priority;
|
||||||
bestPoint = point;
|
|
||||||
bestFills = fills;
|
bestFills = fills;
|
||||||
bestValue = value;
|
bestValue = value;
|
||||||
bestSide = side;
|
bestSide = side;
|
||||||
bestLead = lead;
|
bestLead = lead;
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestRegion == null ? null : (bestRegion, bestPoint);
|
foreach (var region in states)
|
||||||
|
{
|
||||||
|
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>
|
||||||
@@ -177,9 +327,10 @@ internal sealed class FrontierPacker
|
|||||||
private PathsD free;
|
private PathsD free;
|
||||||
private RectD bounds;
|
private RectD bounds;
|
||||||
|
|
||||||
public Region(Orientation orientation, Box work)
|
public Region(Orientation orientation, Box work, bool single)
|
||||||
{
|
{
|
||||||
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;
|
||||||
@@ -203,6 +354,10 @@ 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)
|
||||||
@@ -219,18 +374,54 @@ 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)
|
||||||
|
{
|
||||||
|
var o = Orientation;
|
||||||
|
return BestVertex(free, minX, minY, maxX, maxY, (o.MinX, o.MinY, o.MaxX, o.MaxY),
|
||||||
|
axis, front, out point, out advance, out side, out lead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
point = default;
|
||||||
advance = side = lead = double.PositiveInfinity;
|
advance = side = lead = double.PositiveInfinity;
|
||||||
var found = false;
|
var found = false;
|
||||||
var o = Orientation;
|
|
||||||
foreach (var path in free)
|
foreach (var path in free)
|
||||||
foreach (var raw in path)
|
foreach (var raw in path)
|
||||||
{
|
{
|
||||||
@@ -239,15 +430,15 @@ internal sealed class FrontierPacker
|
|||||||
double reach, across, start;
|
double reach, across, start;
|
||||||
if (axis == PackAxis.X)
|
if (axis == PackAxis.X)
|
||||||
{
|
{
|
||||||
reach = x + o.MaxX;
|
reach = x + box.MaxX;
|
||||||
across = y + o.MinY;
|
across = y + box.MinY;
|
||||||
start = x + o.MinX;
|
start = x + box.MinX;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
reach = y + o.MaxY;
|
reach = y + box.MaxY;
|
||||||
across = x + o.MinX;
|
across = x + box.MinX;
|
||||||
start = y + o.MinY;
|
start = y + box.MinY;
|
||||||
}
|
}
|
||||||
var adv = System.Math.Max(0, reach - front);
|
var adv = System.Math.Max(0, reach - front);
|
||||||
var better = !found
|
var better = !found
|
||||||
@@ -263,5 +454,52 @@ internal sealed class FrontierPacker
|
|||||||
}
|
}
|
||||||
return found;
|
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,9 +48,10 @@ 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);
|
||||||
var solver = new Solver(job, types, progress, token);
|
using var solver = new Solver(job, types, progress, token);
|
||||||
|
|
||||||
// Demand that no offered stock can hold in any allowed orientation is reported unplaced.
|
// Pair-only orientations may fit stock even when the sampled single poses do not.
|
||||||
|
// 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)
|
||||||
{
|
{
|
||||||
@@ -58,7 +59,8 @@ 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 ? type.Part.Quantity : 0;
|
demand[type.Index] = placeable || solver.PairFits(type)
|
||||||
|
|| (type.Part.Quantity > 2 && type.Orientations.Count > 0) ? type.Part.Quantity : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Plan? best = null;
|
Plan? best = null;
|
||||||
@@ -85,10 +87,26 @@ 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();
|
||||||
|
|
||||||
@@ -157,6 +175,30 @@ 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
|
||||||
@@ -199,7 +241,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), stock, axis, beta, Work);
|
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
|
||||||
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)));
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
#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,6 +41,9 @@ 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>
|
||||||
@@ -97,11 +100,32 @@ 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 { Index = index, Part = part, Area = area, Orientations = orientations });
|
types.Add(new PartType
|
||||||
|
{
|
||||||
|
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,6 +215,145 @@ 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()
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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,6 +97,80 @@ 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(() =>
|
||||||
{
|
{
|
||||||
@@ -233,11 +307,10 @@ public class PlateOverlapOverlayTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("add")]
|
|
||||||
[InlineData("remove")]
|
[InlineData("remove")]
|
||||||
[InlineData("replace")]
|
[InlineData("replace")]
|
||||||
[InlineData("clear")]
|
[InlineData("clear")]
|
||||||
public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
|
public void SlotChangingCollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
|
||||||
{
|
{
|
||||||
using var run = new OverlayRun();
|
using var run = new OverlayRun();
|
||||||
run.Finish();
|
run.Finish();
|
||||||
@@ -247,7 +320,6 @@ 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;
|
||||||
@@ -257,6 +329,23 @@ 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(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 PlateOverlapReport pathReport;
|
private IReadOnlyList<PlateOverlapPair> pathPairs;
|
||||||
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,7 +139,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -204,13 +203,22 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
{
|
{
|
||||||
materialCache.Clear();
|
materialCache.Clear();
|
||||||
baseline = null;
|
baseline = null;
|
||||||
Invalidate();
|
state.Invalidate();
|
||||||
|
CancelWorker();
|
||||||
|
ReleasePath();
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Invalidate()
|
public void Invalidate()
|
||||||
{
|
{
|
||||||
var generation = state.Generation;
|
var generation = state.Generation;
|
||||||
state.Invalidate();
|
var display = state.DisplayPairs;
|
||||||
|
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
|
||||||
@@ -223,7 +231,6 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CancelWorker();
|
CancelWorker();
|
||||||
ReleasePath();
|
|
||||||
NotifyChanged();
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +257,8 @@ 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;
|
||||||
@@ -343,7 +352,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.Report != null)
|
if (state.DisplayPairs.Count > 0)
|
||||||
{
|
{
|
||||||
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
|
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
|
||||||
{
|
{
|
||||||
@@ -366,11 +375,16 @@ 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;
|
||||||
@@ -457,7 +471,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.Report.Pairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
|
foreach (var pair in state.DisplayPairs.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))
|
||||||
@@ -532,13 +546,13 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
|
|
||||||
private void EnsurePath()
|
private void EnsurePath()
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(pathReport, state.Report) && pathScale == view.ViewScale && path != null)
|
if (ReferenceEquals(pathPairs, state.DisplayPairs) && 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.Report.Pairs)
|
foreach (var pair in state.DisplayPairs)
|
||||||
{
|
{
|
||||||
foreach (var region in pair.Regions)
|
foreach (var region in pair.Regions)
|
||||||
{
|
{
|
||||||
@@ -560,7 +574,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
path = next;
|
path = next;
|
||||||
pathReport = state.Report;
|
pathPairs = state.DisplayPairs;
|
||||||
pathScale = view.ViewScale;
|
pathScale = view.ViewScale;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -592,7 +606,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
{
|
{
|
||||||
path?.Dispose();
|
path?.Dispose();
|
||||||
path = null;
|
path = null;
|
||||||
pathReport = null;
|
pathPairs = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ 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) clears the overlay and shows
|
(add, remove, reorder, move, rotate, fill) invalidates the full report and shows
|
||||||
**Overlaps: check pending…**; once the layout has been unchanged for 0.5 s and no
|
**Overlaps: check pending…**, but highlights for unchanged pairs remain visible.
|
||||||
|
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
|
||||||
@@ -211,8 +213,21 @@ 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 and baseline, so in-place program
|
`InvalidateOverlapCheck()` clears the cache, baseline and every highlight, so
|
||||||
editors must keep calling it before loading.
|
in-place program 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
|
||||||
@@ -227,7 +242,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 report/scale changes,
|
already applies origin translation. Paths are rebuilt for displayed-pair/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.
|
||||||
@@ -258,7 +273,8 @@ 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, stale clearing, cancellation, and incomplete-versus-clear messaging.
|
freshness, per-pair display retention through repeated edits and pending checks,
|
||||||
|
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
|
||||||
@@ -277,7 +293,8 @@ 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, stale clearing during edits/plate switches, converter cancellation,
|
alignment, changed-pair clearing and unchanged-pair retention during edits and
|
||||||
|
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.
|
||||||
|
|||||||
+25
-2
@@ -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, six whole-job strategy variants and a tail re-plan |
|
| 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 |
|
||||||
| 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,10 +26,33 @@ 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. Concave no-fit polygons are prepared with a single boundary/containment union.
|
enclosed cutouts. For a part with two or more copies it also offers its best-fit pairs (two copies
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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.
Reference in New Issue
Block a user