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